commits
Two cleanups on worker-entry.ts:
1. Arg resolution now lives as a small loop at each call site (value
task, stream task, and recursive task-arg) instead of behind a
resolveArgs helper that returned unknown[] | Promise<unknown[]>.
A `needsAsyncResolve(arg)` helper DRYs up the
`MessagePort | task-arg` check. The hot-path (value task) keeps the
sync-prologue + async-tail structure inline so fully-sync args
don't enter async mode at all.
2. Streaming emit loop uses adaptive setImmediate instead of yielding
after every emit. Arms a setImmediate whose callback flips a
`ticked` flag; if the generator's own awaits naturally tick the
loop between emits the flag flips and we skip the forced yield,
otherwise a streak of YIELD_EVERY (=16) emits without a tick
triggers a forced yield so pause/close can reach us.
Net effect: pure-CPU generators jump from ~40K items/s to ~85K (the
fixed-yield-every-16 ceiling); I/O-backed generators should approach
the no-yield ceiling (~489K) because their awaits tick naturally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous optimization cached the fn + added a sync fast-path for
resolveArgs, but still awaited them unconditionally — which costs a
microtask hop even when the callee returns synchronously.
This change threads "T | Promise<T>" through the hot path and uses
`instanceof Promise` checks to only await when there's actually
something to wait for:
- resolveFn returns a Fn directly when cached
- resolveArgs builds the result array imperatively, switching to an
async helper only at the first arg that needs async resolution
- invokeAndRespond checks whether the moroutine's return value is a
Promise before awaiting it
- the 'message' listener is no longer async; value-task handling runs
fully synchronously when nothing in the chain returns a Promise
Streaming tasks use their own path (handleStreamTask) which remains
async — they're inherently long-lived and the async overhead doesn't
dominate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small savings on the worker's per-task hot path:
1. Cache the resolved moroutine function by id. Previously every
task did id.slice() + imported.has() + registry.get() every call;
now it's a single fnCache.get() after the first call per
moroutine. Applies to both handleTask and task-arg resolution.
2. When a task's args contain no MessagePort and no task-args,
skip Promise.all and synchronously map through deserializeArg.
Avoids allocating N promises + awaiting Promise.all for tasks
with primitive args (the common case).
Eliminates ~3 map/string ops and N promise allocations per call
after warmup. Small but measurable gain in dispatch throughput.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Measures pure round-trip dispatch overhead with a noop moroutine —
the task does essentially no work, so timings reflect the cost of
postMessage + event loop re-entry per task.
Shapes measured:
- Ping-pong latency (strict await-each, 1 worker)
- Pipelined throughput (1 worker, variable in-flight window)
- Parallel throughput (N workers, Promise.all)
- Batch arg vs per-task vs streaming (1 worker, 100K items)
The batch-vs-stream comparison is particularly informative: batching
100K items into a single task arg is ~34x faster than per-task
dispatch, while the current streaming implementation is ~8x slower
than per-task because of the per-item setImmediate yield used to
keep pause/resume backpressure responsive.
Used together with docs/atomics-bench/ (local-only research notes)
to evaluate Piscina's atomics technique — conclusion on the
atomics-dispatch branch; this bench stays useful regardless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Published 1.0.0 threw "Cannot find module .../dist/worker-entry.ts"
because the worker entry URL was hardcoded as a string literal. tsc's
extension rewriting only handles import specifiers, so .ts leaked into
dist/*.js. Now derived from import.meta.url — src picks .ts, dist picks .js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a paragraph under Load Balancing showing how isTask() narrows a
task to a specific moroutine's descriptor shape, with a keyAffinity
balancer example. Link to examples/worker-affinity for the full demo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A custom Balancer routes tasks to workers by hashing a shard key
extracted from task args via isTask() narrowing. Demonstrates how
per-worker state (a Map in this demo) stays consistent when calls
for the same key are pinned to the same worker.
Output contrasts round-robin (counts split across workers, wrong
totals) with keyAffinity (counts consistent, correct totals).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Moroutines returned by mo() expose a readonly .id
- isTask(mo, task) narrows task to Task<T, A> inferred from the
moroutine's return type; useful when a pool handles tasks from
multiple moroutines and you want to recover the specific shape
- Task<T, A>.args is now typed as A (previously unknown[]), so
narrowing propagates through task.args — backward compatible
for Task<T> where A defaults to unknown[]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- inert(task) returns a plain task descriptor without PromiseLike or
AsyncIterable protocols, safe to yield from an (async) generator
without triggering auto-await
- map(run, items, { concurrency, signal }) dispatches an iterable or
async iterable of tasks to a Runner with bounded concurrency, yielding
results in completion order
- Accepts mixed task types: Task<string> | Task<number> yields string | number
- Supports AbortSignal for stream cancellation; moroutine auto-transfers
signals passed as task args so in-flight work can observe the same abort
Includes test coverage, a bounded-map example that hashes a directory
tree via recursive async generator, and a README section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task<T, A> now carries result and arg types via brand symbols, without
PromiseLike or AsyncIterable protocols. Live tasks returned by mo()
intersect with the appropriate protocol for awaiting/iteration.
Enables accurate result-type inference for helpers like map() that
consume inert Task values — the bare descriptor can be yielded from
a generator or held in arrays without triggering auto-await.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap errors in an async catch block inside trackValue (pool) and
runOnDedicated (dedicated), re-throwing with the worker error as
cause. V8's async stack traces then extend the new error with the
caller's await chain, so users see where run(), exec(), or await
task was called.
Adds one microtask hop per dispatch. The error subclass identity
and cause chain are preserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Errors thrown in moroutines now transfer via structured clone
instead of extracting message strings. This preserves stack
traces pointing to the actual source, built-in subclass identity
(TypeError, RangeError, etc.), and cause chains. Applies to
regular dispatch, pool dispatch, and streaming tasks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Task<T> is now a single conditional type: PromiseLike<T> for value
tasks, AsyncIterable<T> for streaming tasks, or the base dispatch
shape when unparameterized. Classes renamed to PromiseLikeTask<T>
and AsyncIterableTask<T>.
BREAKING CHANGE: Task and StreamTask classes renamed; public type
signatures now use Task<T> throughout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pool workers are now ref'd for the lifetime of the pool, preventing the
Node.js event loop from exiting prematurely when using top-level await.
Dedicated workers ref/unref with active counting around each dispatch.
Moves ref/unref responsibility out of the shared dispatch layer
(execute/setupWorker) and into the callers — pool workers stay ref'd,
dedicated workers track an active count and only unref when idle.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move mo() calls from test files to fixtures (stream-context, stream-pipeline)
- Fix unhandled rejection from track().finally() in worker-pool
- Fix error test to use await using for clean disposal
- Drop --experimental-strip-types (Node 24 native) and --test-force-exit
- Quote test glob so Node expands it (shell ** misses top-level files)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shell glob ** doesn't match files in the immediate directory in sh,
only subdirectories. Quoting lets Node's test runner handle the glob
which correctly matches test/*.test.ts and test/**/*.test.ts.
Node 24 strips types natively so the flag is unnecessary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two cleanups on worker-entry.ts:
1. Arg resolution now lives as a small loop at each call site (value
task, stream task, and recursive task-arg) instead of behind a
resolveArgs helper that returned unknown[] | Promise<unknown[]>.
A `needsAsyncResolve(arg)` helper DRYs up the
`MessagePort | task-arg` check. The hot-path (value task) keeps the
sync-prologue + async-tail structure inline so fully-sync args
don't enter async mode at all.
2. Streaming emit loop uses adaptive setImmediate instead of yielding
after every emit. Arms a setImmediate whose callback flips a
`ticked` flag; if the generator's own awaits naturally tick the
loop between emits the flag flips and we skip the forced yield,
otherwise a streak of YIELD_EVERY (=16) emits without a tick
triggers a forced yield so pause/close can reach us.
Net effect: pure-CPU generators jump from ~40K items/s to ~85K (the
fixed-yield-every-16 ceiling); I/O-backed generators should approach
the no-yield ceiling (~489K) because their awaits tick naturally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous optimization cached the fn + added a sync fast-path for
resolveArgs, but still awaited them unconditionally — which costs a
microtask hop even when the callee returns synchronously.
This change threads "T | Promise<T>" through the hot path and uses
`instanceof Promise` checks to only await when there's actually
something to wait for:
- resolveFn returns a Fn directly when cached
- resolveArgs builds the result array imperatively, switching to an
async helper only at the first arg that needs async resolution
- invokeAndRespond checks whether the moroutine's return value is a
Promise before awaiting it
- the 'message' listener is no longer async; value-task handling runs
fully synchronously when nothing in the chain returns a Promise
Streaming tasks use their own path (handleStreamTask) which remains
async — they're inherently long-lived and the async overhead doesn't
dominate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small savings on the worker's per-task hot path:
1. Cache the resolved moroutine function by id. Previously every
task did id.slice() + imported.has() + registry.get() every call;
now it's a single fnCache.get() after the first call per
moroutine. Applies to both handleTask and task-arg resolution.
2. When a task's args contain no MessagePort and no task-args,
skip Promise.all and synchronously map through deserializeArg.
Avoids allocating N promises + awaiting Promise.all for tasks
with primitive args (the common case).
Eliminates ~3 map/string ops and N promise allocations per call
after warmup. Small but measurable gain in dispatch throughput.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Measures pure round-trip dispatch overhead with a noop moroutine —
the task does essentially no work, so timings reflect the cost of
postMessage + event loop re-entry per task.
Shapes measured:
- Ping-pong latency (strict await-each, 1 worker)
- Pipelined throughput (1 worker, variable in-flight window)
- Parallel throughput (N workers, Promise.all)
- Batch arg vs per-task vs streaming (1 worker, 100K items)
The batch-vs-stream comparison is particularly informative: batching
100K items into a single task arg is ~34x faster than per-task
dispatch, while the current streaming implementation is ~8x slower
than per-task because of the per-item setImmediate yield used to
keep pause/resume backpressure responsive.
Used together with docs/atomics-bench/ (local-only research notes)
to evaluate Piscina's atomics technique — conclusion on the
atomics-dispatch branch; this bench stays useful regardless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Published 1.0.0 threw "Cannot find module .../dist/worker-entry.ts"
because the worker entry URL was hardcoded as a string literal. tsc's
extension rewriting only handles import specifiers, so .ts leaked into
dist/*.js. Now derived from import.meta.url — src picks .ts, dist picks .js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A custom Balancer routes tasks to workers by hashing a shard key
extracted from task args via isTask() narrowing. Demonstrates how
per-worker state (a Map in this demo) stays consistent when calls
for the same key are pinned to the same worker.
Output contrasts round-robin (counts split across workers, wrong
totals) with keyAffinity (counts consistent, correct totals).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Moroutines returned by mo() expose a readonly .id
- isTask(mo, task) narrows task to Task<T, A> inferred from the
moroutine's return type; useful when a pool handles tasks from
multiple moroutines and you want to recover the specific shape
- Task<T, A>.args is now typed as A (previously unknown[]), so
narrowing propagates through task.args — backward compatible
for Task<T> where A defaults to unknown[]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- inert(task) returns a plain task descriptor without PromiseLike or
AsyncIterable protocols, safe to yield from an (async) generator
without triggering auto-await
- map(run, items, { concurrency, signal }) dispatches an iterable or
async iterable of tasks to a Runner with bounded concurrency, yielding
results in completion order
- Accepts mixed task types: Task<string> | Task<number> yields string | number
- Supports AbortSignal for stream cancellation; moroutine auto-transfers
signals passed as task args so in-flight work can observe the same abort
Includes test coverage, a bounded-map example that hashes a directory
tree via recursive async generator, and a README section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task<T, A> now carries result and arg types via brand symbols, without
PromiseLike or AsyncIterable protocols. Live tasks returned by mo()
intersect with the appropriate protocol for awaiting/iteration.
Enables accurate result-type inference for helpers like map() that
consume inert Task values — the bare descriptor can be yielded from
a generator or held in arrays without triggering auto-await.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap errors in an async catch block inside trackValue (pool) and
runOnDedicated (dedicated), re-throwing with the worker error as
cause. V8's async stack traces then extend the new error with the
caller's await chain, so users see where run(), exec(), or await
task was called.
Adds one microtask hop per dispatch. The error subclass identity
and cause chain are preserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Errors thrown in moroutines now transfer via structured clone
instead of extracting message strings. This preserves stack
traces pointing to the actual source, built-in subclass identity
(TypeError, RangeError, etc.), and cause chains. Applies to
regular dispatch, pool dispatch, and streaming tasks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Task<T> is now a single conditional type: PromiseLike<T> for value
tasks, AsyncIterable<T> for streaming tasks, or the base dispatch
shape when unparameterized. Classes renamed to PromiseLikeTask<T>
and AsyncIterableTask<T>.
BREAKING CHANGE: Task and StreamTask classes renamed; public type
signatures now use Task<T> throughout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pool workers are now ref'd for the lifetime of the pool, preventing the
Node.js event loop from exiting prematurely when using top-level await.
Dedicated workers ref/unref with active counting around each dispatch.
Moves ref/unref responsibility out of the shared dispatch layer
(execute/setupWorker) and into the callers — pool workers stay ref'd,
dedicated workers track an active count and only unref when idle.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move mo() calls from test files to fixtures (stream-context, stream-pipeline)
- Fix unhandled rejection from track().finally() in worker-pool
- Fix error test to use await using for clean disposal
- Drop --experimental-strip-types (Node 24 native) and --test-force-exit
- Quote test glob so Node expands it (shell ** misses top-level files)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shell glob ** doesn't match files in the immediate directory in sh,
only subdirectories. Quoting lets Node's test runner handle the glob
which correctly matches test/*.test.ts and test/**/*.test.ts.
Node 24 strips types natively so the flag is unnecessary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>