Repository navigation
Releases: tiberjs/runner
Release list
v0.3.2
Changed
FlexJob for intermediate publications
Added FlexJob<Result, Update>, a Job that can publish intermediate values while it is running.
A FlexJob retains the ordinary Job lifecycle and result semantics while exposing a bounded channel for intermediate publications. Its body can call publish(value) multiple times, and consumers can receive those values with receive().
Unlike a handoff, publication is one-way: publish() delivers or stores the value and the body continues without waiting for the consumer to acknowledge it.
import { FlexJob } from "@tiberjs/runner";
type Update = { phase: "started" | "processed" };
const job = new FlexJob<string, Update>(async (publish) => {
publish({ phase: "started" });
await doWork();
publish({ phase: "processed" });
return "finished";
});
job.start();
const update = await job.receive(); // { done: false, value: Update }, or channel completion
const result = await job.result(); // { ok: true, value: "finished" }, or { ok: false, error }await job and job.result() retain the ordinary Job contract and wait for the body, descendants, and finalizers to settle.
HandoffJob and Handoff deprecated
HandoffJob and Handoff are now deprecated. Use FlexJob when a Job needs to expose intermediate values before completion.
Existing handoffs retain their one-shot, two-way rendezvous semantics. In particular, offer() suspends the body until the consumer calls resume(). FlexJob intentionally does not provide this acknowledgement mechanism: publish() does not suspend for consumption and there is no corresponding resume().
Code that used a handoff only to expose an intermediate value can generally migrate from offer()/receive() to publish()/receive().
Code that depends on the acknowledgement returned by resume() should not mechanically replace offer() with publish(). Keep that acknowledgement exchange explicit in the Job body instead.
v0.3.1
Internal only. No API, behavior, or documentation changes; safe to take from 0.3.0 without reading further.
Changed
ContextFrame keeps one representation for its bindings (#28, follows #25/#26)
0.3.0 stored a frame with a single binding in a dedicated key/value object instead of a Map. Measured on its own, that saved 20–40 ns per Job — under 3% of a Job and invisible end to end — and left two representations behind every frame that from, with, and withEntries had to keep in step. It is reverted; a frame is a Map again.
The consumer that created a one-binding frame per request (the HTTP server binding ContainerKey) now hosts its DI scope on the Runner attachment and creates no per-request frame at all (@tiberjs/di ScopeHost), so the one-binding case is no longer on any hot path.
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.
v0.2.1
This release separates early value publication from a Job’s full structured lifetime, adds explicit descendant-control APIs, and removes per-child abort listeners.
Early Job value publication
A Job can now publish one typed value before its body and descendants fully settle.
const release = Promise.withResolvers<void>();
const job = new Job<number, string>(async (publish) => {
publish("ready");
await release.promise;
return 42;
}).start();
console.log(await job.value()); // "ready"
release.resolve();
console.log(await job); // 42- Added
Job<T, Published = T>. - Added the one-shot
publish(value)body argument. - Added
job.value()for observing the published value. - Publication remains available after cancellation while the body is still running.
job.value()and the final Job result remain independent.- Published-value types propagate through Supervisor and
TaskGroupAPIs.
Descendant lifecycle control
- Added
job.cancelChildren(reason?).- Cancels direct children.
- Cascades cancellation through their descendants.
- Waits for the cancelled child lifetimes to settle.
- Does not cancel the owning Job or prevent later child admission.
- Added
job.reconcileFailure(error).- Reconciles a caught rejection with genuine descendant failures already recorded by the Job.
- Preserves independent failures together as an
AggregateError.
Cancellation performance and reliability
- Removed the parent
AbortSignallistener previously installed for every child Job. - Parent cancellation now uses the Job ownership tree directly.
- External cancellation listeners are deduplicated and released after the complete Job lifetime drains.
- Cancellation sources and deadline timers are no longer registered after cancellation has already been committed.
- Reentrant cancellation during listener registration no longer admits unnecessary subsequent bindings.
- Descendant cancellation now uses iterative traversal, avoiding call-stack exhaustion for deeply nested Job trees.
A smoke measurement with 10,000 live children confirmed:
- 0 child-installed listeners on the parent signal.
- 1 listener on an external cancellation source.
- Cancellation still propagates through the complete ownership tree.
Compatibility
No intentional breaking API changes. Existing Job<T> usage remains valid because the published-value type defaults to the Job result type.
Changes
- #17 (#17) Separate Job values from lifetime
- #18 (#18) Remove parent-child abort listeners
- #19 (#19) Harden cancellation binding lifecycle
- #20 (#20) Version 0.2.1
Full Changelog: v0.2.0...v0.2.1
v0.2.0
@tiberjs/runner:0.2.0 is a clean redesign of the runtime around one lifetime primitive: Job.
A Job is now both an execution and the owner of its actual descendants. Supervision, grouped execution, cancellation, deadlines, and immutable context are composed around that ownership model without hidden scopes or lifecycle managers.
Important
This is a breaking release. It intentionally removes the 0.1 compatibility surface rather than retaining aliases or adapters.
Highlights
Job-owned structured concurrency
Job<T> replaces the split between executions, tasks, task groups, and manually managed runtime state.
import { Job, execute, fork } from "@tiberjs/runner";
const job = new Job(() => 42).start();
console.log(await job); // 42
const result = await execute(async () => {
const first = fork(() => "first");
const second = fork(() => "second");
return [await first, await second];
});A Job:
- is cold and single-use
- captures ownership and context when started, not when constructed
- settles only after its body and all actual descendants finish
- owns cancellation for its complete subtree
- preserves the original success value, cancellation reason, or failure
- supports await using through
AsyncDisposable
await job and job.join() unwrap the result. job.result() observes success or failure as a value without throwing execution errors.
Explicit ownership
Jobs form a real ownership tree.
const child = new Job(handler).start({ parent });Inside an execution, fork() and new Job(...).start() create children of the current Job. Pass { parent: undefined } to explicitly start an independent root.
There are no synthetic owners between a Job and its descendants.
Explicit supervision
Supervisor manages an ordinary Job supplied by the application. It does not create a hidden root, startup phase, or shutdown scope.
import { addAbortListener } from "node:events";
import { Job, Supervisor, signal } from "@tiberjs/runner";
const application = new Job(async () => {
const stopped = Promise.withResolvers<void>();
using registration = addAbortListener(signal(), stopped.resolve);
await stopped.promise;
});
await using supervisor = new Supervisor(application, {
failure: "isolate",
});
supervisor.start({ parent: undefined });
const value = await supervisor.run(() => 42);Supervisor submissions become direct children of the managed Job.
failure: "isolate"keeps an independent child failure from cancelling the owner or its siblings.failure: "fail-fast"propagates child failures to the owner.flush()joins current children without closing the owner.close()delegates cancellation and completion to the managed Job.
Initialization order, readiness, logging, retries, and error reporting remain application responsibilities.
Declarative TaskGroup
TaskGroup is now an immutable declaration rather than a lifetime owner.
const group = new TaskGroup([
new TaskGroup([new Job(() => "A"), new Job(() => "B")]),
new Job(() => "C"),
]);
const result = await supervisor.run(group);
// [["A", "B"], "C"]Nested declarations preserve their result shape without creating intermediate Jobs.
Each group selects either:
failure: "fail-fast"— cancel sibling leaves when a genuine failure escapesfailure: "isolate"— contain that failure at the group boundary
The complete declaration is validated before any member starts.
Context is separate from ownership
Immutable context frames describe an execution environment; they do not create lifetime nodes.
import { contextKey, execute, provide, use, withContext } from "@tiberjs/runner";
const Tenant = contextKey<string>("tenant");
await execute({ values: [provide(Tenant, "outer")] }, async () => {
await withContext([provide(Tenant, "inner")], async () => {
console.log(use(Tenant)); // "inner"
});
console.log(use(Tenant)); // "outer"
});New context APIs include:
withContext()hasContext()requireContext()MissingContextError
Job seeds can supply context values, an external signal, an absolute deadline, and an opaque attachment.
Stronger cancellation and failure semantics
Cancellation and deadlines now belong to the Job that owns the affected subtree.
- parent cancellation reaches every actual descendant
- external abort signals remain linked until the Job drains
- deadlines cover the complete subtree, including asynchronous finalization
- cancellation does not hide an independent execution or cleanup failure
- original error identity and cause are preserved
- independent failures are represented with AggregateError
Catching an ordinary child Job failure does not erase its ownership effect. Use execute(), timeout(), Supervisor isolation, or an isolated TaskGroup when a failure must be contained.
Breaking changes
Node.js 24 is required
The minimum supported Node.js version is now 24.
Removed lifecycle and infrastructure APIs
Runner now contains only transport-independent execution ownership, supervision, grouping, cancellation, deadlines, and context.
The following responsibilities have been removed from the package:
- dependency injection and hierarchical resource scopes
- application startup and shutdown orchestration
- event delivery
- tracing
- implicit cleanup registration
Removed APIs include:
ApplicationLifecycleAppStarted,AppClosing,AppClosed- Scope and its DI/resource APIs
inject,scoped,currentScope,onStart,onDisposeEventBus,eventKeysetTracer,spandeferbeginscheduleDeadline
These concerns can now be composed independently at the application boundary instead of being coupled to every execution.
Use native try/finally, using, and await using for lexical resource ownership.
fork() returns Job
fork() now creates and returns a real child Job<T>. The previous Task<T> type and observation-transfer semantics have been removed.
TaskGroup no longer owns runtime state
The old execution-owned TaskGroup and forkGroup() APIs have been removed.
Create cold Jobs, place them in a declarativeTaskGroup, and submit the group through a running Supervisor.
RuntimeState changed
RuntimeState is now:
interface RuntimeState {
readonly job: Job<unknown>;
readonly context: ExecutionContext;
}Task, scope, and attachment fields no longer exist at the top level. Attachments are available through:
currentState().context.attachment;
currentAttachment<T>();Do not manually recreate the old begin() lifecycle. Use an explicit Job for executions whose lifetime must outlive the initial call chain.
Why this release
The 0.1 runtime combined execution ownership, context, dependency injection, resources, events, tracing, and application lifecycle in one package.
0.2 establishes a smaller boundary:
Jobowns execution lifetime.Supervisorapplies admission and failure policy.TaskGroupdeclares grouped work.Contextcarries immutable execution values.- Applications and optional packages own infrastructure concerns.
This removes duplicated lifecycle state and makes ownership, cancellation, failure propagation, and shutdown behavior directly observable from the Job tree.
Full changelog: v0.1.1...v0.2.0 (v0.1.1...v0.2.0)