fix(utils): contained ptree timeout rejections

- Observed timed-out child lifecycle failures without altering awaited rejection semantics.
- Added coverage for callers that settle without observing the lifecycle promise.

Fixes #6635
This commit is contained in:
roboomp
2026-07-25 20:17:08 +00:00
parent 667111575e
commit 8376555099
3 changed files with 34 additions and 0 deletions
+4
View File
@@ -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
+1
View File
@@ -309,6 +309,7 @@ export class ChildProcess<In extends InMask = InMask> {
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(
+29
View File
@@ -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<unknown>();
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<void>();
setImmediate(nextTurn.resolve);
await nextTurn.promise;
expect(child.exitReason).toBeInstanceOf(TimeoutError);
expect(unhandled.has(child.exitReason)).toBe(false);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
});