mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user