Repository navigation
fix(runtime): wait for an HTTP answer before calling local OpenCode ready - #18
Merged
Merged
Conversation
…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
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.
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:NodeHttpServerlistens first, and the route layers are built beforeHttpRouter.serveadds 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:
node:httprepro under Node 22 and Bun 1.4.v1: fix(server): answer requests that arrive before the request listener is attached anomalyco/opencode#56642.dev/2.x already answers 503 until ready.Fix
LocalRuntimeProcessLauncherwaits untilGET /global/healthreturns 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.ProtocolDetectingOpenCodeApigives 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
detekt spotlessCheck :app:testGithubDebugUnitTest :app:assembleGithubDebug, with 1410 unit tests passing. New tests cover:Pre-PR review
Reviewed at 6479a7e: APPROVE. Follow-up commit d84effe applies the review's suggestions:
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)を見ると現状は余裕がありますが、保険として次のどちらかを検討してください。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を使っていないため発生せず、誤った再試行にはつながりません。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/main698a32a、コミットが 6479a7e の 1 つ、変更が 4 ファイルであることを確認しました。diff の全 hunk を読みました。LocalRuntimeManager.startInstalled(:629-630)→launcher.start→waitUntilReadyの順で、serverReadyが使われるのはここだけです。isHealthy/computeStatus/restart/ watchdog は引き続きportProbe(2.5s の connect)を使い、監視側の挙動は変わっていません。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 と、callTimeout5s の大小関係を確認しました。MaxLineLength140、ReturnCount4、LongParameterList10)に対して、追加した行の長さ、コンストラクタの引数数(9)、定数の命名を目視で確認し、違反はありませんでした。shell 権限がなかったため、gradle は自分では実行していません。detekt / spotless / ユニットテストの結果は作成者の報告に依拠しています。