@crewhaus/abort-controller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@crewhaus/abort-controller",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Parent/child abort tree with WeakRef cascade for cooperative cancellation",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "license": "Apache-2.0",
15
+ "author": {
16
+ "name": "Max Meier",
17
+ "email": "max@studiomax.io",
18
+ "url": "https://studiomax.io"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/crewhaus/factory.git",
23
+ "directory": "packages/abort-controller"
24
+ },
25
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/abort-controller#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/crewhaus/factory/issues"
28
+ },
29
+ "publishConfig": {
30
+ "access": "restricted"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE",
36
+ "NOTICE"
37
+ ]
38
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createAbortTree } from "./index";
3
+
4
+ describe("createAbortTree — basics", () => {
5
+ test("root has an unsignalled signal", () => {
6
+ const root = createAbortTree();
7
+ expect(root.signal.aborted).toBe(false);
8
+ });
9
+
10
+ test("aborting root signals its signal", () => {
11
+ const root = createAbortTree();
12
+ root.abort();
13
+ expect(root.signal.aborted).toBe(true);
14
+ });
15
+
16
+ test("abort reason propagates", () => {
17
+ const root = createAbortTree();
18
+ root.abort(new Error("test reason"));
19
+ expect((root.signal.reason as Error).message).toBe("test reason");
20
+ });
21
+ });
22
+
23
+ describe("parent → child cascade", () => {
24
+ test("parent abort cascades to child", () => {
25
+ const root = createAbortTree();
26
+ const child = root.child();
27
+ expect(child.signal.aborted).toBe(false);
28
+ root.abort();
29
+ expect(child.signal.aborted).toBe(true);
30
+ });
31
+
32
+ test("parent abort cascades to grandchild", () => {
33
+ const root = createAbortTree();
34
+ const child = root.child();
35
+ const grandchild = child.child();
36
+ root.abort();
37
+ expect(child.signal.aborted).toBe(true);
38
+ expect(grandchild.signal.aborted).toBe(true);
39
+ });
40
+
41
+ test("siblings are independent", () => {
42
+ const root = createAbortTree();
43
+ const a = root.child();
44
+ const b = root.child();
45
+ a.abort();
46
+ expect(a.signal.aborted).toBe(true);
47
+ expect(b.signal.aborted).toBe(false);
48
+ expect(root.signal.aborted).toBe(false);
49
+ });
50
+
51
+ test("child abort does NOT propagate to parent", () => {
52
+ const root = createAbortTree();
53
+ const child = root.child();
54
+ child.abort();
55
+ expect(child.signal.aborted).toBe(true);
56
+ expect(root.signal.aborted).toBe(false);
57
+ });
58
+
59
+ test("child constructed from already-aborted parent is born aborted", () => {
60
+ const root = createAbortTree();
61
+ root.abort();
62
+ const child = root.child();
63
+ expect(child.signal.aborted).toBe(true);
64
+ });
65
+
66
+ test("createAbortTree(parent) — external parent signal", () => {
67
+ const ctl = new AbortController();
68
+ const tree = createAbortTree(ctl.signal);
69
+ expect(tree.signal.aborted).toBe(false);
70
+ ctl.abort();
71
+ expect(tree.signal.aborted).toBe(true);
72
+ });
73
+ });
74
+
75
+ // T3: Integration — abort the parent and observe a real Bun.spawn'd child
76
+ // process exit (SIGTERM cascade via { signal }).
77
+ describe("T3 — child-process SIGTERM on parent abort", () => {
78
+ test("Bun.spawn(['sleep', '30'], { signal: child.signal }) exits when parent aborts", async () => {
79
+ const root = createAbortTree();
80
+ const child = root.child();
81
+
82
+ const proc = Bun.spawn(["sleep", "30"], {
83
+ signal: child.signal,
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ });
87
+
88
+ const start = Date.now();
89
+ // Abort 100 ms in to make sure the spawn has time to start.
90
+ setTimeout(() => root.abort(), 100);
91
+
92
+ const exitCode = await proc.exited;
93
+ const elapsedMs = Date.now() - start;
94
+
95
+ expect(elapsedMs).toBeLessThan(2_000); // sleep 30 — would have run for 30 s without abort
96
+ expect(child.signal.aborted).toBe(true);
97
+ // Bun's signal-driven exit yields a non-zero exit code (typically null/143).
98
+ // We just assert the process ended early.
99
+ expect(exitCode).not.toBe(0);
100
+ });
101
+ });
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Catalog R1 `abort-controller` — parent/child cancellation tree.
3
+ *
4
+ * Semantics:
5
+ * - Parent abort cascades to all children (recursively).
6
+ * - Sibling abort does NOT propagate.
7
+ * - Child abort does NOT propagate up to the parent.
8
+ * - If `parent` is already aborted at construction time, the child is born aborted.
9
+ *
10
+ * Memory: parent → child propagation goes through WeakRefs so an abandoned
11
+ * child doesn't pin the parent, and a finalized child's listener is removed
12
+ * from the parent on its own abort. Mirrors the proven pattern in
13
+ * `claude-code/utils/abortController.ts`.
14
+ *
15
+ * Listener limit: Node's default of 10 trips a warning when a busy turn fans
16
+ * out into many tool calls; we raise it to 50 per signal.
17
+ */
18
+ import { setMaxListeners } from "node:events";
19
+
20
+ const MAX_LISTENERS_PER_SIGNAL = 50;
21
+
22
+ export type AbortTree = {
23
+ readonly signal: AbortSignal;
24
+ abort(reason?: unknown): void;
25
+ child(): AbortTree;
26
+ };
27
+
28
+ /**
29
+ * Build an `AbortTree` rooted at the given parent (or no parent for a fresh
30
+ * root). The returned tree's `child()` produces children whose `signal`
31
+ * fires when this tree's `signal` fires.
32
+ */
33
+ export function createAbortTree(parent?: AbortSignal): AbortTree {
34
+ const controller = new AbortController();
35
+ setMaxListeners(MAX_LISTENERS_PER_SIGNAL, controller.signal);
36
+
37
+ if (parent !== undefined) {
38
+ if (parent.aborted) {
39
+ controller.abort(parent.reason);
40
+ } else {
41
+ attachParent(parent, controller);
42
+ }
43
+ }
44
+
45
+ return {
46
+ signal: controller.signal,
47
+ abort(reason?: unknown) {
48
+ controller.abort(reason);
49
+ },
50
+ child() {
51
+ return createAbortTree(controller.signal);
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Wire `parent` so its abort propagates to `childCtl`. Uses WeakRefs both ways
58
+ * so neither the parent nor the listener pins the other.
59
+ */
60
+ function attachParent(parent: AbortSignal, childCtl: AbortController): void {
61
+ const weakChild = new WeakRef(childCtl);
62
+
63
+ function onParentAbort(this: AbortSignal): void {
64
+ const ctl = weakChild.deref();
65
+ if (ctl !== undefined && !ctl.signal.aborted) {
66
+ ctl.abort(this.reason);
67
+ }
68
+ }
69
+
70
+ parent.addEventListener("abort", onParentAbort, { once: true });
71
+
72
+ // When the child aborts on its own, drop the parent listener so the parent
73
+ // doesn't accumulate dead listeners across many children.
74
+ childCtl.signal.addEventListener(
75
+ "abort",
76
+ () => {
77
+ parent.removeEventListener("abort", onParentAbort);
78
+ },
79
+ { once: true },
80
+ );
81
+ }