@crewhaus/abort-controller 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +5 -10
  2. package/src/index.test.ts +170 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/abort-controller",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Parent/child abort tree with WeakRef cascade for cooperative cancellation",
6
6
  "main": "src/index.ts",
@@ -14,8 +14,8 @@
14
14
  "license": "Apache-2.0",
15
15
  "author": {
16
16
  "name": "Max Meier",
17
- "email": "max@studiomax.io",
18
- "url": "https://studiomax.io"
17
+ "email": "max@crewhaus.ai",
18
+ "url": "https://crewhaus.ai"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
@@ -27,12 +27,7 @@
27
27
  "url": "https://github.com/crewhaus/factory/issues"
28
28
  },
29
29
  "publishConfig": {
30
- "access": "restricted"
30
+ "access": "public"
31
31
  },
32
- "files": [
33
- "src",
34
- "README.md",
35
- "LICENSE",
36
- "NOTICE"
37
- ]
32
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
33
  }
package/src/index.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { getEventListeners } from "node:events";
2
3
  import { createAbortTree } from "./index";
3
4
 
4
5
  describe("createAbortTree — basics", () => {
@@ -72,6 +73,175 @@ describe("parent → child cascade", () => {
72
73
  });
73
74
  });
74
75
 
76
+ describe("reason propagation", () => {
77
+ test("parent abort reason cascades to child and grandchild", () => {
78
+ const root = createAbortTree();
79
+ const child = root.child();
80
+ const grandchild = child.child();
81
+ const reason = new Error("cascade reason");
82
+ root.abort(reason);
83
+ expect(child.signal.reason).toBe(reason);
84
+ expect(grandchild.signal.reason).toBe(reason);
85
+ });
86
+
87
+ test("already-aborted parent passes its reason to a freshly born child", () => {
88
+ const root = createAbortTree();
89
+ const reason = new Error("born-aborted reason");
90
+ root.abort(reason);
91
+ const child = root.child();
92
+ expect(child.signal.aborted).toBe(true);
93
+ expect(child.signal.reason).toBe(reason);
94
+ });
95
+
96
+ test("child's own abort reason does not leak to the parent", () => {
97
+ const root = createAbortTree();
98
+ const child = root.child();
99
+ child.abort(new Error("child-only reason"));
100
+ expect(root.signal.aborted).toBe(false);
101
+ expect(root.signal.reason).toBeUndefined();
102
+ });
103
+
104
+ test("abort with no reason yields the default DOMException reason on cascade", () => {
105
+ const root = createAbortTree();
106
+ const child = root.child();
107
+ root.abort();
108
+ expect(child.signal.aborted).toBe(true);
109
+ // AbortController with no explicit reason produces an AbortError DOMException.
110
+ expect(child.signal.reason).toBeInstanceOf(DOMException);
111
+ expect((child.signal.reason as DOMException).name).toBe("AbortError");
112
+ });
113
+ });
114
+
115
+ describe("idempotency & ordering", () => {
116
+ test("aborting the root twice is a no-op the second time (reason is sticky)", () => {
117
+ const root = createAbortTree();
118
+ const first = new Error("first");
119
+ root.abort(first);
120
+ root.abort(new Error("second"));
121
+ expect(root.signal.reason).toBe(first);
122
+ });
123
+
124
+ test("aborting a parent after the child already self-aborted leaves the child's reason intact", () => {
125
+ const root = createAbortTree();
126
+ const child = root.child();
127
+ const childReason = new Error("child self");
128
+ child.abort(childReason);
129
+ // Parent aborts afterwards; child must keep its own reason, not adopt the parent's.
130
+ root.abort(new Error("parent later"));
131
+ expect(child.signal.reason).toBe(childReason);
132
+ expect(root.signal.aborted).toBe(true);
133
+ });
134
+
135
+ test("deep chain (5 levels) all cascade from a single root abort", () => {
136
+ const root = createAbortTree();
137
+ const levels = [root];
138
+ for (let i = 0; i < 5; i++) {
139
+ const next = levels[levels.length - 1];
140
+ if (next === undefined) throw new Error("unreachable");
141
+ levels.push(next.child());
142
+ }
143
+ for (const node of levels) expect(node.signal.aborted).toBe(false);
144
+ root.abort();
145
+ for (const node of levels) expect(node.signal.aborted).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("listener hygiene on the parent signal (finalization-removal path)", () => {
150
+ test("each live child registers exactly one abort listener on the parent", () => {
151
+ const root = createAbortTree();
152
+ expect(getEventListeners(root.signal, "abort").length).toBe(0);
153
+ const a = root.child();
154
+ expect(getEventListeners(root.signal, "abort").length).toBe(1);
155
+ const b = root.child();
156
+ expect(getEventListeners(root.signal, "abort").length).toBe(2);
157
+ // Keep references alive so neither can be GC'd before we assert.
158
+ expect(a.signal.aborted).toBe(false);
159
+ expect(b.signal.aborted).toBe(false);
160
+ });
161
+
162
+ test("a child's own abort removes its listener from the parent", () => {
163
+ const root = createAbortTree();
164
+ const a = root.child();
165
+ const b = root.child();
166
+ expect(getEventListeners(root.signal, "abort").length).toBe(2);
167
+
168
+ a.abort();
169
+ expect(getEventListeners(root.signal, "abort").length).toBe(1);
170
+
171
+ b.abort();
172
+ expect(getEventListeners(root.signal, "abort").length).toBe(0);
173
+ // Parent itself stays unaborted throughout.
174
+ expect(root.signal.aborted).toBe(false);
175
+ });
176
+
177
+ test("parent abort consumes the once-listener (parent left with none afterwards)", () => {
178
+ const root = createAbortTree();
179
+ const child = root.child();
180
+ expect(getEventListeners(root.signal, "abort").length).toBe(1);
181
+ root.abort();
182
+ expect(child.signal.aborted).toBe(true);
183
+ // The { once: true } parent listener is consumed on fire.
184
+ expect(getEventListeners(root.signal, "abort").length).toBe(0);
185
+ });
186
+ });
187
+
188
+ describe("external parent signal", () => {
189
+ test("createAbortTree wraps an external AbortController signal and cascades", () => {
190
+ const ext = new AbortController();
191
+ const tree = createAbortTree(ext.signal);
192
+ expect(tree.signal.aborted).toBe(false);
193
+ const grandchild = tree.child();
194
+ const reason = new Error("external abort");
195
+ ext.abort(reason);
196
+ expect(tree.signal.aborted).toBe(true);
197
+ expect(tree.signal.reason).toBe(reason);
198
+ expect(grandchild.signal.aborted).toBe(true);
199
+ expect(grandchild.signal.reason).toBe(reason);
200
+ });
201
+
202
+ test("already-aborted external signal yields a born-aborted tree", () => {
203
+ const ext = new AbortController();
204
+ const reason = new Error("pre-aborted external");
205
+ ext.abort(reason);
206
+ const tree = createAbortTree(ext.signal);
207
+ expect(tree.signal.aborted).toBe(true);
208
+ expect(tree.signal.reason).toBe(reason);
209
+ });
210
+ });
211
+
212
+ describe("memory-safety: collected child does not break parent abort", () => {
213
+ // Exercises the WeakRef deref()===undefined branch in attachParent's
214
+ // onParentAbort: an abandoned child must not pin the parent, and the parent
215
+ // aborting after the child is collected must be a safe no-op for that child.
216
+ test("aborting a parent whose child was GC'd does not throw", () => {
217
+ const root = createAbortTree();
218
+
219
+ // Create a child but retain only a WeakRef to its signal so the underlying
220
+ // AbortController is eligible for collection once this helper returns.
221
+ const probe = ((): WeakRef<AbortSignal> => {
222
+ const child = root.child();
223
+ return new WeakRef(child.signal);
224
+ })();
225
+
226
+ // Bun.gc(true) performs a synchronous, deterministic full GC.
227
+ let collected = false;
228
+ for (let i = 0; i < 20 && !collected; i++) {
229
+ Bun.gc(true);
230
+ collected = probe.deref() === undefined;
231
+ }
232
+
233
+ // The parent still holds one listener for the (now possibly collected) child.
234
+ // Aborting must never throw, regardless of whether collection happened.
235
+ expect(() => root.abort(new Error("after-collect"))).not.toThrow();
236
+ expect(root.signal.aborted).toBe(true);
237
+
238
+ // Bun reliably collects the unreferenced controller; assert we actually hit
239
+ // the deref()===undefined path. If a future runtime cannot collect here,
240
+ // this assertion documents the regression rather than silently passing.
241
+ expect(collected).toBe(true);
242
+ });
243
+ });
244
+
75
245
  // T3: Integration — abort the parent and observe a real Bun.spawn'd child
76
246
  // process exit (SIGTERM cascade via { signal }).
77
247
  describe("T3 — child-process SIGTERM on parent abort", () => {