Skip to content

[bug] ConverterServer が ConverterSession を解放せず、zenzai モデルが蓄積して入力がハングする #347

Description

@L4Ph

概要

プロセス分離(#335, v0.1.5-beta.1)以降、ConverterServerConverterSession を解放しません。

makeSegmentsManager()useZenzai: true を固定で渡すため、セッション1つにつき llama.cpp の GGUF モデルが1つ読み込まれます。
セッションを閉じる経路が実装上どこにも存在しないため、モデルが単調に積み上がります。
最終的に物理メモリに乗り切らなくなり、変換のたびにページインが発生して入力がハングします。

この問題は main(3ae5a46, 2026-08-02)でも未修正です。
#345 は同期経路を全廃して応答性を改善していますが、セッションの解放そのものには触れていません。

環境

  • azooKeyMac v0.1.5-beta.1(tag v0.1.5-beta.1 = 521425f、PR feat: apply process separation #335 のビルド)
  • macOS 26.5.2 (25F84), Apple M3 Pro
  • インストール先:/Library/Input Methods/azooKeyMac.app
  • /Library/LaunchAgents/dev.ensan.inputmethod.azooKeyMac.ConverterServer.plistKeepAlive=true、idle exit なし)

症状

日本語入力が数秒単位で固まります。
変換候補が出るまで待たされ、キー入力が取りこぼされます。

クラッシュはしません。
~/Library/Logs/DiagnosticReports にレポートは残らず、ConverterServer プロセスも生き続けます。
稼働時間が長いほど悪化します。

実測データ

同一プロセス(PID 910、連続稼働3日23時間)を1時間49分の間隔で2回計測しました。

指標 17:02 18:51
GGUF のマップ数 14 224
llama_init_from_model 累計 56 1,100
stderr ログサイズ 2.0 MB 40 MB
RSS 1.02 GB 328 MB

同一の GGUF ファイルが 224 箇所に個別にマップされています。

$ vmmap $(pgrep ConverterServer) | grep -c ggml-model
224
mapped file  10b498000-10fadc000  [ 70.3M  69.2M ] r--/r-- SM=SHM  .../Contents/Resources/ggml-model-Q5_K_M.gguf
mapped file  112c28000-11726c000  [ 70.3M  70.3M ] r--/r-- SM=SHM  .../Contents/Resources/ggml-model-Q5_K_M.gguf
...(224行)

70.3MB × 224 でおよそ 15.7 GB になり、物理メモリに乗り切らずほぼ全量がスワップアウトされています。

Writable regions: Total=23.7G  resident=777.5M(3%)  swapped_out=7.2G(30%)
vm.swapusage: total = 7168.00M  used = 5883.12M
Pageins: 8,493,827

RSS が 1.02 GB から 328 MB へ減って見えるのは、モデル領域が丸ごと追い出されたためです。
変換のたびにスワップから読み直しており、これがストールの直接の原因になっています。

アイドル時にもセッションが増え続ける

日本語入力を一切していない状態で30秒間計測しました。

loads: 1097 -> 1100 in 30s   => 6.0 models/min
mappings: 222 -> 224

操作していないあいだも、毎分6個のペースでモデルが読み込まれ続けています。
ユーザー操作起因ではありません。

セッションが解放されない

セッションごとに zenzai モデルを作る

Core/Sources/ConverterServer/ConverterServer+Snapshot.swift:89-108(main)

static func makeSegmentsManager() -> SegmentsManager {
    ...
    return SegmentsManager(
        kanaKanjiConverter: KanaKanjiConverter.withDefaultDictionary(),   // 辞書フルセット
        ...
        context: .init(useZenzai: true, resourcesDirectoryURL: appResourcesDirectoryURL())
    )                            // ^^^^^^^^^^^^^^^ セッション単位で zenzai を有効化
}

Core/Sources/ConverterServer/main.swift:20-28(main、beta.1 と同一)

func openSession(with reply: @escaping @Sendable (String) -> Void) {
    DispatchQueue.main.async {
        MainActor.assumeIsolated {
            let sessionID = UUID().uuidString
            self.sessions[sessionID] = ConverterSession(manager: Self.makeSegmentsManager())
            reply(sessionID)
        }
    }
}

呼ばれるたびに、無条件で新しいセッションと新しいモデルを作ります。

解放する経路が存在しない

箇所 状態
azooKeyMacInputController.swift:7 let converterServerClient = ConverterServerClient() がコントローラのインスタンス変数。IMKit はテキスト入力クライアントごとにコントローラを生成し、activateServeropenSession する
azooKeyMacInputController.swift:187 deactivateServer .lifecycle(.deactivate) を送るのみで closeSession() を呼ばない
ConverterServerClient.swift:62 closeSession() 呼び出し元がゼロ。リポジトリ全体を grep しても定義しかヒットしない
azooKeyMac/InputController/ deinit が1つも存在しない
main.swift:257-266 ServiceDelegate shouldAcceptNewConnectioninvalidationHandlerinterruptionHandler を設定していない。クライアントプロセスが死んでもサーバは sessions を保持し続ける
ConverterServerClient.swift:308 resetConnection sessionID を nil にするだけでサーバ側を閉じない。XPC リセットのたびに孤児セッションが残る
launchd plist KeepAlive=true で idle exit もないため、プロセスが再起動せずリークが累積する

当該マシンでは、azooKeyMac 本体が再起動したあとも、起動しっぱなしの ConverterServer が旧プロセス分のセッションを保持していました。
クライアントの終了方法に関係なくサーバ側で回収する仕組みがない点が、リークの本体だと考えています。

メインスレッドの同期ブロック

beta.1 では ConverterServerClient.waitForResultDispatchSemaphore で呼び出しスレッドを止めていました(syncTimeout = 0.8)。
これを @MainActor override func handle(_ event:client:) -> Bool から sendSyncsendIfSessionOpenSync 経由で10箇所呼んでいます。
1打鍵あたり最大1.6秒(openSession 0.8 + command 0.8)メインスレッドが停止します。

この同期経路は #345 で全廃されているため、こちらは解決済みと理解しています。
以下では、これがセッション解放漏れの増幅器として働く点だけを述べます。

なぜ回復しないのか

実測できたのは「アイドル時に毎分6個読み込まれ続ける」という結果までで、以下はコードから読み取った推定です。

  1. サーバがページインで遅くなり、openSession の応答が 0.8 秒を超えます。
  2. クライアントはタイムアウトして失敗と判断しますが、サーバ側は要求を受理済みなので、セッションを作ってモデルを読み込みます。
  3. クライアントは recordReconnectFailure() を経て2秒後に再接続を試みます。
  4. サーバはモデルを1つ多く抱えてさらに遅くなり、1 に戻ります。

失敗するたびにモデルが1つ増えるため、一度この状態に入ると 0.8 秒以内に応答できる状態へは戻れないと考えられます。
killall ConverterServer で launchd が張り直すと入力は正常化しますが、しばらくすると再発します。

再現手順

  1. v0.1.5-beta.1 をインストールし、ConverterServer を起動状態にします。
  2. 複数のアプリでテキスト入力欄にフォーカスを移します(アプリ切り替えを繰り返す)。
  3. 次のコマンドを繰り返し実行します。
$ vmmap $(pgrep ConverterServer) | grep -c ggml-model

値が単調増加して減らなければ再現しています。
正常なら 1 であるべきで、N になっていれば N 個のセッションがリークしています。

修正案

クライアント側で解放するだけでは、プロセスが落ちた場合を回収できません。
サーバ側での回収が必要だと考えています。

  1. ServiceDelegate で接続ごとに生成した sessionID を追跡し、invalidationHandlerinterruptionHandlersessions.removeValue(forKey:) する。
    クライアントの終了方法に依存せず回収できる唯一の方法なので、これを最優先と考えます。
  2. azooKeyMacInputControllerdeinitapplicationWillTerminatecloseSession() を呼ぶ。
    closeSession() は実装済みで、呼ぶだけの状態です。
  3. zenzai の llama コンテキストを、セッション単位ではなくサーバ全体で共有する。
    1プロセスが同一モデルを何十個も保持する必要はなく、これ単体でも被害を大きく減らせます。
  4. openSession にセッション数の上限を設ける、または launchd に idle exit や定期再起動を入れる。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions