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