Files
my-pi/pi-subagents/test/abortable.test.ts
T

165 lines
7.2 KiB
TypeScript

// src/abortable.ts was the only module in the repo that no test imported. It is
// reached indirectly through the get_subagent_result wait paths, but its two
// real invariants were never asserted:
//
// 1. Aborting cancels the CALLER'S WAIT, not the work (#159). The background
// child keeps running and its result stays unconsumed — so the wrapped
// promise settles later, after `abortable` already rejected. What makes
// that safe is that `abortable` subscribes to the child with BOTH handlers
// (`promise.then(onFulfilled, onRejected)`): the child's eventual rejection
// is therefore already handled and goes nowhere. Drop the rejection handler
// and a child that fails after the user pressed Esc raises an unhandled
// rejection at the process level, long after the abort, with nothing
// pointing back here.
//
// (The `settled` latch is defensive, not load-bearing: settling an already
// settled promise a second time is a no-op per spec, so removing the latch
// changes nothing observable. No test here claims to protect it.)
// 2. The abort listener is removed on every settle path. The parent's signal
// outlives many waits, so a leak here accumulates handlers across a fleet.
//
// Both failures are invisible to a test that only checks the happy path, which
// is why they are asserted directly rather than through a consumer.
import { afterEach, describe, expect, it, vi } from "vitest";
import { abortable } from "../src/abortable.js";
/**
* Collect unhandled rejections for the duration of a block.
*
* Vitest installs its own handler, so `process.on` alone would not observe them —
* `removeAllListeners` is used and the originals restored afterwards.
*/
async function withUnhandledRejectionSpy(fn: () => Promise<void>): Promise<unknown[]> {
const seen: unknown[] = [];
const prior = process.listeners("unhandledRejection");
process.removeAllListeners("unhandledRejection");
const capture = (reason: unknown) => { seen.push(reason); };
process.on("unhandledRejection", capture);
try {
await fn();
// Unhandled rejections are reported on a later macrotask, not a microtask.
await new Promise((r) => setTimeout(r, 20));
} finally {
process.off("unhandledRejection", capture);
for (const l of prior) process.on("unhandledRejection", l as never);
}
return seen;
}
describe("abortable", () => {
afterEach(() => vi.useRealTimers());
it("returns the original promise untouched when there is no signal", async () => {
const promise = Promise.resolve("value");
// Identity, not just equality: with no signal there is nothing to race, so
// wrapping would add a needless tick and a needless rejection surface.
expect(abortable(promise)).toBe(promise);
await expect(promise).resolves.toBe("value");
});
it("rejects immediately with the signal's reason when already aborted", async () => {
const controller = new AbortController();
controller.abort(new Error("gone before we started"));
await expect(abortable(new Promise(() => {}), controller.signal))
.rejects.toThrow("gone before we started");
});
it("resolves normally when the signal never fires", async () => {
const controller = new AbortController();
await expect(abortable(Promise.resolve(42), controller.signal)).resolves.toBe(42);
});
it("propagates a rejection from the wrapped promise", async () => {
const controller = new AbortController();
await expect(abortable(Promise.reject(new Error("work failed")), controller.signal))
.rejects.toThrow("work failed");
});
it("rejects with the abort reason when the signal fires mid-wait", async () => {
const controller = new AbortController();
const pending = abortable(new Promise(() => {}), controller.signal);
controller.abort(new Error("user pressed Esc"));
await expect(pending).rejects.toThrow("user pressed Esc");
});
it("absorbs a late REJECTION of the wrapped promise after an abort", async () => {
// The #159 guarantee: the child was not cancelled, so it can still fail
// after we stopped waiting. That failure must go nowhere, not to the
// process's unhandled-rejection handler. Verified to fail if `abortable`
// stops attaching a rejection handler to the wrapped promise.
const unhandled = await withUnhandledRejectionSpy(async () => {
const controller = new AbortController();
let failChild: ((e: unknown) => void) | undefined;
const childWork = new Promise((_resolve, reject) => { failChild = reject; });
const pending = abortable(childWork, controller.signal);
controller.abort(new Error("stopped waiting"));
await expect(pending).rejects.toThrow("stopped waiting");
failChild!(new Error("child died later"));
await new Promise((r) => setTimeout(r, 5));
});
expect(unhandled).toEqual([]);
});
it("absorbs a late RESOLUTION of the wrapped promise after an abort", async () => {
const controller = new AbortController();
let finishChild: ((v: string) => void) | undefined;
const childWork = new Promise<string>((resolve) => { finishChild = resolve; });
const pending = abortable(childWork, controller.signal);
controller.abort(new Error("stopped waiting"));
await expect(pending).rejects.toThrow("stopped waiting");
finishChild!("child finished anyway");
// The child's own result is still available to whoever holds it — abortable
// cancelled the wait, not the work.
await expect(childWork).resolves.toBe("child finished anyway");
});
it("removes its abort listener once the promise resolves", async () => {
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
await abortable(Promise.resolve("done"), controller.signal);
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("removes its abort listener once the promise rejects", async () => {
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
await expect(abortable(Promise.reject(new Error("nope")), controller.signal)).rejects.toThrow();
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("a late abort after normal settlement is inert", async () => {
// A long-lived parent signal is aborted for unrelated reasons after this
// wait already finished; nothing should be re-rejected or throw.
const unhandled = await withUnhandledRejectionSpy(async () => {
const controller = new AbortController();
await expect(abortable(Promise.resolve("done"), controller.signal)).resolves.toBe("done");
controller.abort(new Error("much later"));
await new Promise((r) => setTimeout(r, 5));
});
expect(unhandled).toEqual([]);
});
it("does not leak a listener per wait on a shared parent signal", async () => {
// Many children wait on one parent signal over a session's lifetime.
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, "removeEventListener");
for (let i = 0; i < 20; i++) {
await abortable(Promise.resolve(i), controller.signal);
}
expect(remove).toHaveBeenCalledTimes(20);
});
});