Repository navigation
v0.3.0
Job is a single-result lifetime again, one-shot handoff has its own type, and a Job no longer pays for cancellation machinery it never uses. Every consumer of the removed publication API must migrate; everything else is source-compatible.
Breaking
Job<Result> has one type parameter and no publication API (#22, closes #21)
Removed: the Published type parameter on Job, Supervisor, GroupMember, and GroupResults; the publisher argument passed to Job bodies; JobPublisher; Job.value().
// 0.2.x
const job = new Job<void, Prepared>((publish) => { publish(prepared); return finish(); });
const prepared = await job.value();
// 0.3.0
const job = new HandoffJob<void, Prepared, Outcome>(async (handoff) => {
const outcome = await handoff.offer(prepared); // suspended until resume()
await finish(outcome);
});
const prepared = await job.receive();
job.resume(outcome);Bodies that never published need only drop the unused parameter. Every Job that never published used to allocate a LifecycleStateError on completion; plain Job start → complete drops from 3.5 µs to 0.98 µs.
Added
HandoffJob<Result, Offered, Resumed> — a Job whose body offers one value mid-execution and suspends until the consumer resumes it. The lifetime rule is unchanged: the Job settles only after body and descendants finish.
offer()andresume()each accept one call; a second throwsTypeError.- Cancellation (direct, cascaded, deadline) rejects a suspended
offer()with the original reason. An offer after cancellation still reachesreceive()but rejects for the body; a lateresume()is discarded. - A body that returns without offering fails; a Job that closes without offering rejects
receive()with its failure. receive()rejects self/ancestor observation synchronously, likeresult().Supervisor.run(job)preserves the submitted subtype.
Changed
External cancellation sources are followed lazily (#24, closes #23)
A Job seeded with an external signal no longer registers a listener in start(). It subscribes the first time its cancellation is observable — a job.signal, signal(), or context.signal read, or a child start — and never if nothing observes it. What you can rely on:
- A source that aborted before the body runs still cancels the Job without running it.
- A source that aborts during the body still yields a cancelled result.
- Only code already suspended needs the subscription to be woken. For
HandoffJob, an offer consults its sources before suspending; a source that aborts during the suspension releases the offer only if the body observedsignal()first — otherwise the consumer'sresume()ends it and the result still reports the cancellation.
Job fixed cost (#26, closes #25)
- The
AbortControlleris created bycancel()or the firstsignalread, not in the constructor. - A Job without children no longer awaits an empty drain.
- A single-binding
ContextFrameholds one key/value instead of aMap. - Internally, a Job's incoming cancellation (signal, followed sources, deadline) is one
Cancellationcomponent; every delivery still goes throughJob.cancel().
Per-Job cost, Node 24 / Bun, 0.2.1 → 0.3.0:
| scenario | Node | Bun |
|---|---|---|
| plain Job | 3.5 → 0.82 µs | — → 0.70 µs |
| Job with an external signal nobody observes | — → 0.83 µs | — → 0.77 µs |
| HandoffJob with an external signal and one provided value | 1.41 → 1.36 µs | 1.67 → 1.29 µs |
Migration
- Replace
Job<R, P>+publish()+value()withHandoffJob<R, P, Resumed>+offer()/receive()/resume(); drop the body's publisher parameter elsewhere. - If code relied on an external signal waking a suspended
offer()without ever readingsignal(), either readsignal()in the body before offering or have the consumerresume()with the cancellation outcome. - Nothing else changes:
execute,fork,timeout,Supervisor,TaskGroup, context, and error classification are as in 0.2.x.