Skip to content

Commit c5653be

Browse files
Fix unhandled rejection from process.stdin.close() in execProcess
execProcess() in src/core/process.ts called process.stdin.close() without awaiting the returned Promise. When the child closes/errors its stdin before the parent's close completes, the close Promise rejects with "Writable stream is closed or errored." Because the Promise was unhandled, the rejection escaped the surrounding try/catch and surfaced on a later microtask as an uncaught Deno rejection that aborted the render — bypassing the try/catch in analyzeNeededPackages that was meant to fall back gracefully. Manifests on Linux at roughly a 1% race rate when typst-gather analyze runs against a broken or fast-failing input. Confirmed on Ubuntu CI; not reproduced on macOS arm64. The fix is to await the close() inside a try/catch — the child's exit status reflects any real failure, so the close rejection is not actionable from execProcess. Also surface the captured stderr in the fallback warning from analyzeNeededPackages, so the next user with a broken typst-gather binary can diagnose without objdump. Includes a regression test that runs four execProcess scenarios 1000 iterations each with a global unhandledrejection listener and asserts no rejection fires. Fixes #14445
1 parent 48199c3 commit c5653be

4 files changed

Lines changed: 73 additions & 83 deletions

File tree

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,5 @@ All changes included in 1.10:
5959
- ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy.
6060
- ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved.
6161
- ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization.
62+
- ([#14445](https://github.com/quarto-dev/quarto-cli/issues/14445)): Fix intermittent `Uncaught (in promise) TypeError: Writable stream is closed or errored.` aborting renders on Linux. `execProcess` now awaits and swallows the rejection from `process.stdin.close()` when the child closes its stdin first. The captured stderr is now also surfaced when `typst-gather analyze` falls back to staging all packages, so failures are diagnosable without bypassing `quarto`.
6263
- ([#14359](https://github.com/quarto-dev/quarto-cli/issues/14359)): Fix intermediate `.quarto_ipynb` file not being deleted after rendering a `.qmd` with Jupyter engine, causing numbered variants (`_1`, `_2`, ...) to accumulate on disk across renders.

src/command/render/output-typst.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,12 @@ async function analyzeNeededPackages(
119119
name,
120120
version,
121121
}));
122-
} catch {
122+
} catch (e) {
123123
// Fallback: if analyze fails, stage everything (current behavior)
124-
warning("typst-gather analyze failed; staging all packages as fallback");
124+
const detail = e instanceof Error ? e.message : String(e);
125+
warning(
126+
`typst-gather analyze failed; staging all packages as fallback: ${detail}`,
127+
);
125128
return null;
126129
}
127130
}

src/core/process.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,17 @@ export async function execProcess(
9494
offset += window.byteLength;
9595
}
9696
stdinWriter.releaseLock();
97-
process.stdin.close();
97+
try {
98+
await process.stdin.close();
99+
} catch (e) {
100+
// The child may have closed its read end of the pipe before our
101+
// close() completed (e.g. exited fast, failed to spawn). The
102+
// resulting "Writable stream is closed or errored." is not a
103+
// failure of execProcess — the child's exit status reflects any
104+
// real problem. Swallow it so it doesn't escape as an unhandled
105+
// rejection that aborts the process. See #14445.
106+
debug(`[execProcess] stdin.close() rejected: ${e}`);
107+
}
98108
}
99109

100110
let stdoutText = "";
Lines changed: 56 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
/*
22
* exec-process-stdin.test.ts
33
*
4-
* Reproducer for #14445.
4+
* Regression test for #14445.
55
*
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.
6+
* src/core/process.ts execProcess() must not leak unhandled promise
7+
* rejections from `process.stdin.close()`. If the child closes/errors
8+
* its stdin before the parent's close completes, the close Promise
9+
* rejects with "Writable stream is closed or errored."; an unawaited
10+
* close lets that rejection escape the surrounding try/catch and
11+
* surface as an uncaught Deno rejection that aborts the render.
1112
*
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.
13+
* Manifests on Linux at roughly a 1% race rate when the child exits
14+
* without reading stdin (typst-gather analyze of a broken or
15+
* fast-failing input). Has not been observed on macOS arm64.
1416
*
15-
* These tests run many iterations of execProcess scenarios that exercise
16-
* the same code path and assert that no unhandled rejection occurs.
17+
* The race is timing-dependent, so each scenario runs many iterations
18+
* and asserts no unhandled rejection fires.
1719
*
1820
* Copyright (C) 2026 Posit Software, PBC
1921
*/
@@ -25,7 +27,9 @@ import { execProcess } from "../../src/core/process.ts";
2527
import { existsSync } from "../../src/deno_ral/fs.ts";
2628
import { architectureToolsPath } from "../../src/core/resources.ts";
2729

28-
const ITERS = 200;
30+
// Iteration count chosen so that a ~1% race produces ≥1 hit with >99.99%
31+
// probability — enough to fail the test reliably if the bug returns.
32+
const ITERS = 1000;
2933
const TOML = 'discover = ["nonexistent.typ"]\npackage-cache = []\n';
3034

3135
// Wrap the body in an unhandledrejection listener so Deno's runner can't
@@ -59,8 +63,9 @@ async function loop(
5963
cmd: string,
6064
args: string[],
6165
stdin: string,
66+
iters = ITERS,
6267
): Promise<void> {
63-
for (let i = 0; i < ITERS; i++) {
68+
for (let i = 0; i < iters; i++) {
6469
try {
6570
await execProcess(
6671
{ cmd, args, stdout: "piped", stderr: "piped" },
@@ -74,74 +79,45 @@ async function loop(
7479
}
7580
}
7681

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-
);
82+
function assertNoRejections(
83+
r: { count: number; last: string; samples: string[] },
84+
) {
85+
assertEquals(
86+
r.count,
87+
0,
88+
`${r.count} unhandled rejections. last="${r.last}"\n` +
89+
`samples=${JSON.stringify(r.samples, null, 2)}`,
90+
);
91+
}
9292

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-
);
93+
// Child exits without reading stdin. This is the scenario that
94+
// reliably reproduces the bug on Linux (~1% race rate).
95+
unitTest("execProcess - child exits without reading stdin", async () => {
96+
if (isWindows) return;
97+
assertNoRejections(await withRejectionTracking(() => loop("true", [], TOML)));
98+
});
10999

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-
);
100+
// Child errors out fast.
101+
unitTest("execProcess - child exits with error", async () => {
102+
if (isWindows) return;
103+
assertNoRejections(
104+
await withRejectionTracking(() => loop("sh", ["-c", "exit 1"], TOML)),
105+
);
106+
});
125107

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-
);
108+
// Child reads all of stdin, writes to stdout, exits cleanly. Mimics the
109+
// success path of typst-gather analyze.
110+
unitTest("execProcess - child consumes stdin then exits", async () => {
111+
if (isWindows) return;
112+
assertNoRejections(await withRejectionTracking(() => loop("cat", [], TOML)));
113+
});
114+
115+
// Real typst-gather, if the binary is present in the dist tree.
116+
unitTest("execProcess - real typst-gather analyze", async () => {
117+
if (isWindows) return;
118+
const binary = architectureToolsPath("typst-gather");
119+
if (!existsSync(binary)) return;
120+
assertNoRejections(
121+
await withRejectionTracking(() => loop(binary, ["analyze", "-"], TOML)),
122+
);
123+
});

0 commit comments

Comments
 (0)