概要
プロセス分離(#335 , v0.1.5-beta.1)以降、ConverterServer が ConverterSession を解放しません。
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.plist(KeepAlive=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 はテキスト入力クライアントごとにコントローラを生成し、activateServer で openSession する
azooKeyMacInputController.swift:187 deactivateServer
.lifecycle(.deactivate) を送るのみで closeSession() を呼ばない
ConverterServerClient.swift:62 closeSession()
呼び出し元がゼロ。リポジトリ全体を grep しても定義しかヒットしない
azooKeyMac/InputController/
deinit が1つも存在しない
main.swift:257-266 ServiceDelegate
shouldAcceptNewConnection で invalidationHandler と interruptionHandler を設定していない。クライアントプロセスが死んでもサーバは sessions を保持し続ける
ConverterServerClient.swift:308 resetConnection
sessionID を nil にするだけでサーバ側を閉じない。XPC リセットのたびに孤児セッションが残る
launchd plist
KeepAlive=true で idle exit もないため、プロセスが再起動せずリークが累積する
当該マシンでは、azooKeyMac 本体が再起動したあとも、起動しっぱなしの ConverterServer が旧プロセス分のセッションを保持していました。
クライアントの終了方法に関係なくサーバ側で回収する仕組みがない点が、リークの本体だと考えています。
メインスレッドの同期ブロック
beta.1 では ConverterServerClient.waitForResult が DispatchSemaphore で呼び出しスレッドを止めていました(syncTimeout = 0.8)。
これを @MainActor override func handle(_ event:client:) -> Bool から sendSync と sendIfSessionOpenSync 経由で10箇所呼んでいます。
1打鍵あたり最大1.6秒(openSession 0.8 + command 0.8)メインスレッドが停止します。
この同期経路は #345 で全廃されているため、こちらは解決済みと理解しています。
以下では、これがセッション解放漏れの増幅器として働く点だけを述べます。
なぜ回復しないのか
実測できたのは「アイドル時に毎分6個読み込まれ続ける」という結果までで、以下はコードから読み取った推定です。
サーバがページインで遅くなり、openSession の応答が 0.8 秒を超えます。
クライアントはタイムアウトして失敗と判断しますが、サーバ側は要求を受理済みなので、セッションを作ってモデルを読み込みます。
クライアントは recordReconnectFailure() を経て2秒後に再接続を試みます。
サーバはモデルを1つ多く抱えてさらに遅くなり、1 に戻ります。
失敗するたびにモデルが1つ増えるため、一度この状態に入ると 0.8 秒以内に応答できる状態へは戻れないと考えられます。
killall ConverterServer で launchd が張り直すと入力は正常化しますが、しばらくすると再発します。
再現手順
v0.1.5-beta.1 をインストールし、ConverterServer を起動状態にします。
複数のアプリでテキスト入力欄にフォーカスを移します(アプリ切り替えを繰り返す)。
次のコマンドを繰り返し実行します。
$ vmmap $( pgrep ConverterServer) | grep -c ggml-model
値が単調増加して減らなければ再現しています。
正常なら 1 であるべきで、N になっていれば N 個のセッションがリークしています。
修正案
クライアント側で解放するだけでは、プロセスが落ちた場合を回収できません。
サーバ側での回収が必要だと考えています。
ServiceDelegate で接続ごとに生成した sessionID を追跡し、invalidationHandler と interruptionHandler で sessions.removeValue(forKey:) する。
クライアントの終了方法に依存せず回収できる唯一の方法なので、これを最優先と考えます。
azooKeyMacInputController の deinit と applicationWillTerminate で closeSession() を呼ぶ。
closeSession() は実装済みで、呼ぶだけの状態です。
zenzai の llama コンテキストを、セッション単位ではなくサーバ全体で共有する。
1プロセスが同一モデルを何十個も保持する必要はなく、これ単体でも被害を大きく減らせます。
openSession にセッション数の上限を設ける、または launchd に idle exit や定期再起動を入れる。
概要
プロセス分離(#335, v0.1.5-beta.1)以降、
ConverterServerがConverterSessionを解放しません。makeSegmentsManager()がuseZenzai: trueを固定で渡すため、セッション1つにつき llama.cpp の GGUF モデルが1つ読み込まれます。セッションを閉じる経路が実装上どこにも存在しないため、モデルが単調に積み上がります。
最終的に物理メモリに乗り切らなくなり、変換のたびにページインが発生して入力がハングします。
この問題は
main(3ae5a46, 2026-08-02)でも未修正です。#345 は同期経路を全廃して応答性を改善していますが、セッションの解放そのものには触れていません。
環境
v0.1.5-beta.1= 521425f、PR feat: apply process separation #335 のビルド)/Library/Input Methods/azooKeyMac.app/Library/LaunchAgents/dev.ensan.inputmethod.azooKeyMac.ConverterServer.plist(KeepAlive=true、idle exit なし)症状
日本語入力が数秒単位で固まります。
変換候補が出るまで待たされ、キー入力が取りこぼされます。
クラッシュはしません。
~/Library/Logs/DiagnosticReportsにレポートは残らず、ConverterServerプロセスも生き続けます。稼働時間が長いほど悪化します。
実測データ
同一プロセス(PID 910、連続稼働3日23時間)を1時間49分の間隔で2回計測しました。
llama_init_from_model累計同一の GGUF ファイルが 224 箇所に個別にマップされています。
70.3MB × 224 でおよそ 15.7 GB になり、物理メモリに乗り切らずほぼ全量がスワップアウトされています。
RSS が 1.02 GB から 328 MB へ減って見えるのは、モデル領域が丸ごと追い出されたためです。
変換のたびにスワップから読み直しており、これがストールの直接の原因になっています。
アイドル時にもセッションが増え続ける
日本語入力を一切していない状態で30秒間計測しました。
操作していないあいだも、毎分6個のペースでモデルが読み込まれ続けています。
ユーザー操作起因ではありません。
セッションが解放されない
セッションごとに zenzai モデルを作る
Core/Sources/ConverterServer/ConverterServer+Snapshot.swift:89-108(main)Core/Sources/ConverterServer/main.swift:20-28(main、beta.1 と同一)呼ばれるたびに、無条件で新しいセッションと新しいモデルを作ります。
解放する経路が存在しない
azooKeyMacInputController.swift:7let converterServerClient = ConverterServerClient()がコントローラのインスタンス変数。IMKit はテキスト入力クライアントごとにコントローラを生成し、activateServerでopenSessionするazooKeyMacInputController.swift:187deactivateServer.lifecycle(.deactivate)を送るのみでcloseSession()を呼ばないConverterServerClient.swift:62closeSession()azooKeyMac/InputController/deinitが1つも存在しないmain.swift:257-266ServiceDelegateshouldAcceptNewConnectionでinvalidationHandlerとinterruptionHandlerを設定していない。クライアントプロセスが死んでもサーバはsessionsを保持し続けるConverterServerClient.swift:308resetConnectionsessionIDを nil にするだけでサーバ側を閉じない。XPC リセットのたびに孤児セッションが残るKeepAlive=trueで idle exit もないため、プロセスが再起動せずリークが累積する当該マシンでは、
azooKeyMac本体が再起動したあとも、起動しっぱなしのConverterServerが旧プロセス分のセッションを保持していました。クライアントの終了方法に関係なくサーバ側で回収する仕組みがない点が、リークの本体だと考えています。
メインスレッドの同期ブロック
beta.1 では
ConverterServerClient.waitForResultがDispatchSemaphoreで呼び出しスレッドを止めていました(syncTimeout = 0.8)。これを
@MainActor override func handle(_ event:client:) -> BoolからsendSyncとsendIfSessionOpenSync経由で10箇所呼んでいます。1打鍵あたり最大1.6秒(openSession 0.8 + command 0.8)メインスレッドが停止します。
この同期経路は #345 で全廃されているため、こちらは解決済みと理解しています。
以下では、これがセッション解放漏れの増幅器として働く点だけを述べます。
なぜ回復しないのか
実測できたのは「アイドル時に毎分6個読み込まれ続ける」という結果までで、以下はコードから読み取った推定です。
openSessionの応答が 0.8 秒を超えます。recordReconnectFailure()を経て2秒後に再接続を試みます。失敗するたびにモデルが1つ増えるため、一度この状態に入ると 0.8 秒以内に応答できる状態へは戻れないと考えられます。
killall ConverterServerで launchd が張り直すと入力は正常化しますが、しばらくすると再発します。再現手順
ConverterServerを起動状態にします。$ vmmap $(pgrep ConverterServer) | grep -c ggml-model値が単調増加して減らなければ再現しています。
正常なら 1 であるべきで、N になっていれば N 個のセッションがリークしています。
修正案
クライアント側で解放するだけでは、プロセスが落ちた場合を回収できません。
サーバ側での回収が必要だと考えています。
ServiceDelegateで接続ごとに生成した sessionID を追跡し、invalidationHandlerとinterruptionHandlerでsessions.removeValue(forKey:)する。クライアントの終了方法に依存せず回収できる唯一の方法なので、これを最優先と考えます。
azooKeyMacInputControllerのdeinitとapplicationWillTerminateでcloseSession()を呼ぶ。closeSession()は実装済みで、呼ぶだけの状態です。1プロセスが同一モデルを何十個も保持する必要はなく、これ単体でも被害を大きく減らせます。
openSessionにセッション数の上限を設ける、または launchd に idle exit や定期再起動を入れる。