From 92eb282310e909ffb90899349886516761192788 Mon Sep 17 00:00:00 2001 From: src-opn Date: Fri, 29 May 2026 09:15:44 -0700 Subject: [PATCH] fix(app): bound e2e health checks with per-request timeout waitForHealthy polled /global/health with no per-request timeout, so a single fetch to a not-yet-listening opencode port could block on the OS-level TCP connect timeout and blow past the 10s budget (the loop only re-checks the deadline between awaits). This caused recurring ~5-minute "Timed out waiting for /global/health: fetch failed" failures on the macOS-14 CI runner. Bound each request with AbortSignal.timeout, raise the overall budget for slow CI cold starts, and capture server stdout + exit info so future failures are diagnosable instead of showing empty stderr. --- apps/app/scripts/_util.mjs | 30 +++++++++++++++++++++++++---- apps/app/scripts/browser-entry.mjs | 2 ++ apps/app/scripts/e2e.mjs | 2 ++ apps/app/scripts/fs-engine.mjs | 10 +++++++++- apps/app/scripts/session-switch.mjs | 2 ++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/app/scripts/_util.mjs b/apps/app/scripts/_util.mjs index aa6bcc0926..76369d1a97 100644 --- a/apps/app/scripts/_util.mjs +++ b/apps/app/scripts/_util.mjs @@ -59,7 +59,7 @@ export async function spawnOpencodeServe({ const child = spawn("opencode", args, { cwd, - stdio: ["ignore", "ignore", "pipe"], + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, // Make it explicit we're a non-TUI client. @@ -69,13 +69,20 @@ export async function spawnOpencodeServe({ const baseUrl = `http://${hostname}:${port}`; - // If the process dies early, surface stderr. + // If the process dies early or never becomes healthy, surface its output so + // CI failures are diagnosable instead of showing an empty stderr. let stderr = ""; child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk) => { stderr += chunk; }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + async function waitForExit(ms) { return Promise.race([ once(child, "exit").then(() => true), @@ -115,16 +122,31 @@ export async function spawnOpencodeServe({ getStderr() { return stderr; }, + getStdout() { + return stdout; + }, + getExitInfo() { + return { exitCode: child.exitCode, signalCode: child.signalCode }; + }, }; } -export async function waitForHealthy(client, { timeoutMs = 10_000, pollMs = 250 } = {}) { +export async function waitForHealthy( + client, + { timeoutMs = 30_000, pollMs = 250, requestTimeoutMs = 5_000 } = {}, +) { const start = Date.now(); let lastError; while (Date.now() - start < timeoutMs) { try { - const health = await client.global.health(); + // Bound each individual request: a single fetch to a port that is not + // yet accepting connections can otherwise block for the OS-level connect + // timeout (tens of seconds on macOS), blowing past `timeoutMs` because + // the loop can only re-check the deadline between awaits. + const health = await client.global.health({ + signal: AbortSignal.timeout(requestTimeoutMs), + }); assert.equal(health.healthy, true); assert.ok(typeof health.version === "string"); return health; diff --git a/apps/app/scripts/browser-entry.mjs b/apps/app/scripts/browser-entry.mjs index ad71d782a9..7d0bc5d49b 100644 --- a/apps/app/scripts/browser-entry.mjs +++ b/apps/app/scripts/browser-entry.mjs @@ -284,6 +284,8 @@ try { results.ok = false; results.error = message; results.stderr = opencode?.getStderr?.() ?? ""; + results.stdout = opencode?.getStdout?.() ?? ""; + results.serverExit = opencode?.getExitInfo?.() ?? null; console.error(JSON.stringify(results, null, 2)); process.exitCode = 1; } finally { diff --git a/apps/app/scripts/e2e.mjs b/apps/app/scripts/e2e.mjs index 79c2bbeb71..fc6f100770 100644 --- a/apps/app/scripts/e2e.mjs +++ b/apps/app/scripts/e2e.mjs @@ -154,6 +154,8 @@ try { results.ok = false; results.error = message; results.stderr = server.getStderr(); + results.stdout = server.getStdout?.() ?? ""; + results.serverExit = server.getExitInfo?.() ?? null; console.error(JSON.stringify(results, null, 2)); process.exitCode = 1; } finally { diff --git a/apps/app/scripts/fs-engine.mjs b/apps/app/scripts/fs-engine.mjs index 7fb044ffdf..89835fcccd 100644 --- a/apps/app/scripts/fs-engine.mjs +++ b/apps/app/scripts/fs-engine.mjs @@ -47,7 +47,15 @@ try { ); } catch (e) { const message = e instanceof Error ? e.message : String(e); - console.error(JSON.stringify({ ok: false, error: message, stderr: server.getStderr() })); + console.error( + JSON.stringify({ + ok: false, + error: message, + stderr: server.getStderr(), + stdout: server.getStdout?.() ?? "", + serverExit: server.getExitInfo?.() ?? null, + }), + ); process.exitCode = 1; } finally { await server.close(); diff --git a/apps/app/scripts/session-switch.mjs b/apps/app/scripts/session-switch.mjs index 694f65cca6..cc6192cd07 100644 --- a/apps/app/scripts/session-switch.mjs +++ b/apps/app/scripts/session-switch.mjs @@ -144,6 +144,8 @@ try { results.ok = false; results.error = message; results.stderr = server.getStderr(); + results.stdout = server.getStdout?.() ?? ""; + results.serverExit = server.getExitInfo?.() ?? null; console.error(JSON.stringify(results, null, 2)); process.exitCode = 1; } finally {