diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index e0f8ad6be..177ec6cc1 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Contained timed-out child lifecycle rejections so `ptree` callers cannot leak an unhandled `TimeoutError` after settling ([#6635](https://github.com/can1357/oh-my-pi/issues/6635)). + ## [17.0.9] - 2026-07-23 ### Breaking Changes diff --git a/packages/utils/src/ptree.ts b/packages/utils/src/ptree.ts index 472847a70..179656cfb 100644 --- a/packages/utils/src/ptree.ts +++ b/packages/utils/src/ptree.ts @@ -309,6 +309,7 @@ export class ChildProcess { attachTimeout(ms: number): void { if (ms <= 0 || this.proc.killed) return; + this.#exited.catch(() => {}); Promise.race([ Bun.sleep(ms).then(() => true), this.proc.exited.then( diff --git a/packages/utils/test/ptree-timeout.test.ts b/packages/utils/test/ptree-timeout.test.ts new file mode 100644 index 000000000..5127e74a0 --- /dev/null +++ b/packages/utils/test/ptree-timeout.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { spawn, TimeoutError } from "@oh-my-pi/pi-utils/ptree"; + +describe("ptree timeout", () => { + it("contains the lifecycle rejection when the caller does not observe exited", async () => { + const unhandled = new Set(); + const onUnhandled = (reason: unknown) => { + unhandled.add(reason); + }; + process.on("unhandledRejection", onUnhandled); + + try { + // Bun's subprocess timeout uses the platform clock; fake timers cannot drive this lifecycle. + using child = spawn(["bun", "-e", "Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0)"], { + timeout: 20, + }); + await child.nothrow().text(); + await child.proc.exited; + const nextTurn = Promise.withResolvers(); + setImmediate(nextTurn.resolve); + await nextTurn.promise; + + expect(child.exitReason).toBeInstanceOf(TimeoutError); + expect(unhandled.has(child.exitReason)).toBe(false); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +});