Skip to content

fix(runtime): wait for an HTTP answer before calling local OpenCode ready - #18

Merged
doublepi123 merged 2 commits into
mainfrom
fix/probe-timeout
Oct 18, 2026
Merged

doublepi123 merged 2 commits into
mainfrom
fix/probe-timeout

Conversation

@doublepi123

Copy link
Copy Markdown
Owner

Fixes the agent chip spinning for ~2 minutes on many cold starts of the local OpenCode.

Root cause. opencode serve (1.18.5, 1.19.3) accepts TCP connections a few hundred ms before it attaches its HTTP request handler: NodeHttpServer listens first, and the route layers are built before HttpRouter.serve adds the "request" listener. Node emits a request that arrives in that window to no listener and drops it, so the socket stays open and nothing ever answers.

The launcher treated an open port as a ready server. The app's first request (the protocol probe, api/info) often landed in that window and hung until the 120 s read timeout.

Evidence for the cause:

Fix

  • LocalRuntimeProcessLauncher waits until GET /global/health returns any HTTP status line (401 counts), not just until a TCP connect succeeds. Each attempt uses its own connection with a 1 s connect and 1 s read timeout. At the 30 s deadline, a server that is alive and accepting connections is kept, so a slow device does not turn into a failed start.
  • ProtocolDetectingOpenCodeApi gives its two probes a 5 s call timeout plus one retry with a 20 s timeout, so a remote or restarted server caught in that window costs seconds instead of 120 s.

Verified

  • Local: detekt spotlessCheck :app:testGithubDebugUnitTest :app:assembleGithubDebug, with 1410 unit tests passing. New tests cover:
    • an accepting port that never answers is not ready, and the check gives up within 5 s
    • a 401 counts as ready
    • a closed port is not ready
    • a probe that gets no response is retried well before the read timeout
  • M098FE, instrumented build: 12 cold starts at 10–12 s, no probe timeouts. In 11 of them the readiness check's first attempt landed in the dead window and the next attempt succeeded; before the fix, the app's own request hung there for 120 s.
  • k70u (Redmi K70 Ultra), final build: 8 cold starts at 11–12 s.
  • Before the fix: M098FE took 128 s and over 180 s in 2 of 6 cold starts.

Pre-PR review

Reviewed at 6479a7e: APPROVE. Follow-up commit d84effe applies the review's suggestions:

  • a live, listening server is kept at the readiness deadline
  • the probe retry gets a 20 s timeout
  • a thread-safe list is used in the test

It was verified locally (1410 tests) and on k70u (8 cold starts). Report:

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • app/src/main/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncher.kt:213-224 — Ready の条件が「TCP が accept される」から「1s 以内に status line が返る」に厳しくなりましたが、30s の deadline は変わっていません。そのため、修正前は起動できていた端末が起動失敗になり、destroyForcibly される可能性があります。対象は、高負荷時(Claude Code のターン中など。LocalRuntimeManager.kt:711-720 のコメントでは、この負荷で connect に 300ms 以上かかった事例が記録されています)や、初回の bind 自体が 25s 前後かかる端末です。HTTP 応答がまったく返らない場合は 1s で打ち切られるので、/global/health の処理が恒常的に 1s を超える端末だけが該当します。実機計測(10–12s)を見ると現状は余裕がありますが、保険として次のどちらかを検討してください。
    • read timeout を試行ごとに 1s→2s→3s と伸ばす
    • deadline の時点で process.isAlive && portProbe(port) なら、修正前と同じく Ready として扱う(kill しない)
  • 同ファイル :213-219 — 1 回のループは最大で connect 1s + read 1s + sleep 250ms です。新規接続の負荷はループ 1 回につき 1 本、かつ Connection: close なので問題ありません。ただし最後の試行が deadline を最大 ~2s 超える点は、コメントに一言あると親切です。
  • app/src/main/java/com/yugahashimoto/andcode/core/api/ProtocolDetectingOpenCodeApi.kt:63-66 — callTimeout は connect と TLS ハンドシェイクも含めた 5s です。リモート先でも、トンネル経由やコールドスタートするホストのように最初の応答だけ遅いものがあります。こうした接続先は、修正前は connect 15s / read 120s で成功していましたが、修正後は 5s×2 で失敗します。影響するのは WorkspaceViewModel.kt:333 の接続テストと、RemoteOpenCodeBackend の health です。2 回目の試行だけ timeout を長くする(例: 15s)と、副作用を抑えられます。
  • ProtocolDetectingOpenCodeApi.kt:395 の InterruptedIOException の捕捉範囲について、確認結果は次のとおりです。
    • SocketTimeoutException(connect/read)も含みますが、既定の connect 15s / read 120s はどちらも call 5s より長いので、実際には callTimeout が先に発火します。
    • スレッド割り込みは withContext(Dispatchers.IO) で runInterruptible を使っていないため発生せず、誤った再試行にはつながりません。
    • 逆に、コルーチンがキャンセルされても再試行の 5s は止まりません。修正前の 120s よりは大幅に短いので許容範囲です。
  • probeLock を保持する時間 — 最悪経路は ~10s ではなく ~20s です。api/info が 2 回タイムアウト(10s)→ IOException → confirmLegacyMajorOne() の global/health も 2 回タイムアウト(10s)、という流れです。修正前の 120s+ からは大きく改善しているので問題ありませんが、PR 説明の数値は直しておくとよいです。
  • プール共有について — callTimeout が発火すると、OkHttp は該当の exchange を cancel して接続を閉じます。in-flight の接続が idle プールに戻ることはないので、窓の中で受け付けられた接続が次の試行で再利用されることはありません。再試行が拾うのは、正常に応答済みの idle 接続か新しい接続のどちらかです。
  • app/src/test/java/com/yugahashimoto/andcode/runtime/local/LocalRuntimeProcessLauncherTest.kt:535 — accepted は acceptor スレッドが書き、テストスレッドが読む素の mutableListOf で、可視性の保証がありません。最悪でも accept 済みのソケットが 1 本閉じられずに残るだけで、assert には影響しません(acceptor がなくてもカーネルの backlog が accept するのでテストは成立します)。気になるなら CopyOnWriteArrayList にするか、listener.close() の後に acceptor.join() してください。
  • テストの網羅について — start() が portProbe ではなく serverReady を待つこと自体を固定するテストはありません。proot を起動しないと検証しにくいので現状でも可としますが、waitUntilReady を internal にして、注入した serverReady で検証する余地はあります。

チェック済み項目

  • git log でベースが origin/main 698a32a、コミットが 6479a7e の 1 つ、変更が 4 ファイルであることを確認しました。diff の全 hunk を読みました。
  • 起動経路は LocalRuntimeManager.startInstalled(:629-630)→ launcher.start → waitUntilReady の順で、serverReady が使われるのはここだけです。isHealthy / computeStatus / restart / watchdog は引き続き portProbe(2.5s の connect)を使い、監視側の挙動は変わっていません。
  • DI について — di/AppModule.kt:99 と AndCodeApplication.kt:299 の両方の構築経路が serverReady の既定値を使っており、整合しています。既存テスト(LocalRuntimeManagerTest / LocalRuntimePortTest / IdleStopDecisionTest)は portProbe = { true } 経路で launch しないか、launch に到達しないため、影響はありません。
  • --hostname 127.0.0.1 と readiness の接続先 LOOPBACK = "127.0.0.1" が一致することを確認しました。認証なしのリクエストで 401 が返る経路も ready として扱われ、パスワード付きのサーバーでも誤判定しません。
  • ProtocolDetectingOpenCodeApi について — probeClient は httpClient.newBuilder() で作られ、インターセプタ・証明書ピン留め・プールを引き継いでいます。probe() の IOException 分岐(:334)は、再試行が尽きた後の InterruptedIOException も従来どおり confirmLegacyMajorOne で処理します。
  • defaultHttpClient(OpenCodeApiClient.kt:778-783)の connect 15s / read 120s / write 30s と、callTimeout 5s の大小関係を確認しました。
  • detekt の設定(MaxLineLength 140、ReturnCount 4、LongParameterList 10)に対して、追加した行の長さ、コンストラクタの引数数(9)、定数の命名を目視で確認し、違反はありませんでした。shell 権限がなかったため、gradle は自分では実行していません。detekt / spotless / ユニットテストの結果は作成者の報告に依拠しています。
  • i18n について — ユーザーに表示される文字列の追加はありません。追加は英語のログとエラー文言のみで、ロケールファイルの変更は不要です。シークレットの追加もありません。

…eady

On many cold starts the agent picker spun for ~2 minutes. `opencode serve`
(1.18.5, 1.19.3) starts accepting TCP connections a few hundred ms before
it attaches its HTTP request handler; Node emits the request on a
connection accepted in that window with no listener and drops it, so the
request is never answered while the socket stays open. The launcher took
an open port as a ready server, the app's first request (the protocol
probe, api/info) landed in that window, and it hung until the 120 s read
timeout. Reproduced on the host with plain OpenCode 1.19.3 (a request sent
the instant the port opens: 12/12 never answered) and traced on two phones.

- the launcher waits until GET /global/health returns any HTTP status line
  (401 included), each attempt on its own connection with a 1 s read
  timeout, instead of until a TCP connect succeeds
- the two protocol probes get a 5 s call timeout and one retry on a fresh
  call, so a server caught in that window costs seconds, not 120 s

Verified on a phone (M098FE): 12 cold starts at 10-12 s each, no probe
timeouts; the readiness check's first attempt landed in the window in
11 of them and the next one succeeded.
… give the probe retry 20 s

Per review:
- at the 30 s startup deadline, a server that is alive and accepting
  connections is kept (the old connect-only verdict) instead of being
  killed, so a loaded device that is slow to answer HTTP does not turn into
  a failed start
- the protocol probe's retry gets a 20 s call timeout, so a remote host
  that is slow on its first request (tunnel, waking up) still connects
- test: thread-safe list for accepted sockets
@doublepi123
doublepi123 merged commit 229c93c into main Oct 18, 2026
5 checks passed
@doublepi123
doublepi123 deleted the fix/probe-timeout branch October 18, 2026 03:30
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