From 4c69da8236f1d4786d33373deae69b45ae53ec8e Mon Sep 17 00:00:00 2001 From: Gu Andres Date: Tue, 5 May 2026 20:51:30 -0600 Subject: [PATCH] fix(server): emit keep-alive newlines during /session/:id/message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST /session/:sessionID/message handler awaits the entire SessionPrompt.prompt() before writing any response bytes. For long synchronous tool calls (large `kubectl get events -A`, multi-step `gh pr create` flows, etc.) the wait can exceed many minutes — during which no application bytes flow on the response stream and the TCP connection is held open with no traffic. HTTP clients with finite per-recv timeouts then ReadTimeout before any byte arrives. We observed downstream workflow runners hitting this on every long-running cluster-health agent run (60+ min) until they disabled their timeout entirely; that loses the ability to detect genuinely dead connections. Emit a `\n` every 30s while the prompt runs. The response remains application/json since JSON parsers ignore leading whitespace before the value. clearInterval on completion (and in the finally block) prevents stray writes from interleaving with the JSON body. Tested: `bun turbo typecheck --filter=opencode` passes. --- .../opencode/src/server/instance/session.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index b28db3a8941d..98d8e9b8a19c 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -848,8 +848,26 @@ export const SessionRoutes = lazy(() => return stream(c, async (stream) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) - stream.write(JSON.stringify(msg)) + + // Long synchronous tool calls (large `kubectl` queries, multi-step + // git ops, etc.) can hold the response stream silent for many + // minutes while `SessionPrompt.prompt` runs. HTTP clients with + // finite per-recv timeouts (httpx defaults to 5s; some downstream + // workflow runners cap at minutes-to-hours) hit ReadTimeout before + // any byte is sent. Emit a periodic newline so the connection + // produces traffic; the response stays application/json because + // JSON parsers ignore leading whitespace before a value. + const keepalive = setInterval(() => { + stream.write("\n").catch(() => {}) + }, 30_000) + + try { + const msg = await SessionPrompt.prompt({ ...body, sessionID }) + clearInterval(keepalive) + await stream.write(JSON.stringify(msg)) + } finally { + clearInterval(keepalive) + } }) }, )