/ utils / abortController.ts
abortController.ts
 1  import { setMaxListeners } from 'events'
 2  
 3  /**
 4   * Default max listeners for standard operations
 5   */
 6  const DEFAULT_MAX_LISTENERS = 50
 7  
 8  /**
 9   * Creates an AbortController with proper event listener limits set.
10   * This prevents MaxListenersExceededWarning when multiple listeners
11   * are attached to the abort signal.
12   *
13   * @param maxListeners - Maximum number of listeners (default: 50)
14   * @returns AbortController with configured listener limit
15   */
16  export function createAbortController(
17    maxListeners: number = DEFAULT_MAX_LISTENERS,
18  ): AbortController {
19    const controller = new AbortController()
20    setMaxListeners(maxListeners, controller.signal)
21    return controller
22  }
23  
24  /**
25   * Propagates abort from a parent to a weakly-referenced child controller.
26   * Both parent and child are weakly held — neither direction creates a
27   * strong reference that could prevent GC.
28   * Module-scope function avoids per-call closure allocation.
29   */
30  function propagateAbort(
31    this: WeakRef<AbortController>,
32    weakChild: WeakRef<AbortController>,
33  ): void {
34    const parent = this.deref()
35    weakChild.deref()?.abort(parent?.signal.reason)
36  }
37  
38  /**
39   * Removes an abort handler from a weakly-referenced parent signal.
40   * Both parent and handler are weakly held — if either has been GC'd
41   * or the parent already aborted ({once: true}), this is a no-op.
42   * Module-scope function avoids per-call closure allocation.
43   */
44  function removeAbortHandler(
45    this: WeakRef<AbortController>,
46    weakHandler: WeakRef<(...args: unknown[]) => void>,
47  ): void {
48    const parent = this.deref()
49    const handler = weakHandler.deref()
50    if (parent && handler) {
51      parent.signal.removeEventListener('abort', handler)
52    }
53  }
54  
55  /**
56   * Creates a child AbortController that aborts when its parent aborts.
57   * Aborting the child does NOT affect the parent.
58   *
59   * Memory-safe: Uses WeakRef so the parent doesn't retain abandoned children.
60   * If the child is dropped without being aborted, it can still be GC'd.
61   * When the child IS aborted, the parent listener is removed to prevent
62   * accumulation of dead handlers.
63   *
64   * @param parent - The parent AbortController
65   * @param maxListeners - Maximum number of listeners (default: 50)
66   * @returns Child AbortController
67   */
68  export function createChildAbortController(
69    parent: AbortController,
70    maxListeners?: number,
71  ): AbortController {
72    const child = createAbortController(maxListeners)
73  
74    // Fast path: parent already aborted, no listener setup needed
75    if (parent.signal.aborted) {
76      child.abort(parent.signal.reason)
77      return child
78    }
79  
80    // WeakRef prevents the parent from keeping an abandoned child alive.
81    // If all strong references to child are dropped without aborting it,
82    // the child can still be GC'd — the parent only holds a dead WeakRef.
83    const weakChild = new WeakRef(child)
84    const weakParent = new WeakRef(parent)
85    const handler = propagateAbort.bind(weakParent, weakChild)
86  
87    parent.signal.addEventListener('abort', handler, { once: true })
88  
89    // Auto-cleanup: remove parent listener when child is aborted (from any source).
90    // Both parent and handler are weakly held — if either has been GC'd or the
91    // parent already aborted ({once: true}), the cleanup is a harmless no-op.
92    child.signal.addEventListener(
93      'abort',
94      removeAbortHandler.bind(weakParent, new WeakRef(handler)),
95      { once: true },
96    )
97  
98    return child
99  }