Repository navigation
perf: park always-on bridge polls while nothing can write to them - #400
Merged
yuga-hashimoto merged 4 commits intoOct 24, 2026
Merged
Conversation
Three file polls between the app and the in-guest agent ran for the life of the process, waking it every few hundred milliseconds whether or not anything could have been written: - Claude Code permission bridge: every 250 ms, started by the first session and never cancelled, even after every session had ended. - Schedule bridge: every 800 ms on the application scope. - Guest browser command watcher: every 1 s from a LaunchedEffect, which keeps running while the activity is stopped. Add Flow<Boolean>.pollWhileActive (poll at once when the gate opens, then every interval, and stay suspended with no timer while it is closed) and gate each loop on the condition under which a writer can exist: - permission bridge: a CLI process is registered. The hook that parks a request dies with its process, so with none alive nothing can appear. - schedule bridge: the app is in the foreground or runtimeWork is active. With neither, the device may suspend and the guest freezes with it. - browser command watcher: the app is in the foreground. ScheduleBridge.run now requires the gate rather than defaulting to "always" so a future call site cannot quietly bring the unbounded loop back. Covered by PollWhileActiveTest plus a gating test each for the schedule bridge and the browser watcher. Disabling the gate makes exactly those five fail. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ption-optimization-961b0f
Follow-ups from the pre-PR review of the polling gate. - ScheduleBridge: with the poll parked while the app is backgrounded and idle, a request written in that window used to run on the way back in even though the guest MCP had already given up after 90 s and told the agent the call failed. A create/delete/runNow the agent was told did not happen would then happen anyway, and a retry could duplicate it. Requests older than two minutes are now answered with an "expired" error and discarded. A missing or zero createdAtMs is "unknown" and still runs. - ClaudeCodeRuntime: route every change to the session map through registerSession/unregisterSession, which keep hasLiveSessions in step, so a future edit cannot forget to publish and leave permission prompts silently unanswered. - KDoc: say what pollWhileActive does to a suspending action, and that the browser watcher's foreground flow must be a stable reference. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review follow-up. The expiry added for requests that sat unanswered while the bridge poll was parked was two minutes against the guest's 90 s reply timeout, which left a 90-120 s window where a request ran after its caller had already given up. The stated reason for the extra headroom, clock drift, does not apply: the guest runs under PRoot and reads the same kernel clock as the app. - REQUEST_EXPIRES_AFTER_MILLIS is now 95 s, with a note to keep it in step with RESPONSE_TIMEOUT_S in andcode-schedule-mcp.py. - Tests pin the boundary and the cases that must still run: a timestamp in the future and a request with no timestamp at all. - docs/LOCAL_RUNTIME.md says when the bridge polls and that stale requests are discarded. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three file polls between the app and the in-guest agent ran for the life of the process, waking it every few hundred milliseconds whether or not anything could have been written:
ClaudeCodeRuntime)ScheduleBridge)runtimeWorkis activeLaunchedEffect, which keeps running while the activity is stoppedFlow<Boolean>.pollWhileActive(intervalMillis, action)is the shared gate: it polls at once when the gate opens, then every interval, and stays suspended with no timer while the gate is closed.ScheduleBridge.runnow requires the gate instead of defaulting to "always", so a future call site cannot quietly bring the unbounded loop back.Why the gates are safe
pendingQuestions), so with no process registered nothing can appear. The state is published from the only two places that changesessions, under the same monitor.runtimeWorklease, the device is free to suspend and the guest freezes with it, so there is nobody to answer. Chat/voice sessions ("sessions"lease), scheduled runs (schedule:<id>), installs, ADB and terminal commands all hold leases.Also in this PR
RESPONSE_TIMEOUT_S) are answered with an "expired" error and discarded instead of run. Without this, a request written while the poll was parked would run on the way back in, after the guest had already told the agent the call failed. A missing, zero or futurecreatedAtMsstill runs.ClaudeCodeRuntimechangessessionsonly throughregisterSession/unregisterSession, which keep thehasLiveSessionsflag in step.docs/LOCAL_RUNTIME.mdsays when the schedule bridge polls.What to expect
These are small wakeups: a directory listing or a stat each. A
delaydoes not wake a suspended CPU, so they only cost anything while the device is awake for another reason. Expect a modest improvement, not a step change. No before/after battery measurement was done, and the bulk of the drain for this kind of app is likely the agent itself plus screen-on time, which this PR does not touch.Known limitations and follow-ups
None of these were blocking in the pre-PR review.
stop/stopAll/ exit, so after the first Claude chat the 250 ms poll keeps running until those happen. This PR stops it after them and for any period with no process. Tightening it tohasLiveSessions && (foreground || runtimeWork.active)or to a turn-in-progress condition would help more, but a missed condition silently drops permission prompts, so it is left conservative.time.monotonic()(it does not advance while the device is suspended) whilecreatedAtMsis wall clock, so across a suspend the app can answer "expired" while the guest is still waiting; that fails safe (nothing is executed). The KDoc onREQUEST_EXPIRES_AFTER_MILLISand the "90 s" indocs/LOCAL_RUNTIME.mdread slightly stronger than this; a wording fix is a follow-up.andcode-schedule-mcp.pyhas no reciprocal comment because the shipped script was not touched.pollis suspended inwithContext(Dispatchers.IO)right after it consumed the command file, that URL is dropped. It is a window of milliseconds, at the moment the app leaves the screen, and is documented in the KDoc.registerSession/unregisterSession->hasLiveSessionswiring, becauseClaudeCodeRuntimeneeds real CLI processes. Extractingsessionsand the flag into a small holder would make theonlyProcesssemantics testable.pollWhileActiverestarts, and polls immediately, if its upstream re-emits an equaltrue. Every caller passes aStateFlowor appliesdistinctUntilChanged(); the helper could dedupe itself.Test plan
./gradlew :app:testGithubDebugUnitTest: 1404 tests, 0 failures, 0 errors (8 skipped, not from this change)../gradlew spotlessCheckpasses../gradlew detektreportsNO-SOURCElocally, so it analysed nothing and is not evidence.:app:assembleGithubDebugand:app:assembleGithubDebugAndroidTestbuild.pollWhileActivemakes exactly the five gating tests fail and nothing else.origin/mainis clean.and-code-scheduleMCP create/list from an agent still works.dumpsys batterystats. Not done.Pre-PR review
判定: APPROVE
ブロッカー
なし
提案(非ブロッキング)
REQUEST_EXPIRES_AFTER_MILLISのKDocが実装より強く書かれている — 次の3点を直すと正確になります。time.monotonic()(andcode-schedule-mcp.py:59)ですが、createdAtMsは壁時計です。Linuxの CLOCK_MONOTONIC は端末suspend中に進みません。そのためsuspendを挟むと、ゲストがまだ待っているのにアプリ側が「expired」を返すことがあります。ゲストにはエラーが即座に返るだけで、実行はされません。安全側の誤判定ですが、「同じカーネルクロックなので許容は不要」という説明は壁時計についてしか成り立ちません。RESPONSE_TIMEOUT_S = 90)には逆向きのコメントがありません。コメントを足すか、テストでアセットの値を読んでREQUEST_EXPIRES_AFTER_MILLISとの関係を固定すると、片方だけ変更される事故を防げます。stopとstopAllだけです。一度でも会話すると、アプリが背面でwake lockがなくても、端末が起きている間は250msポーリングが続きます。許可フックが書き込めるのはターン中だけです。foreground || runtimeWork.activeとの合成にすると、ねらい(バッテリー削減)に近づきます。手間が大きければ現状のままでも妥当です。registerSession/unregisterSession→hasLiveSessionsの配線にはテストがありません。壊れると許可プロンプトが無言で出なくなる箇所で、コミットメッセージもそこを狙った変更だと説明しています。プロセス起動が絡んで直接テストしづらいので、sessionsとhasLiveSessionsを小さなホルダークラスに切り出し、onlyProcessの等価性(差し替え済みなら消さない)をユニットテストで固定することを検討してください。pollWhileActiveは上流がtrue, trueと流すと、ループを再起動して即時ポーリングします。現状の呼び出し元は StateFlow かdistinctUntilChanged()(AndCodeApplication.kt:589-591)で重複を除いているので問題ありません。ヘルパー内にdistinctUntilChanged()を入れるか、KDocで「呼び出し側で重複を除くこと」と明記すると、将来の事故を防げます。チェック済み項目
git fetch origin mainのうえgit diff origin/main...HEADで全hunk(main 6ファイル、test 3ファイル、docs 1ファイル)を読みました。ブランチは origin/main に対して 0 behind / 4 ahead で、作業ツリーはクリーンです。strings.xml などリソースの変更はなく、i18nへの影響はありません。ゲストに返す「Request expired ...」は英語で、「LLM向け・ログは英語」の規約に合っています。sessionsの追加はregisterSession(ensureProcess の.also)だけです。削除はstopと reader job終了時のunregisterSession(..., onlyProcess = process)だけです。stopAllはstop経由で、sessionsの直接参照は読み取りのみです。onlyProcessの意味は集約前のif (sessions[id]?.process === process) removeと等価です(エントリなしと別プロセスはどちらも null を返します)。stopも旧remove(id)?.letと等価です。@Synchronizedの再入は問題ありません。ensureProcessはsend(@synchronized)からのみ呼ばれ、同一モニターは再入可能です。reader job(Dispatchers.IO)はsendの完了までロックを待つので、旧コードと同じ順序でregisterがunregisterより先になります。thisのロック取得はありません。hasLiveSessionsはモニター内でsessionsと同時に更新されます。プロセス起動失敗時は false のままで正しく、置換(stop → register)時の false→true の一瞬は collectLatest が吸収します。生きたセッションがあるのにウォッチャが止まったままになる経路は見つかりませんでした。summarizeTitleとrunInWorkspaceはsessionsに入りませんが、前者は--tools ""で許可フックが動かず、後者は単なるシェルなので、requestを積まないことを確認しました。createdAtMsは Python がint(time.time()*1000)(エポックms)で書き、Kotlin はSystem::currentTimeMillisとoptLongで読むので、単位も時計も一致しています。createdAtMs > 0を満たさないので実行されます。未来時刻は差が負なので実行されます。now - createdAtMsにオーバーフローはありません。createdAtMs = 0と実時計の組み合わせで、0が「不明」扱いであることを暗黙に固定しています。追加テストは期限切れ、期限内、しきい値の前後、未来時刻と欠落の5系統で、時計を固定しているため決定的です。file.delete()が応答書き込みより先なので、再実行の恐れはありません。残った応答ファイルは15分のpruneStaleで消えます。combine(appForeground.foreground, runtimeWork.active) { fg, w -> fg || w }.distinctUntilChanged()で、combineとdistinctUntilChangedの import は既存です。runtimeWorkの保持元は、"sessions"(activeSessionIds、60秒debounce付き)、スケジュール実行(ScheduleExecutionService:149)、各controllerの install/update、ADB、ターミナル、git-clone、runtime-op です。GuestBrowserCommandWatcherの呼び出し元は AndCodeApp.kt:710 の1箇所だけで、foreground = app.appForeground.foregroundに更新済みです。foregroundはProcessLifecycleAppForegroundが保持する val の StateFlow で、同一参照です。LaunchedEffectのキーに入れても再コンポーズで再起動しません。runtimeDirectoryのFileは equals で比較されるため、既存どおり安定です。ScheduleBridge.runの呼び出し元は AndCodeApplication.kt:592 の1箇所です。テストはpollOnceと新しいrun(gate)を使います。リポジトリ全体(--include='*.kt/kts/md')で、ほかの参照元がないことを確認しました。pollWhileActiveはcollectLatestと即時 action とdelayの組み合わせで、「false中は何も動かない/true再開で即ポーリング/false化でdelay中のループをキャンセル」という KDoc の記述どおりに動きます。runの1件はいずれも、runTestの testScheduler を共有する別のTestScopeでループを起動します。advanceTimeByは境界ちょうどのタスクを実行しないため、runCurrentの併用が正しく、finallyのscope.cancel()でリークもありません。BrowserCommandWatcher.kt:45のwithContext(IO)中にgateが閉じると、消費済みURLが失われる窓があります。ただし KDoc が明示しており、アプリが画面を離れる瞬間のミリ秒単位なので許容と判断しました。delayimport の取り残しもありません(isActiveは reader job で使用中)。ktlint の関数シグネチャ整形も、周辺コードと同じ書き方です。detekt は root の build.gradle.kts にしか適用されておらず、app 側の依存設定にはありません。この差分で新たに失敗する見込みはないと判断しました。🤖 Generated with Claude Code