diff --git a/src/tests/web-boot-node24.test.ts b/src/tests/web-boot-node24.test.ts new file mode 100644 index 000000000..f64d3b654 --- /dev/null +++ b/src/tests/web-boot-node24.test.ts @@ -0,0 +1,151 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { resolveTypeStrippingFlag } from "../web/ts-subprocess-flags.ts" + +// --------------------------------------------------------------------------- +// Bug 1 — resolveTypeStrippingFlag selects the correct flag +// --------------------------------------------------------------------------- + +test("resolveTypeStrippingFlag returns --experimental-strip-types for paths outside node_modules", () => { + const flag = resolveTypeStrippingFlag("/home/user/projects/gsd") + assert.equal(flag, "--experimental-strip-types") +}) + +test("resolveTypeStrippingFlag returns --experimental-strip-types for path with node_modules substring not as directory", () => { + // e.g. /home/user/my_node_modules_backup/gsd — not actually under node_modules/ + const flag = resolveTypeStrippingFlag("/home/user/my_node_modules_backup/gsd") + assert.equal(flag, "--experimental-strip-types") +}) + +test("resolveTypeStrippingFlag returns --experimental-transform-types for paths under node_modules/ on Node >= 22.7", () => { + const [major, minor] = process.versions.node.split(".").map(Number) + const flag = resolveTypeStrippingFlag("/usr/lib/node_modules/gsd-pi") + + if (major > 22 || (major === 22 && minor >= 7)) { + assert.equal(flag, "--experimental-transform-types") + } else { + // On older Node, falls back to strip-types since transform-types isn't available + assert.equal(flag, "--experimental-strip-types") + } +}) + +test("resolveTypeStrippingFlag handles Windows-style paths under node_modules", () => { + const [major, minor] = process.versions.node.split(".").map(Number) + const flag = resolveTypeStrippingFlag("C:\\Users\\dev\\AppData\\node_modules\\gsd-pi") + + if (major > 22 || (major === 22 && minor >= 7)) { + assert.equal(flag, "--experimental-transform-types") + } else { + assert.equal(flag, "--experimental-strip-types") + } +}) + +// --------------------------------------------------------------------------- +// Bug 2 — waitForBootReady fails fast on consecutive 5xx +// --------------------------------------------------------------------------- + +// The waitForBootReady function is not exported, but the behavior is testable +// by verifying the launchWebMode deps injection. We test the core logic +// pattern directly: 3 consecutive 5xx should abort without waiting for timeout. + +test("waitForBootReady pattern: consecutive 5xx detection aborts early", async () => { + // Simulate the retry logic extracted from waitForBootReady + let consecutive5xx = 0 + const MAX_CONSECUTIVE_5XX = 3 + const responses = [500, 500, 500] // three deterministic 500s + let abortedEarly = false + + for (const statusCode of responses) { + if (statusCode >= 500) { + consecutive5xx++ + if (consecutive5xx >= MAX_CONSECUTIVE_5XX) { + abortedEarly = true + break + } + } else { + consecutive5xx = 0 + } + } + + assert.equal(abortedEarly, true, "should abort after 3 consecutive 5xx responses") + assert.equal(consecutive5xx, 3) +}) + +test("waitForBootReady pattern: non-5xx responses reset the consecutive counter", () => { + let consecutive5xx = 0 + const MAX_CONSECUTIVE_5XX = 3 + // 500, 500, connection-refused (resets), 500, 500 — should NOT trigger abort + const events = [ + { type: "response", status: 500 }, + { type: "response", status: 500 }, + { type: "error" }, // connection refused resets counter + { type: "response", status: 500 }, + { type: "response", status: 500 }, + ] + let abortedEarly = false + + for (const event of events) { + if (event.type === "response" && (event.status ?? 0) >= 500) { + consecutive5xx++ + if (consecutive5xx >= MAX_CONSECUTIVE_5XX) { + abortedEarly = true + break + } + } else { + consecutive5xx = 0 + } + } + + assert.equal(abortedEarly, false, "should not abort when errors reset the counter") +}) + +test("waitForBootReady pattern: mixed 4xx and 5xx only counts 5xx", () => { + let consecutive5xx = 0 + const MAX_CONSECUTIVE_5XX = 3 + const responses = [500, 404, 500, 500] + let abortedEarly = false + + for (const statusCode of responses) { + if (statusCode >= 500) { + consecutive5xx++ + if (consecutive5xx >= MAX_CONSECUTIVE_5XX) { + abortedEarly = true + break + } + } else { + consecutive5xx = 0 + } + } + + assert.equal(abortedEarly, false, "404 should reset the consecutive 5xx counter") +}) + +// --------------------------------------------------------------------------- +// Bug 3 — /api/boot route error handling +// --------------------------------------------------------------------------- + +test("boot route returns { error } JSON on handler failure", async () => { + // Read the route source to verify try/catch wrapping is present + const { readFileSync } = await import("node:fs") + const { join } = await import("node:path") + + const routeSource = readFileSync( + join(process.cwd(), "web", "app", "api", "boot", "route.ts"), + "utf-8", + ) + + // The route must catch errors and return { error: message } + assert.match(routeSource, /try\s*\{/, "boot route must have try block") + assert.match(routeSource, /catch\s*\(/, "boot route must have catch block") + assert.match( + routeSource, + /\{\s*error:\s*message\s*\}/, + "boot route must return { error: message } on failure", + ) + assert.match( + routeSource, + /status:\s*500/, + "boot route must return status 500 on error", + ) +}) diff --git a/src/web-mode.ts b/src/web-mode.ts index f3a1e5014..2f6b3e2ad 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -451,7 +451,10 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa const deadline = Date.now() + timeoutMs const startedAt = Date.now() let lastError: string | null = null + let lastBody: string | null = null let hostUp = false + let consecutive5xx = 0 + const MAX_CONSECUTIVE_5XX = 3 // Print a progress dot every N ms while waiting so the terminal isn't silent const TICKER_INTERVAL_MS = 5_000 let lastTickAt = startedAt @@ -468,12 +471,29 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa hostUp = true stderr?.write(`[gsd] Web host ready.\n`) } + consecutive5xx = 0 // Host responded successfully — it's ready for the browser return + } else if (response.statusCode >= 500) { + consecutive5xx++ + lastError = `http ${response.statusCode}` + lastBody = response.body || null + if (consecutive5xx >= MAX_CONSECUTIVE_5XX) { + const detail = lastBody ? `: ${lastBody.slice(0, 500)}` : '' + throw new Error( + `boot route returned ${MAX_CONSECUTIVE_5XX} consecutive 5xx responses (last: ${response.statusCode})${detail}`, + ) + } } else { + consecutive5xx = 0 lastError = `http ${response.statusCode}` } } catch (error) { + if (error instanceof Error && error.message.startsWith('boot route returned')) { + throw error + } + // Connection refused, timeout, etc. — transient during cold start + consecutive5xx = 0 lastError = error instanceof Error ? error.message : String(error) } diff --git a/src/web/auto-dashboard-service.ts b/src/web/auto-dashboard-service.ts index 9b377c632..fdce2c0c9 100644 --- a/src/web/auto-dashboard-service.ts +++ b/src/web/auto-dashboard-service.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { AutoDashboardData } from "./bridge-service.ts"; +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" const AUTO_DASHBOARD_MAX_BUFFER = 1024 * 1024; const TEST_AUTO_DASHBOARD_MODULE_ENV = "GSD_WEB_TEST_AUTO_DASHBOARD_MODULE"; @@ -73,7 +74,7 @@ export async function collectAuthoritativeAutoDashboardData( [ "--import", pathToFileURL(resolveTsLoader).href, - "--experimental-strip-types", + resolveTypeStrippingFlag(packageRoot), "--input-type=module", "--eval", script, diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index 771a51211..32ed1048b 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -4,6 +4,7 @@ import { StringDecoder } from "node:string_decoder"; import type { Readable } from "node:stream"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts"; import type { AgentSessionEvent, SessionStateChangeReason } from "../../packages/pi-coding-agent/src/core/agent-session.ts"; import type { @@ -924,7 +925,7 @@ async function loadWorkspaceIndexViaChildProcess(basePath: string, packageRoot: [ "--import", pathToFileURL(resolveTsLoader).href, - "--experimental-strip-types", + resolveTypeStrippingFlag(packageRoot), "--input-type=module", "--eval", script, diff --git a/src/web/captures-service.ts b/src/web/captures-service.ts index 003591845..938cdf396 100644 --- a/src/web/captures-service.ts +++ b/src/web/captures-service.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" import type { CapturesData, CaptureResolveRequest, CaptureResolveResult } from "../../web/lib/knowledge-captures-types.ts" const CAPTURES_MAX_BUFFER = 2 * 1024 * 1024 @@ -51,7 +52,7 @@ export async function collectCapturesData(projectCwdOverride?: string): Promise< [ "--import", pathToFileURL(resolveTsLoader).href, - "--experimental-strip-types", + resolveTypeStrippingFlag(packageRoot), "--input-type=module", "--eval", script, @@ -120,7 +121,7 @@ export async function resolveCaptureAction(request: CaptureResolveRequest, proje [ "--import", pathToFileURL(resolveTsLoader).href, - "--experimental-strip-types", + resolveTypeStrippingFlag(packageRoot), "--input-type=module", "--eval", script, diff --git a/src/web/cleanup-service.ts b/src/web/cleanup-service.ts index 02f7d414e..a83ba40f3 100644 --- a/src/web/cleanup-service.ts +++ b/src/web/cleanup-service.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" import type { CleanupData, CleanupResult } from "../../web/lib/remaining-command-types.ts" const CLEANUP_MAX_BUFFER = 2 * 1024 * 1024 @@ -65,7 +66,7 @@ export async function collectCleanupData(projectCwdOverride?: string): Promise 22 || (major === 22 && minor >= 7) +} diff --git a/src/web/undo-service.ts b/src/web/undo-service.ts index 42a953051..ede0049c3 100644 --- a/src/web/undo-service.ts +++ b/src/web/undo-service.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" import type { UndoInfo, UndoResult } from "../../web/lib/remaining-command-types.ts" const UNDO_MAX_BUFFER = 2 * 1024 * 1024 @@ -182,7 +183,7 @@ export async function executeUndo(projectCwdOverride?: string): Promise { }); } - const bootPayload = await collectBootPayload(projectCwd); + try { + const bootPayload = await collectBootPayload(projectCwd); - return Response.json(bootPayload, { - headers: { - "Cache-Control": "no-store", - }, - }); + return Response.json(bootPayload, { + headers: { + "Cache-Control": "no-store", + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Response.json( + { error: message }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); + } }