diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 9f9bb91d..e87a2a86 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -39,15 +39,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // ピン留めプロンプトのキャッシュ(パフォーマンス向上のため) private var pinnedPromptsCache: [PromptHistoryItem] = [] - private static func makeCandidateWindow(contentViewController: NSViewController, inputClient: IMKTextInput?) -> NSWindow { + private static func makeCandidateWindow(contentViewController: NSViewController) -> NSWindow { let window = NSWindow(contentViewController: contentViewController) window.styleMask = [.borderless] window.level = .popUpMenu - var rect: NSRect = .zero - inputClient?.attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - rect.size = candidateWindowInitialSize - window.setFrame(rect, display: true) + // Chromium 系アプリの deadlock 回避のため、初期化時に client への + // 問い合わせを行わない(Chromium issue 503787240)。 + // ウィンドウは直後に orderOut されるため origin はユーザーから不可視であり、 + // 最初の候補表示時に refreshCandidateWindow() で正しい位置に再配置される。 + var frame = NSRect.zero + frame.size = candidateWindowInitialSize + window.setFrame(frame, display: true) window.setIsVisible(false) window.orderOut(nil) return window @@ -121,8 +124,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.liveConversionToggleMenuItem = NSMenuItem() self.transformSelectedTextMenuItem = NSMenuItem() - let textInputClient = inputClient as? IMKTextInput - let candidatesViewController = CandidatesViewController() let predictionViewController = PredictionCandidatesViewController() let replaceSuggestionsViewController = ReplaceSuggestionsViewController() @@ -131,18 +132,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.predictionViewController = predictionViewController self.replaceSuggestionsViewController = replaceSuggestionsViewController - self.candidatesWindow = Self.makeCandidateWindow( - contentViewController: candidatesViewController, - inputClient: textInputClient - ) - self.predictionWindow = Self.makeCandidateWindow( - contentViewController: predictionViewController, - inputClient: textInputClient - ) - self.replaceSuggestionWindow = Self.makeCandidateWindow( - contentViewController: replaceSuggestionsViewController, - inputClient: textInputClient - ) + self.candidatesWindow = Self.makeCandidateWindow(contentViewController: candidatesViewController) + self.predictionWindow = Self.makeCandidateWindow(contentViewController: predictionViewController) + self.replaceSuggestionWindow = Self.makeCandidateWindow(contentViewController: replaceSuggestionsViewController) // PromptInputWindowの初期化 self.promptInputWindow = PromptInputWindow() @@ -171,14 +163,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s if let client = sender as? IMKTextInput { client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) - var rect: NSRect = .zero - client.attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: rect.origin) - } else { - self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) } - self.refreshCandidateWindow() - self.refreshPredictionWindow() + // Chromium 系アプリで JS コンパイル中に activate された場合、 + // client.attributes(forCharacterIndex:) の同期呼び出しが deadlock を + // 引き起こすため呼び出さない(Chromium issue 503787240)。 + // refreshCandidateWindow / refreshPredictionWindow は composing/selecting 状態で + // client.attributes(...) を呼ぶ経路があるため、activate 中は使わずウィンドウを + // 明示的に閉じる。 + self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + self.candidatesWindow.setIsVisible(false) + self.candidatesWindow.orderOut(nil) + self.candidatesViewController.hide() + self.hidePredictionWindow() } @MainActor diff --git a/azooKeyMacTests/ChromiumDeadlockRegressionTests.swift b/azooKeyMacTests/ChromiumDeadlockRegressionTests.swift new file mode 100644 index 00000000..e1cac0f9 --- /dev/null +++ b/azooKeyMacTests/ChromiumDeadlockRegressionTests.swift @@ -0,0 +1,70 @@ +/// Chromium IME deadlock workaround に関する回帰テスト。 +/// +/// Chrome 等の Chromium 系ブラウザで大規模 JS バンドルのページを開いた直後、 +/// azooKey の activateServer 内で `client.attributes(forCharacterIndex:)` を呼ぶと +/// deadlock が発生する問題(Chromium issue 503787240)を回避するための変更を保護する。 +/// +/// 安全性の根拠: +/// - activateServer が渡す候補配列は空であるため、BaseCandidateViewController の +/// `resizeWindowToFitContent` は `numberOfVisibleRows == 0` で早期 return し、 +/// cursorLocation は window 位置計算に使われない。 +/// - この事実が変わらない限り、activateServer 時の client 問い合わせを削除しても +/// 機能に影響がない。 + +import XCTest +import Core +import KanaKanjiConverterModuleWithDefaultDictionary +@testable import azooKeyMac + +@MainActor +final class ChromiumDeadlockRegressionTests: XCTestCase { + /// 空の候補配列で updateCandidatePresentations を呼んだ後、 + /// numberOfVisibleRows が 0 であることを確認する。 + /// + /// これにより、resizeWindowToFitContent の `numberOfVisibleRows == 0` での + /// 早期 return が継続的に機能することを保護する。 + func test空配列でupdateCandidatePresentationsを呼ぶとnumberOfVisibleRowsが0になる() { + let vc = CandidatesViewController() + _ = vc.view // loadView を強制実行 + vc.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + XCTAssertEqual( + vc.numberOfVisibleRows, + 0, + "空の候補配列では numberOfVisibleRows は 0 でなければならない" + ) + } + + /// 空の候補配列で updateCandidatePresentations を呼んだ後、 + /// candidates プロパティが空であることを確認する。 + func test空配列でupdateCandidatePresentationsを呼ぶとcandidatesが空になる() { + let vc = CandidatesViewController() + _ = vc.view + vc.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + XCTAssertTrue(vc.candidates.isEmpty, "空の候補配列を渡した後、candidates は空でなければならない") + } + + /// 0 より多い候補配列を渡した後に空配列で更新すると、 + /// numberOfVisibleRows が 0 に戻ることを確認する。 + func test候補が存在する状態から空配列に更新するとnumberOfVisibleRowsが0になる() { + let vc = CandidatesViewController() + _ = vc.view + let dummy = CandidatePresentation( + candidate: Candidate( + text: "テスト", + value: 0, + composingCount: .surfaceCount(3), + lastMid: 0, + data: [] + ) + ) + vc.updateCandidatePresentations([dummy], selectionIndex: nil, cursorLocation: .zero) + XCTAssertEqual(vc.numberOfVisibleRows, 1, "候補が1件の状態を前提とする") + + vc.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + XCTAssertEqual( + vc.numberOfVisibleRows, + 0, + "空配列に更新した後は numberOfVisibleRows が 0 でなければならない" + ) + } +}