From 48199c3e7aaf03d1b0373aeb14007ab058c72bda Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 28 Apr 2026 07:43:43 -0400 Subject: [PATCH 1/2] Add reproducer test for #14445 unhandled rejection in execProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/unit/exec-process-stdin.test.ts | 147 ++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/unit/exec-process-stdin.test.ts diff --git a/tests/unit/exec-process-stdin.test.ts b/tests/unit/exec-process-stdin.test.ts new file mode 100644 index 00000000000..340d7ec79de --- /dev/null +++ b/tests/unit/exec-process-stdin.test.ts @@ -0,0 +1,147 @@ +/* + * exec-process-stdin.test.ts + * + * Reproducer for #14445. + * + * src/core/process.ts execProcess() calls `process.stdin.close()` without + * awaiting the returned Promise. If the child closes/errors its stdin + * before the parent's close() runs, that Promise rejects with "Writable + * stream is closed or errored." and surfaces as an unhandled rejection, + * aborting the render. + * + * Manifests intermittently on Linux (Ubuntu 22.04 and 24.04) when running + * typst-gather. Has not reproduced on macOS arm64 in 500-iter probes. + * + * These tests run many iterations of execProcess scenarios that exercise + * the same code path and assert that no unhandled rejection occurs. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; +import { isWindows } from "../../src/deno_ral/platform.ts"; +import { execProcess } from "../../src/core/process.ts"; +import { existsSync } from "../../src/deno_ral/fs.ts"; +import { architectureToolsPath } from "../../src/core/resources.ts"; + +const ITERS = 200; +const TOML = 'discover = ["nonexistent.typ"]\npackage-cache = []\n'; + +// Wrap the body in an unhandledrejection listener so Deno's runner can't +// race us — we count rejections explicitly and assert at the end. +async function withRejectionTracking( + body: () => Promise, +): Promise<{ count: number; last: string; samples: string[] }> { + let count = 0; + let last = ""; + const samples: string[] = []; + const handler = (ev: PromiseRejectionEvent) => { + count++; + // deno-lint-ignore no-explicit-any + const reason: any = ev.reason; + last = reason?.message ?? String(reason); + if (samples.length < 5) samples.push(last); + ev.preventDefault(); + }; + globalThis.addEventListener("unhandledrejection", handler); + try { + await body(); + // Give any deferred rejections a chance to surface. + await new Promise((r) => setTimeout(r, 250)); + } finally { + globalThis.removeEventListener("unhandledrejection", handler); + } + return { count, last, samples }; +} + +async function loop( + cmd: string, + args: string[], + stdin: string, +): Promise { + for (let i = 0; i < ITERS; i++) { + try { + await execProcess( + { cmd, args, stdout: "piped", stderr: "piped" }, + stdin, + ); + } catch { + // execProcess may throw legitimately (e.g. exit 1). We are hunting + // unhandled rejections from the unawaited stdin.close(), which fire + // on a separate microtask and are not caught by `try { await ... }`. + } + } +} + +// 1. Child exits without reading stdin. Closest synthetic to the +// typst-gather GLIBC failure: binary exits before consuming any input. +unitTest( + "execProcess stdin.close - child that exits without reading (#14445)", + async () => { + if (isWindows) return; + const r = await withRejectionTracking(() => loop("true", [], TOML)); + assertEquals( + r.count, + 0, + `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + + `samples=${JSON.stringify(r.samples, null, 2)}`, + ); + }, +); + +// 2. Child errors out fast. +unitTest( + "execProcess stdin.close - child that exits with error (#14445)", + async () => { + if (isWindows) return; + const r = await withRejectionTracking(() => + loop("sh", ["-c", "exit 1"], TOML) + ); + assertEquals( + r.count, + 0, + `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + + `samples=${JSON.stringify(r.samples, null, 2)}`, + ); + }, +); + +// 3. Child reads all of stdin, writes to stdout, exits cleanly. Mimics the +// success path of typst-gather analyze. +unitTest( + "execProcess stdin.close - child that consumes stdin then exits (#14445)", + async () => { + if (isWindows) return; + const r = await withRejectionTracking(() => loop("cat", [], TOML)); + assertEquals( + r.count, + 0, + `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + + `samples=${JSON.stringify(r.samples, null, 2)}`, + ); + }, +); + +// 4. Real typst-gather, if the binary is present in the dist tree. +// This is the actual failing path in #14445. +unitTest( + "execProcess stdin.close - real typst-gather analyze (#14445)", + async () => { + if (isWindows) return; + const binary = architectureToolsPath("typst-gather"); + if (!existsSync(binary)) { + // Binary not in dist tree on this runner; nothing to test. + return; + } + const r = await withRejectionTracking(() => + loop(binary, ["analyze", "-"], TOML) + ); + assertEquals( + r.count, + 0, + `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + + `samples=${JSON.stringify(r.samples, null, 2)}`, + ); + }, +); From c5653bee1047d91c9b6f1a9ac34020489005274a Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 28 Apr 2026 08:35:20 -0400 Subject: [PATCH 2/2] Fix unhandled rejection from process.stdin.close() in execProcess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- news/changelog-1.10.md | 1 + src/command/render/output-typst.ts | 7 +- src/core/process.ts | 12 ++- tests/unit/exec-process-stdin.test.ts | 136 +++++++++++--------------- 4 files changed, 73 insertions(+), 83 deletions(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 2e9e9d05980..d5da8ded432 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -59,4 +59,5 @@ All changes included in 1.10: - ([#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. - ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved. - ([#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. +- ([#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`. - ([#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. \ No newline at end of file diff --git a/src/command/render/output-typst.ts b/src/command/render/output-typst.ts index ae7c3a59486..798c7aa7fd8 100644 --- a/src/command/render/output-typst.ts +++ b/src/command/render/output-typst.ts @@ -119,9 +119,12 @@ async function analyzeNeededPackages( name, version, })); - } catch { + } catch (e) { // Fallback: if analyze fails, stage everything (current behavior) - warning("typst-gather analyze failed; staging all packages as fallback"); + const detail = e instanceof Error ? e.message : String(e); + warning( + `typst-gather analyze failed; staging all packages as fallback: ${detail}`, + ); return null; } } diff --git a/src/core/process.ts b/src/core/process.ts index 131ccaf37d7..0cd4915a9fa 100644 --- a/src/core/process.ts +++ b/src/core/process.ts @@ -94,7 +94,17 @@ export async function execProcess( offset += window.byteLength; } stdinWriter.releaseLock(); - process.stdin.close(); + try { + await process.stdin.close(); + } catch (e) { + // The child may have closed its read end of the pipe before our + // close() completed (e.g. exited fast, failed to spawn). The + // resulting "Writable stream is closed or errored." is not a + // failure of execProcess — the child's exit status reflects any + // real problem. Swallow it so it doesn't escape as an unhandled + // rejection that aborts the process. See #14445. + debug(`[execProcess] stdin.close() rejected: ${e}`); + } } let stdoutText = ""; diff --git a/tests/unit/exec-process-stdin.test.ts b/tests/unit/exec-process-stdin.test.ts index 340d7ec79de..4a59c93783d 100644 --- a/tests/unit/exec-process-stdin.test.ts +++ b/tests/unit/exec-process-stdin.test.ts @@ -1,19 +1,21 @@ /* * exec-process-stdin.test.ts * - * Reproducer for #14445. + * Regression test for #14445. * - * src/core/process.ts execProcess() calls `process.stdin.close()` without - * awaiting the returned Promise. If the child closes/errors its stdin - * before the parent's close() runs, that Promise rejects with "Writable - * stream is closed or errored." and surfaces as an unhandled rejection, - * aborting the render. + * src/core/process.ts execProcess() must not leak unhandled promise + * rejections from `process.stdin.close()`. If the child closes/errors + * its stdin before the parent's close completes, the close Promise + * rejects with "Writable stream is closed or errored."; an unawaited + * close lets that rejection escape the surrounding try/catch and + * surface as an uncaught Deno rejection that aborts the render. * - * Manifests intermittently on Linux (Ubuntu 22.04 and 24.04) when running - * typst-gather. Has not reproduced on macOS arm64 in 500-iter probes. + * Manifests on Linux at roughly a 1% race rate when the child exits + * without reading stdin (typst-gather analyze of a broken or + * fast-failing input). Has not been observed on macOS arm64. * - * These tests run many iterations of execProcess scenarios that exercise - * the same code path and assert that no unhandled rejection occurs. + * The race is timing-dependent, so each scenario runs many iterations + * and asserts no unhandled rejection fires. * * Copyright (C) 2026 Posit Software, PBC */ @@ -25,7 +27,9 @@ import { execProcess } from "../../src/core/process.ts"; import { existsSync } from "../../src/deno_ral/fs.ts"; import { architectureToolsPath } from "../../src/core/resources.ts"; -const ITERS = 200; +// Iteration count chosen so that a ~1% race produces ≥1 hit with >99.99% +// probability — enough to fail the test reliably if the bug returns. +const ITERS = 1000; const TOML = 'discover = ["nonexistent.typ"]\npackage-cache = []\n'; // Wrap the body in an unhandledrejection listener so Deno's runner can't @@ -59,8 +63,9 @@ async function loop( cmd: string, args: string[], stdin: string, + iters = ITERS, ): Promise { - for (let i = 0; i < ITERS; i++) { + for (let i = 0; i < iters; i++) { try { await execProcess( { cmd, args, stdout: "piped", stderr: "piped" }, @@ -74,74 +79,45 @@ async function loop( } } -// 1. Child exits without reading stdin. Closest synthetic to the -// typst-gather GLIBC failure: binary exits before consuming any input. -unitTest( - "execProcess stdin.close - child that exits without reading (#14445)", - async () => { - if (isWindows) return; - const r = await withRejectionTracking(() => loop("true", [], TOML)); - assertEquals( - r.count, - 0, - `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + - `samples=${JSON.stringify(r.samples, null, 2)}`, - ); - }, -); +function assertNoRejections( + r: { count: number; last: string; samples: string[] }, +) { + assertEquals( + r.count, + 0, + `${r.count} unhandled rejections. last="${r.last}"\n` + + `samples=${JSON.stringify(r.samples, null, 2)}`, + ); +} -// 2. Child errors out fast. -unitTest( - "execProcess stdin.close - child that exits with error (#14445)", - async () => { - if (isWindows) return; - const r = await withRejectionTracking(() => - loop("sh", ["-c", "exit 1"], TOML) - ); - assertEquals( - r.count, - 0, - `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + - `samples=${JSON.stringify(r.samples, null, 2)}`, - ); - }, -); +// Child exits without reading stdin. This is the scenario that +// reliably reproduces the bug on Linux (~1% race rate). +unitTest("execProcess - child exits without reading stdin", async () => { + if (isWindows) return; + assertNoRejections(await withRejectionTracking(() => loop("true", [], TOML))); +}); -// 3. Child reads all of stdin, writes to stdout, exits cleanly. Mimics the -// success path of typst-gather analyze. -unitTest( - "execProcess stdin.close - child that consumes stdin then exits (#14445)", - async () => { - if (isWindows) return; - const r = await withRejectionTracking(() => loop("cat", [], TOML)); - assertEquals( - r.count, - 0, - `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + - `samples=${JSON.stringify(r.samples, null, 2)}`, - ); - }, -); +// Child errors out fast. +unitTest("execProcess - child exits with error", async () => { + if (isWindows) return; + assertNoRejections( + await withRejectionTracking(() => loop("sh", ["-c", "exit 1"], TOML)), + ); +}); -// 4. Real typst-gather, if the binary is present in the dist tree. -// This is the actual failing path in #14445. -unitTest( - "execProcess stdin.close - real typst-gather analyze (#14445)", - async () => { - if (isWindows) return; - const binary = architectureToolsPath("typst-gather"); - if (!existsSync(binary)) { - // Binary not in dist tree on this runner; nothing to test. - return; - } - const r = await withRejectionTracking(() => - loop(binary, ["analyze", "-"], TOML) - ); - assertEquals( - r.count, - 0, - `${r.count}/${ITERS} unhandled rejections. last="${r.last}"\n` + - `samples=${JSON.stringify(r.samples, null, 2)}`, - ); - }, -); +// Child reads all of stdin, writes to stdout, exits cleanly. Mimics the +// success path of typst-gather analyze. +unitTest("execProcess - child consumes stdin then exits", async () => { + if (isWindows) return; + assertNoRejections(await withRejectionTracking(() => loop("cat", [], TOML))); +}); + +// Real typst-gather, if the binary is present in the dist tree. +unitTest("execProcess - real typst-gather analyze", async () => { + if (isWindows) return; + const binary = architectureToolsPath("typst-gather"); + if (!existsSync(binary)) return; + assertNoRejections( + await withRejectionTracking(() => loop(binary, ["analyze", "-"], TOML)), + ); +});