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({