Skip to content

fix(runtime): stop the main thread and startForeground waiting on local-runtime work - #412

Merged
yuga-hashimoto merged 2 commits into
mainfrom
fix-fgs-start-timeout
Oct 30, 2026
Merged

yuga-hashimoto merged 2 commits into
mainfrom
fix-fgs-start-timeout

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Fixes the ANRs and ForegroundServiceDidNotStartInTimeException crashes that Crashlytics reports for v1.2.27 (last 14 days). They share one cause: slow local-runtime work running on the main thread, or ahead of startForeground(), where it must not.

Crashlytics issue Events Cause
ANR LocalRuntimeAccessCoordinator.write (2 issues) 3 Compose reads ClaudeCodeTarget.capabilities on the main thread. Its getter went permissionBridgeReady() → installedRuntime(), which takes the runtime write lock and extracts the command suite, so the UI froze behind any running install.
ANR LocalRuntimeManager.readMetadata 1 Every LocalOpenCodeBackend call re-read the runtime metadata and the server password from disk to find its delegate, and ChatViewModel calls it from the main thread.
ForegroundServiceDidNotStartInTimeException — LocalRuntimeService (2 issues), CodexKeepAliveService (1) 7 See below.

The main-thread stacks for the first two rows were read from the ANR events, so those causes are confirmed. For the foreground-service crashes the reports do not say why startForeground() was missed, so this closes the two defects the code shows instead of guessing at one:

  • LocalRuntimeService.onCreate called manager.status() to build the first notification. That reads metadata and probes the server's port (up to 2.5 s) on the main thread, inside the platform's deadline.
  • startForeground() was only called from onCreate. After a Stop, Delete or idle stop the instance outlives stopForeground() until its own stopSelf() lands, and a start arriving in that gap is held to the same deadline without onCreate running again.

What changed

  • ClaudeCodeRuntime.permissionBridgeReady() looks at the hook files directly instead of going through the installer: no lock, no extraction. A file caught mid-write reads as "not ready".
  • LocalOpenCodeBackend resolves its delegate through backend() on Dispatchers.IO. Only the resolution moves; the request itself still runs where the caller put it. events() now resolves lazily inside the flow, so a runtime that is not installed fails the stream where callers' .catch can handle it instead of throwing while the flow is built.
  • LocalRuntimeService builds its first notification from manager.state.value (no I/O) and refreshes the status on a worker. Both services call startForeground() on every onStartCommand.

Not in this PR (known follow-ups)

From the review, none blocking:

  • LocalRuntimeManager.status() can now overlap an operation that the next command launches, so its write can briefly overwrite a fresher Starting with a stale Stopped. Cosmetic and corrected by the watchdog's next tick; compareAndSet would close it.
  • Restore and InstallFullDevelopmentTools in onStartCommand, and several LocalRuntimeTarget methods, still call manager.status() (a port probe plus a metadata read) on the calling thread. That is after startForeground() so it cannot cause the crash, but it can still block the main thread.
  • CodexKeepAliveService logs twice when entering the foreground fails, once from onCreate and once from onStartCommand.
  • The literal "environment/rootfs" is duplicated across the repo; a shared constant would stop a layout change from silently turning the readiness check into false.

Test plan

  • ./gradlew spotlessCheck detekt :app:testGithubDebugUnitTest — 1623 tests, 0 failures. New: ClaudePermissionBridgeReadinessTest (4), LocalOpenCodeBackendThreadingTest (3). The lock-wait and thread tests were written first and failed before the change (TimeoutException while another thread holds the write lock; providers running on the caller's thread).
  • ./gradlew :app:assembleGithubDebug :app:lintGithubDebug — 0 errors, no findings in the changed files
  • Not verified on a device, and the service lifecycle itself has no unit test (it needs a real Service). Worth a manual check that the runtime still starts, stops and restores, including a Stop immediately followed by a start.
  • CI green

Pre-PR review

The repo-reviewer subagent is defined for OpenCode (.opencode/agents/repo-reviewer.md) and is not available in the Claude Code session that produced this change. The review below was done by a read-only Claude Code subagent given that definition as its role and output format. It is a stand-in, not the repo-reviewer subagent itself.

It reviewed commit 28b278d9. The branch has one further commit, test-only, that adds the two events() tests the reviewer recommended before merge; no production code differs between the reviewed commit and HEAD.

Pre-PR review

判定: APPROVE

対象: ブランチ fix-fgs-start-timeout(コミット 28b278d の1件、origin/main の先頭を祖先に持つ)。

ブロッカー

なし

提案(非ブロッキング)

  • LocalOpenCodeBackend.kt:280(events() の遅延解決)— 挙動変更(解決のタイミングとスレッド、失敗が surfaced するタイミング)に対するテストがない。LocalOpenCodeBackendThreadingTest は answerQuestion 1本だけを検証している。JVM だけでテストできるので、次の2点を追加すると堅くなる。マージ前を推奨する。
    • events() を呼んだだけでは portProvider/passwordProvider が呼ばれず、collect で初めて IO スレッド上で呼ばれること。
    • 未インストール(portProvider が null)のとき、エラーが events() の呼び出し時ではなく collect 時に届き、.catch {} で捕捉できること。
  • LocalRuntimeManager.kt:83-92(status())と LocalRuntimeService.kt:327 — 今回 onCreate の manager.status() を IO に移したことで、後続コマンドの操作と並行する可能性が新たに生じた。
    • 以前は onCreate で同期実行され、直後に onStartCommand が launch する操作より必ず先に終わっていた。
    • 今は status() が Installing/Starting/Updating 以外の状態を読んだ直後に、操作側が Starting を書き込み、その後 computeStatus() の古い結果(Stopped など)で上書きされうる。
    • 上書きされても、一時的な通知やウェイクロックの揺れで済み、watchdog が次の tick で直す。ただ、mutableState.compareAndSet(operation, computed) にするか、status() の書き込みを operationMutex 外で行わない形にすると安全。
  • 取りこぼし(今回の変更範囲外、フォローアップで検討)。メインスレッドや呼び出しスレッド上で、ポート接続確認(最大2.5秒)とメタデータ読み込みが残っている。
    • LocalRuntimeService.kt:390(InstallFullDevelopmentTools)と LocalRuntimeService.kt:444(Restore)の manager.status() は、enterForeground() の後なので FGS 期限には影響しないが、onStartCommand(メイン)では依然としてブロックする。launchOperation の中に移すか、manager.state.value を使えば避けられる。
    • LocalRuntimeTarget.kt:79、:95、:100、:127 の runtimeManager.status() も呼び出しスレッドで実行される。connect() は ChatViewModel 系の呼び出し元のスレッドで動く。AntigravityTarget と ClaudeCodeTarget の connect() は、同じ理由で withContext(IO) に包んである。
  • CodexKeepAliveService.kt:36 と :47(onCreate と onStartCommand)— onCreate で enterForeground() が失敗して stopSelf() した場合でも、続けて届く onStartCommand でもう一度 enterForeground() と stopSelf() が走る。ログが二重になるだけで実害はない。LocalRuntimeService のように inForeground ガードを持たせると揃う。
    • また、この Service には stopForeground を呼ぶ経路がない。AMS は「すでに foreground のインスタンスへの2回目の startForegroundService」では startForeground の要求を無視するので、「実行中のインスタンスにも応える」という追加は主に防御的で、現状の実害はない。
  • ClaudeCodeRuntime.kt:813(ACTIVE_ROOTFS)— "environment/rootfs" のリテラルがリポジトリ内で既に8箇所以上重複していて、WorkspaceViewModel:403 には RUNTIME_ROOTFS_PATH もある。既存の流儀と揃っているのでブロッカーではないが、定数を共通化すると、LocalRuntimeInstaller のレイアウト変更で読み取り側が黙って false を返すドリフトをコンパイル時に防げる。テスト側(rootfs())も同じリテラルをハードコードしているため、いまはドリフトを検出できない。

チェック済み項目

  • (a) permissionBridgeReady の新実装:
    • 指す場所は旧実装と同じ。LocalRuntimeInstaller.installedRuntime() の rootfs は File(File(runtimeDirectory,"environment"),"rootfs") で、ClaudeCodeRuntime の runtimeDirectory は AndCodeApplication.kt:331 でインストーラと同じものを受け取っている。LocalRuntimeManager.computeStatus(:622)や ClaudeCodeTarget:237 も同じリテラルを使っている。
    • 長いインストールの本体は environment.staging に展開され、アクティブな環境が切り替わるのは activateRuntimeEnvironment の rename の瞬間だけ。ただし installFullDevelopmentTools や Claude のインストールは、アクティブな rootfs を書き込みロック下でその場で変更する。この途中で読むと、フック欠落や設定ファイルの途中書きで false になる。
    • 例外は runCatching で false に畳まれる。フックの File.isFile/readText は旧実装も読んでいた同じ処理。
    • 挙動差は2点。①旧実装は「メタデータ有効かつ commandSuite 展開済み」でも false を返したが、新実装はフックのファイルだけを見る。ただし capabilities が参照する機能は Claude Code が動くときだけ意味を持つので影響はない。②旧実装は呼び出し時に normalizeRuntimeSymlinks と ensureAndCodeAgentContext の副作用が付いてきたが、新実装にはない。フックのパス(usr/local/bin/...、root/.claude/settings.json)に内部シンボリックリンクの正規化が必要なレイアウトは通常ない。
    • 注意点: capabilities は Compose 上で observable ではないため、切替中に一度 false を読むと、次の再コンポーズまで引きずられる可能性がある。UI の supportsPermissions が一時的に違う表示になるだけで、ロック待ちの ANR と比べて許容できる。
    • 同種の取りこぼしを網羅確認した。capabilities の getter は、Claude 以外は定数(Codex・Antigravity)か、キャッシュ済みの backend.protocol を読むだけ(Local・Remote)で、ロックやディスクには触れない。ClaudeCodeTarget の connect・health・commands・skills は withContext(IO) 済み。installedRuntime() を呼ぶ他の呼び出し元(AndCodeApplication.kt:442 など)は、いずれも IO 側かコレクタ内。
  • (b) LocalOpenCodeBackend の backend() 化:
    • delegate() を直接呼ぶ箇所は、定義(:63)以外にない(grep 済み)。internal の delegate() 自体は同期のまま。テストは通っている。
    • 例外の型と伝播は保たれる。withContext は元の例外を再スローし、runCatching を使う呼び出し元(ChatViewModel の health ループ、LocalRuntimeTarget.connect:112 など)の挙動も変わらない。リクエスト本体は呼び出し元のディスパッチャで実行される。events() は backend() を完了させてから emitAll するので、フローのコンテキスト保存不変条件にも違反しない。ScheduleExecutionService の CompletionSignal による collect 中断も、そのまま伝播する。
    • events() の呼び出し元4箇所(ChatViewModel:778、ScheduleExecutionService:510、RuntimeActivityRepository:210、AndCodeVoiceSession:227)を実際に開いて確認した。どれも失敗を collect 側で受けられる。ChatViewModel と AndCodeVoiceSession は .catch が events() の後ろに付く構成で、旧実装では未インストール時の同期 throw が .catch の外に出ていたが、新実装では .catch で捕捉される。ScheduleExecutionService は try の中、RuntimeActivityRepository は flow { emitAll(...) }.retryWhen の中なので、旧実装・新実装とも捕捉される。つまり例外処理は退行せず、むしろ堅くなっている。
    • 並行性: delegate() はこれまでメインで直列に呼ばれていたが、IO 上では並行に呼ばれる。内部は二重チェックロックで安全。LocalServerAuth.password も生成時は synchronized。
  • (c) LocalRuntimeService の onStartCommand での enterForeground():
    • inForeground の意味は「onCreate での昇格に成功した」から、「直近の startForeground が成功した」に変わった。ただし読み取りは LocalRuntimeService.kt:322 と :377 の2箇所だけで、stopForeground(REMOVE) で false に戻さなくても、onStartCommand が毎回再アサートするので矛盾しない。
    • onCreate で昇格失敗(inForeground=false)の場合は || の左辺で即 stopSelf() するため、コレクタやウォッチドッグが未起動のまま enterForeground() を再試行して中途半端に動く事態はない。
    • Stop 後の再通知: Stop コマンド自体も enterForeground() を通るため、manager.state.value で一度通知が再投稿され、その後に stopForeground(REMOVE) で消える。ユーザーが FGS 通知をスワイプで消していた場合は復活する。stopForeground(REMOVE) から stopSelf() が着地するまでの隙間に届いた Start は、再昇格できて期限切れは避けられる。その後 stopSelf() が効くと Start 自体は取り消される。これは旧実装から続く競合で、今回の変更で悪化はしていない。
    • startForeground が再入で失敗した場合は、inForeground=false にして stopSelf() する。実行中の操作は onDestroy で中断される。ただし、すでに foreground のサービスへの2回目の startForeground が投げる経路は現実的にほぼなく、期限切れで殺されるよりは安全側の判断。
    • manager.state.value が古い場合の通知内容: state の初期値は MutableStateFlow(computeStatus())(LocalRuntimeManager.kt:77)で、アプリ起動時点のものなので、新規プロセスでは新鮮。長寿命プロセスでも、稼働中はウォッチドッグが status() で更新し続ける。プロセスが生きたままサービスだけが止まっていた後は、古い Ready が一瞬表示されうるが、onCreate の IO status()(:327)が更新し、:329-336 のコレクタが再描画する。FGS の種類は specialUse で、dataSync のようなタイムアウトを持たず、onTimeout の実装も不要。
    • IO の manager.status() の効果は、上記の「更新と再描画」だけ。コレクタは IO の launch より先に購読される(Main.immediate、かつ同一 onCreate 内)。
  • (d) テストの妥当性:
    • ClaudePermissionBridgeReadinessTest は主張どおり。フック有りで true(プロバイダを呼べば error で落ちる)、フック無し・未インストールで false、書き込みロックを別スレッドが保持している間もメインに相当するスレッドが3秒以内に応答する(旧実装は coordinator.write を踏み、get(3s) で TimeoutException になる)。finally で必ずロックを解放して後始末している。
    • LocalOpenCodeBackendThreadingTest は、portProvider と passwordProvider が呼び出しスレッド以外で実行されることを検証していて、旧実装では落ちる設計。ただし events() は未検証(上記の提案)。
    • サービスのライフサイクルが単体テスト不可という説明は妥当で、PR 本文の「実機未検証、サービスのライフサイクルは単体テストなし」の明記で十分。
  • ハードルール: ユーザー向け文言の追加なし(i18n 影響なし)、シークレット混入なし、コミットは1件、worktree は scripts/new-worktree.sh の流儀(.worktree/ 配下、origin/main 基点)。spotless・detekt・単体テスト(1621件)は依頼文の報告に依拠しており、再実行はしていない(読み取り専用のため)。assembleGithubDebug と lintGithubDebug は実行中とのことなので、マージ前に緑であることを確認してほしい。

🤖 Generated with Claude Code

yuga-hashimoto and others added 2 commits October 30, 2026 23:18
…al-runtime work

Crashlytics (14 days, v1.2.27) shows two groups of failures that share one
cause, slow local-runtime work running where it must not:

ANRs with the main thread parked in our own code
- LocalRuntimeAccessCoordinator.write (2 users): Compose reads
  ClaudeCodeTarget.capabilities on the main thread. Its getter went
  permissionBridgeReady() -> installedRuntime(), which takes the runtime
  *write* lock and extracts the command suite, so the UI froze behind any
  running install. The hook check only needs the files, so it now reads
  them directly, without the lock.
- LocalRuntimeManager.readMetadata (1 user): ChatViewModel calls the local
  backend from the main thread and every call re-read the metadata and the
  server password from disk to find the delegate. The resolution now runs on
  Dispatchers.IO; the request itself stays where the caller put it.

ForegroundServiceDidNotStartInTimeException (LocalRuntimeService x2,
CodexKeepAliveService x1)
The reports do not say why startForeground was missed, so this closes the two
defects the code shows rather than guessing at one:
- LocalRuntimeService.onCreate called manager.status() to build the first
  notification. That reads metadata and probes the server's port (up to 2.5 s)
  on the main thread, inside the platform's deadline. It now uses the last
  known status and refreshes it on a worker.
- startForeground was only called from onCreate. After a Stop, Delete or idle
  stop the instance outlives stopForeground() until its own stopSelf() lands,
  and a start arriving in that gap is held to the same deadline without
  onCreate running again. onStartCommand now answers every start, in both
  services.

Tests: ClaudePermissionBridgeReadinessTest reproduces the lock wait with a
thread holding the write lock; LocalOpenCodeBackendThreadingTest asserts the
port and password are read off the caller's thread. Both failed before the
change. The service lifecycle itself is not unit-testable here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ckend.events()

The previous commit made events() resolve the local server inside the flow
instead of while the flow is built. Pre-PR review asked for that change in
timing to be covered before merge:

- events() alone does not call the port or password providers; collecting
  does, on a worker thread rather than the collector's.
- a runtime that is not installed now fails the stream at collection, where
  the .catch callers chain after events() can handle it, instead of throwing
  while the flow is being built.

Test-only; production code is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit db42ebd into main Oct 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant