fix: resolve Node v24 web boot failure — ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (#1864)

Node v24 forbids --experimental-strip-types for files under node_modules/.
When GSD is globally installed, all src/ files live under node_modules/gsd-pi/,
causing every subprocess worker to crash with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Bug 1: Extract resolveTypeStrippingFlag() into src/web/ts-subprocess-flags.ts.
When the package root is under node_modules/ and Node >= 22.7, the function
returns --experimental-transform-types (which handles node_modules paths).
All 15 service files and cli-entry.ts now call this function instead of
hardcoding --experimental-strip-types.

Bug 2: waitForBootReady() now tracks consecutive 5xx responses and aborts
after 3 in a row, including the response body in the error message.
Connection-level errors (transient during cold start) reset the counter.

Bug 3: The /api/boot route handler now wraps collectBootPayload() in
try/catch and returns { error: message } with status 500, matching the
error response pattern used by other API routes.

Fixes #1849

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 17:24:07 -04:00 committed by GitHub
parent 7140ee0f53
commit 2e04253c0b
19 changed files with 255 additions and 23 deletions

View file

@ -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",
)
})

View file

@ -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)
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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<C
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,
@ -152,7 +153,7 @@ export async function executeCleanup(
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts";
export interface GsdCliEntry {
command: string;
@ -46,7 +47,7 @@ export function resolveGsdCliEntry(options: ResolveGsdCliEntryOptions): GsdCliEn
args: [
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(options.packageRoot),
sourceEntry,
...extraArgs,
...messageArgs,

View file

@ -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 { DoctorReport, DoctorFixResult } from "../../web/lib/diagnostics-types.ts"
const DOCTOR_MAX_BUFFER = 2 * 1024 * 1024
@ -42,7 +43,7 @@ function runDoctorChild(
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { ExportResult } from "../../web/lib/remaining-command-types.ts"
const EXPORT_MAX_BUFFER = 4 * 1024 * 1024
@ -60,7 +61,7 @@ export async function collectExportData(
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { ForensicReport } from "../../web/lib/diagnostics-types.ts"
const FORENSICS_MAX_BUFFER = 2 * 1024 * 1024
@ -79,7 +80,7 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { HistoryData } from "../../web/lib/remaining-command-types.ts"
const HISTORY_MAX_BUFFER = 2 * 1024 * 1024
@ -53,7 +54,7 @@ export async function collectHistoryData(projectCwdOverride?: string): Promise<H
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { HooksData } from "../../web/lib/remaining-command-types.ts"
const HOOKS_MAX_BUFFER = 512 * 1024
@ -54,7 +55,7 @@ export async function collectHooksData(projectCwdOverride?: string): Promise<Hoo
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -8,6 +8,7 @@ import {
collectSelectiveLiveStatePayload,
resolveBridgeRuntimeConfig,
} from "./bridge-service.ts"
import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts"
import type {
WorkspaceRecoveryBrowserAction,
WorkspaceRecoveryCodeSummary,
@ -473,7 +474,7 @@ async function collectRecoveryDiagnosticsChildPayload(
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { SettingsData } from "../../web/lib/settings-types.ts"
const SETTINGS_MAX_BUFFER = 2 * 1024 * 1024
@ -110,7 +111,7 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise<
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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 { SkillHealthReport } from "../../web/lib/diagnostics-types.ts"
const SKILL_HEALTH_MAX_BUFFER = 2 * 1024 * 1024
@ -48,7 +49,7 @@ export async function collectSkillHealthData(projectCwdOverride?: string): Promi
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -0,0 +1,38 @@
/**
* Returns the correct Node.js type-stripping flag for subprocess spawning.
*
* Node v24 enforces ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for files
* resolved under `node_modules/`. When GSD is installed globally via npm,
* all source files live under `node_modules/gsd-pi/src/...`, so
* `--experimental-strip-types` fails deterministically.
*
* `--experimental-transform-types` applies a full TypeScript transform that
* works regardless of whether the file is under `node_modules/`. On older
* Node versions (< 22.7) that lack both flags, this falls back to
* `--experimental-strip-types` (the caller's loader handles the rest).
*/
export function resolveTypeStrippingFlag(packageRoot: string): string {
const needsTransform =
isUnderNodeModules(packageRoot) && supportsTransformTypes()
return needsTransform
? "--experimental-transform-types"
: "--experimental-strip-types"
}
/**
* Returns true when the given path sits inside a `node_modules/` directory.
* Handles both Unix and Windows path separators.
*/
function isUnderNodeModules(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, "/")
return normalized.includes("/node_modules/")
}
/**
* Returns true when the running Node version supports
* `--experimental-transform-types` (available since Node v22.7.0).
*/
function supportsTransformTypes(): boolean {
const [major, minor] = process.versions.node.split(".").map(Number)
return major > 22 || (major === 22 && minor >= 7)
}

View file

@ -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<UndoResu
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -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"
const VISUALIZER_MAX_BUFFER = 2 * 1024 * 1024
const VISUALIZER_MODULE_ENV = "GSD_VISUALIZER_MODULE"
@ -85,7 +86,7 @@ export async function collectVisualizerData(projectCwdOverride?: string): Promis
[
"--import",
pathToFileURL(resolveTsLoader).href,
"--experimental-strip-types",
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,

View file

@ -28,11 +28,19 @@ export async function GET(request: Request): Promise<Response> {
});
}
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" } },
);
}
}