Offload functions to worker threads with shared memory primitives for Node.js.
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: async worker cleanup design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+96
+96
docs/superpowers/specs/2026-04-12-async-worker-cleanup-design.md
··· 1 + # Async Worker Cleanup 2 + 3 + ## Goal 4 + 5 + Support graceful async shutdown of worker pools via `await using`, with an abort signal that tasks can opt into for cooperative cancellation. 6 + 7 + ## Runner Changes 8 + 9 + ### New properties 10 + 11 + - `run.signal: AbortSignal` — fires when the pool starts disposing (either sync or async path). Tasks can thread this signal to support cooperative cancellation. 12 + - `run[Symbol.asyncDispose](): Promise<void>` — graceful shutdown: aborts signal, waits for in-flight tasks to settle, then terminates workers. 13 + 14 + ### Updated type 15 + 16 + ```ts 17 + type Runner = { 18 + <T>(task: Task<T>): Promise<T>; 19 + <T extends Task<any>[]>(tasks: [...T]): Promise<TaskResults<T>>; 20 + <T>(task: StreamTask<T>, opts?: ChannelOptions): AsyncIterable<T>; 21 + readonly signal: AbortSignal; 22 + [Symbol.dispose](): void; 23 + [Symbol.asyncDispose](): Promise<void>; 24 + }; 25 + ``` 26 + 27 + ### `workers()` signature 28 + 29 + ```ts 30 + workers(size?: number, opts?: { shutdownTimeout?: number }): Runner 31 + ``` 32 + 33 + `shutdownTimeout` controls the maximum time `asyncDispose` waits for in-flight tasks before force-terminating. No default — omitting it means wait indefinitely. 34 + 35 + ## Dispose Behavior 36 + 37 + | | `Symbol.dispose` (sync) | `Symbol.asyncDispose` (async) | 38 + |------------------|----------------------------|---------------------------------------| 39 + | Abort signal | fires | fires | 40 + | In-flight tasks | abandoned | awaited via `Promise.allSettled` | 41 + | New tasks | rejected | rejected | 42 + | Workers | terminated immediately | terminated after tasks settle | 43 + | Timeout | n/a | force-terminates after `shutdownTimeout` ms | 44 + 45 + Both paths set `disposed = true` immediately so new task submissions are rejected. 46 + 47 + ## In-Flight Tracking 48 + 49 + The pool owns a `Set<Promise<unknown>>` of active dispatches. 50 + 51 + **Regular tasks:** `dispatch()` wraps the `execute()` promise — added to the set on dispatch, removed on settle (resolve or reject). 52 + 53 + **Streaming tasks:** The promise represents the full lifecycle of the stream, not just dispatch. It resolves when the consumer finishes iterating (natural completion, `return()`, or error). `dispatchStream()` needs to expose a "done" promise for this. 54 + 55 + **Batch tasks:** `run([task1, task2])` — each individual task in the batch is tracked separately. The batch `Promise.all` is not separately tracked. 56 + 57 + ## Async Dispose Flow 58 + 59 + 1. Set `disposed = true` (reject new tasks from here) 60 + 2. Fire abort signal (`ac.abort()`) 61 + 3. `await Promise.allSettled(inflightSet)` 62 + 4. If `shutdownTimeout` is set, race the allSettled with a timeout. If timeout wins, proceed to terminate. 63 + 5. Terminate all workers 64 + 6. Clear pool 65 + 66 + ## Files Changed 67 + 68 + - `src/runner.ts` — update Runner type with `signal`, `Symbol.asyncDispose` 69 + - `src/worker-pool.ts` — AbortController ownership, in-flight set, async dispose logic, shutdown timeout 70 + - `src/execute.ts` — `dispatchStream` returns a "done" promise alongside the AsyncIterable for stream lifecycle tracking 71 + - Tests for: async dispose waits for tasks, signal fires on dispose, sync dispose still works, timeout force-terminates, rejected tasks after dispose, streaming task lifecycle tracking 72 + 73 + ## Usage 74 + 75 + ```ts 76 + // Sync dispose — immediate termination (existing behavior) 77 + { 78 + using run = workers(4); 79 + await run(someTask()); 80 + } 81 + 82 + // Async dispose — graceful shutdown 83 + { 84 + await using run = workers(4); 85 + run(longTask(run.signal)); // task can react to abort 86 + run(otherTask()); // runs to completion 87 + } 88 + // signal aborted, waited for both tasks, then terminated 89 + 90 + // With timeout 91 + { 92 + await using run = workers(4, { shutdownTimeout: 5000 }); 93 + run(longTask(run.signal)); 94 + } 95 + // if tasks don't finish within 5s, force-terminate 96 + ```