Skip to content

Commit 48199c3

Browse files
Add reproducer test for #14445 unhandled rejection in execProcess
execProcess() in src/core/process.ts calls process.stdin.close() without awaiting the returned Promise. When the child closes/errors its stdin first, that Promise rejects with "Writable stream is closed or errored." and surfaces as an uncaught rejection that aborts the render — bypassing the try/catch in analyzeNeededPackages. These tests run many iterations of execProcess scenarios that exercise the same code path and assert no unhandled rejection fires. Pass on macOS arm64; expected to fail on Ubuntu CI if the diagnosis is correct.
1 parent 8262be2 commit 48199c3

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* exec-process-stdin.test.ts
3+
*
4+
* Reproducer for #14445.
5+
*
6+
* src/core/process.ts execProcess() calls `process.stdin.close()` without
7+
* awaiting the returned Promise. If the child closes/errors its stdin
8+
* before the parent's close() runs, that Promise rejects with "Writable
9+
* stream is closed or errored." and surfaces as an unhandled rejection,
10+
* aborting the render.
11+
*
12+
* Manifests intermittently on Linux (Ubuntu 22.04 and 24.04) when running
13+
* typst-gather. Has not reproduced on macOS arm64 in 500-iter probes.
14+
*
15+
* These tests run many iterations of execProcess scenarios that exercise
16+
* the same code path and assert that no unhandled rejection occurs.
17+
*
18+
* Copyright (C) 2026 Posit Software, PBC
19+
*/
20+
21+
import { unitTest } from "../test.ts";
22+
import { assertEquals } from "testing/asserts";
23+
import { isWindows } from "../../src/deno_ral/platform.ts";
24+
import { execProcess } from "../../src/core/process.ts";
25+
import { existsSync } from "../../src/deno_ral/fs.ts";
26+
import { architectureToolsPath } from "../../src/core/resources.ts";
27+
28+
const ITERS = 200;
29+
const TOML = 'discover = ["nonexistent.typ"]\npackage-cache = []\n';
30+
31+
// Wrap the body in an unhandledrejection listener so Deno's runner can't
32+
// race us — we count rejections explicitly and assert at the end.
33+
async function withRejectionTracking(
34+
body: () => Promise<void>,
35+
): Promise<{ count: number; last: string; samples: string[] }> {
36+
let count = 0;
37+
let last = "";
38+
const samples: string[] = [];
39+
const handler = (ev: PromiseRejectionEvent) => {
40+
count++;
41+
// deno-lint-ignore no-explicit-any
42+
const reason: any = ev.reason;
43+
last = reason?.message ?? String(reason);
44+
if (samples.length < 5) samples.push(last);
45+
ev.preventDefault();
46+
};
47+
globalThis.addEventListener("unhandledrejection", handler);
48+
try {
49+
await body();
50+
// Give any deferred rejections a chance to surface.
51+
await new Promise((r) => setTimeout(r, 250));
52+
} finally {
53+
globalThis.removeEventListener("unhandledrejection", handler);
54+
}
55+
return { count, last, samples };
56+
}
57+
58+
async function loop(
59+
cmd: string,
60+
args: string[],
61+
stdin: string,
62+
): Promise<void> {
63+
for (let i = 0; i < ITERS; i++) {
64+
try {
65+
await execProcess(
66+
{ cmd, args, stdout: "piped", stderr: "piped" },
67+
stdin,
68+
);
69+
} catch {
70+
// execProcess may throw legitimately (e.g. exit 1). We are hunting
71+
// unhandled rejections from the unawaited stdin.close(), which fire
72+
// on a separate microtask and are not caught by `try { await ... }`.
73+
}
74+
}
75+
}
76+
77+
// 1. Child exits without reading stdin. Closest synthetic to the
78+
// typst-gather GLIBC failure: binary exits before consuming any input.
79+
unitTest(
80+
"execProcess stdin.close - child that exits without reading (#14445)",
81+
async () => {
82+
if (isWindows) return;
83+
const r = await withRejectionTracking(() => loop("true", [], TOML));
84+
assertEquals(
85+
r.count,
86+
0,
87+
`${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` +
88+
`samples=${JSON.stringify(r.samples, null, 2)}`,
89+
);
90+
},
91+
);
92+
93+
// 2. Child errors out fast.
94+
unitTest(
95+
"execProcess stdin.close - child that exits with error (#14445)",
96+
async () => {
97+
if (isWindows) return;
98+
const r = await withRejectionTracking(() =>
99+
loop("sh", ["-c", "exit 1"], TOML)
100+
);
101+
assertEquals(
102+
r.count,
103+
0,
104+
`${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` +
105+
`samples=${JSON.stringify(r.samples, null, 2)}`,
106+
);
107+
},
108+
);
109+
110+
// 3. Child reads all of stdin, writes to stdout, exits cleanly. Mimics the
111+
// success path of typst-gather analyze.
112+
unitTest(
113+
"execProcess stdin.close - child that consumes stdin then exits (#14445)",
114+
async () => {
115+
if (isWindows) return;
116+
const r = await withRejectionTracking(() => loop("cat", [], TOML));
117+
assertEquals(
118+
r.count,
119+
0,
120+
`${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` +
121+
`samples=${JSON.stringify(r.samples, null, 2)}`,
122+
);
123+
},
124+
);
125+
126+
// 4. Real typst-gather, if the binary is present in the dist tree.
127+
// This is the actual failing path in #14445.
128+
unitTest(
129+
"execProcess stdin.close - real typst-gather analyze (#14445)",
130+
async () => {
131+
if (isWindows) return;
132+
const binary = architectureToolsPath("typst-gather");
133+
if (!existsSync(binary)) {
134+
// Binary not in dist tree on this runner; nothing to test.
135+
return;
136+
}
137+
const r = await withRejectionTracking(() =>
138+
loop(binary, ["analyze", "-"], TOML)
139+
);
140+
assertEquals(
141+
r.count,
142+
0,
143+
`${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` +
144+
`samples=${JSON.stringify(r.samples, null, 2)}`,
145+
);
146+
},
147+
);

0 commit comments

Comments
 (0)