From 284324df4c8b72727876b96ebe2254ffb1c8c1c1 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 11 Sep 2026 18:50:42 +0200 Subject: [PATCH] fix(conformance): cap the sandbox install so a resolver hang is a finding, not a cancelled job npm does not fail when a dependency's peer range names a version no published package satisfies; it backtracks for hours. On 2026-09-11 the @effect adapters published 4.0.0-rc.114 ahead of effect itself and every conformance run sat in npm install until someone cancelled it, 24 minutes in, with nothing in the log to say why. The sandbox install now runs under a cap, five minutes by default and settable through installTimeoutMs like the bin start's binTimeoutMs. A kill produces its own finding, install-timed-out, whose detail says what npm is doing and carries the ERESOLVE warnings that name the package to pin. A plain failure keeps the install-failed finding it had. Verified against the real tarball with a two-second cap: killed at two seconds, reported as timed out, npm's output attached. The normal run still reports nothing. Co-Authored-By: Claude Fable 5.1 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-conformance/src/checks/tarball.ts | 27 ++++++++++++++- packages/cli-conformance/src/findings.ts | 2 ++ packages/cli-conformance/src/tarball-io.ts | 11 +++++-- .../cli-conformance/tests/tarball.test.ts | 33 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/cli-conformance/src/checks/tarball.ts b/packages/cli-conformance/src/checks/tarball.ts index ffdfa48a..8ce15c41 100644 --- a/packages/cli-conformance/src/checks/tarball.ts +++ b/packages/cli-conformance/src/checks/tarball.ts @@ -44,7 +44,8 @@ export interface TarballIo { readonly rootTarball: string; /** Version-qualified name → absolute `file:` tarball path. */ readonly overrides: Readonly>; - }): Promise<{ ok: true } | { ok: false; output: string }>; + readonly timeoutMs: number; + }): Promise<{ ok: true } | { ok: false; timedOut: boolean; output: string }>; readInstalledManifest( sandboxDir: string, name: string, @@ -97,6 +98,13 @@ export interface TarballInput { readonly exceptions: readonly PinException[]; readonly sandboxDir: string; readonly binTimeoutMs?: number; + /** + * npm does not fail on a peer range no published version satisfies; + * it backtracks for hours (2026-09-11: `@effect/*@4.0.0-rc.114` shipped + * ahead of `effect`, and every install of the shells ran until CI was + * cancelled). The cap turns that into a finding with npm's output. + */ + readonly installTimeoutMs?: number; /** * Which channel these tarballs are for. Check 4 measures the packed * manifests against it; a dev publish is allowed its dev builds. @@ -326,11 +334,28 @@ async function sandboxFindings( } }; visit(root.manifest); + const timeoutMs = input.installTimeoutMs ?? 300_000; const install = await io.installSandbox({ sandboxDir, rootTarball: root.tarball, overrides, + timeoutMs, }); + if (!install.ok && install.timedOut) { + return [ + finding( + "install-timed-out", + packageName, + `npm install did not finish within ${timeoutMs / 1000}s and was killed`, + "npm is backtracking rather than failing: a dependency in the tree " + + "resolves to a version whose peer range no published version " + + "satisfies, and npm retries the resolution instead of reporting " + + "ERESOLVE. Find the package npm names in the ERESOLVE warnings " + + "below and pin it exactly in the package that pulls it in.\n\n" + + install.output, + ), + ]; + } if (!install.ok) { return [ finding( diff --git a/packages/cli-conformance/src/findings.ts b/packages/cli-conformance/src/findings.ts index 9211b51e..e1dfc3b4 100644 --- a/packages/cli-conformance/src/findings.ts +++ b/packages/cli-conformance/src/findings.ts @@ -27,6 +27,8 @@ export type FindingKind = | "validator-malformed" | "pack-failed" | "install-failed" + /** npm never returned: the resolver is backtracking, not failing. */ + | "install-timed-out" | "bin-failed" /** The shell and a family it mounts disagree about the engine version. */ | "engine-pin-mismatch" diff --git a/packages/cli-conformance/src/tarball-io.ts b/packages/cli-conformance/src/tarball-io.ts index 7f97472c..59b5f91a 100644 --- a/packages/cli-conformance/src/tarball-io.ts +++ b/packages/cli-conformance/src/tarball-io.ts @@ -87,7 +87,7 @@ export function realTarballIo( return files; }, - async installSandbox({ sandboxDir, rootTarball, overrides }) { + async installSandbox({ sandboxDir, rootTarball, overrides, timeoutMs }) { const dir = resolve(sandboxDir); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); @@ -115,11 +115,18 @@ export function realTarballIo( cwd: dir, env: { ...process.env, COREPACK_ENABLE_STRICT: "0" }, maxBuffer: 64 * 1024 * 1024, + timeout: timeoutMs, + killSignal: "SIGKILL", }, ); return { ok: true as const }; } catch (error) { - return { ok: false as const, output: installErrorOutput(error) }; + const timedOut = (error as { killed?: boolean }).killed === true; + return { + ok: false as const, + timedOut, + output: installErrorOutput(error), + }; } }, diff --git a/packages/cli-conformance/tests/tarball.test.ts b/packages/cli-conformance/tests/tarball.test.ts index e805b26c..6f1b41d4 100644 --- a/packages/cli-conformance/tests/tarball.test.ts +++ b/packages/cli-conformance/tests/tarball.test.ts @@ -387,6 +387,7 @@ describe("checkTarball", () => { installSandbox: () => Promise.resolve({ ok: false as const, + timedOut: false, output: "ETIMEDOUT registry.npmjs.org", }), }); @@ -396,6 +397,38 @@ describe("checkTarball", () => { expect(installs[0]?.detail).toContain("ETIMEDOUT"); }); + test("an install npm never finishes is its own finding, names the cap, and starts no bin", async () => { + const started: string[] = []; + const io = fakeIo({ + installSandbox: ({ timeoutMs }) => + Promise.resolve({ + ok: false as const, + timedOut: true, + output: `killed after ${timeoutMs}ms\nnpm warn ERESOLVE overriding peer dependency\nnpm warn While resolving: @effect/sql-d1@4.0.0-rc.114`, + }), + startBin: ({ binName }) => { + started.push(binName); + return Promise.resolve({ + exitCode: 0, + stdout: "", + stderr: "", + timedOut: false, + }); + }, + }); + const findings = await checkTarball( + { ...input(), installTimeoutMs: 120_000 }, + io, + ); + const timeouts = findings.filter((f) => f.kind === "install-timed-out"); + expect(timeouts).toHaveLength(1); + expect(timeouts[0]?.summary).toContain("120s"); + expect(timeouts[0]?.detail).toContain("backtracking"); + expect(timeouts[0]?.detail).toContain("@effect/sql-d1@4.0.0-rc.114"); + expect(findings.filter((f) => f.kind === "install-failed")).toHaveLength(0); + expect(started).toHaveLength(0); + }); + test("3b: every declared bin is started; a non-zero exit names the bin", async () => { const started: string[] = []; const io = fakeIo({