Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/cli-conformance/src/checks/tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export interface TarballIo {
readonly rootTarball: string;
/** Version-qualified name → absolute `file:` tarball path. */
readonly overrides: Readonly<Record<string, string>>;
}): Promise<{ ok: true } | { ok: false; output: string }>;
readonly timeoutMs: number;
}): Promise<{ ok: true } | { ok: false; timedOut: boolean; output: string }>;
readInstalledManifest(
sandboxDir: string,
name: string,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-conformance/src/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions packages/cli-conformance/src/tarball-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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),
};
}
},

Expand Down
33 changes: 33 additions & 0 deletions packages/cli-conformance/tests/tarball.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ describe("checkTarball", () => {
installSandbox: () =>
Promise.resolve({
ok: false as const,
timedOut: false,
output: "ETIMEDOUT registry.npmjs.org",
}),
});
Expand All @@ -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({
Expand Down
Loading