Children die with their parent. Cleanup runs to completion. Cancellation is an AbortSignal.
e2 has fibers, scopes and schedules, and no ambient context: the cancellation signal is handed to the operation that needs it, which is why the same code runs unchanged in a browser. Everything on this page runs in the playground.
fork, join, joinExit, interruptFiber
A fiber is one running computation: its continuation stack, its services, its scope, its cancellation signal and its children. fork(effect) starts one and hands back the handle; join(fiber) waits and reproduces its outcome, defects and all; joinExit(fiber) waits and reports the outcome as a value; interruptFiber(fiber, reason?) requests interruption and waits for it to settle.
const program = gen(function* () {
const subscription = yield* fork(receive.forThread('thread-a', onMessage))
yield* sleep(60)
yield* network.simulateIncoming(batch)
yield* sleep(90)
yield* interruptFiber(subscription) // runs its finally blocks and finalizers
})
A fiber cannot complete while it still has children
fork ends when the program that started it ends, whether that is by return, failure or interruption.The exception is deliberate: the fibers that build services and run memo and batch have no parent. A shared construction must not be cancelled because the first caller lost interest. They belong to the runtime's scope and are interrupted when it closes.
Delivered once, by signal
- Interruption travels by
AbortSignal.fromPromiseandasync_receive the fiber's signal explicitly, sofetchcancels for free and there is noAsyncLocalStorageanywhere. - It is delivered once. Everything that runs afterwards, scope finalizers,
ensuringhandlers, a generator'sfinally, runs to completion rather than being re-interrupted. A hung finalizer therefore cannot itself be cancelled; that is the deliberate side of the trade against cleanup that silently never happens. - A generator's
finallyfires on interruption and on failure, because the interpreter callsiterator.return()while unwinding. catchAlldoes not see interruption. Only finalizers do, through theExitthey receive.
acquire, addFinalizer, scoped, ensuring
A scope is a stack of finalizers closed with the Exit that triggered it. Finalizers receive that Exit, so they can tell success from failure from interruption: commit versus rollback, or "the request was cancelled, do not bill for it". Closing is resilient: a finalizer that fails does not stop the rest.
Every runtime supplies a root scope, and every provider body runs in it, which is why Scope never appears in a provider's Needs. A request that wants its own scope wraps itself in scoped(effect), which discharges the requirement.
| Operation | Effect |
|---|---|
acquire(open, close, onError?) | opens a resource, possibly asynchronously, and registers close(resource, exit) on the ambient scope. Without onError a failure to open is a defect. |
addFinalizer((exit) => fx) | registers a finalizer on the ambient scope |
scoped(effect) | runs the effect with a fresh scope, closed when it settles; removes 'Scope' from its dependencies |
ensuring(finalizer)(effect) | runs a finalizer when the effect settles, whatever the outcome |
retry and repeat, driven by a Schedule
A Schedule answers one question: given the attempt number and the last error, how long until the next try, or stop. retry(schedule)(effect) retries on expected failure; repeat(schedule)(effect) re-runs on success. Both leave the effect's type unchanged.
| Constructor | Meaning |
|---|---|
Schedule.recurs(n) | n more times, immediately |
Schedule.spaced(ms) | forever, every ms |
Schedule.exponential(base, factor = 2) | base, base·factor, base·factor², … |
Schedule.stop | never |
| Modifier | Meaning |
|---|---|
.upTo(n) | at most n attempts |
.maxDelay(ms) | cap each delay |
.jittered(factor = 0.5) | randomise each delay by up to factor |
.whileError(pred) | only while the error satisfies pred |
const resilient = retry(
Schedule.exponential(50).jittered().maxDelay(2_000).upTo(5).whileError((e: NetworkError) => e.reason === 'timeout')
)(server.fetchOne(threadId, id))
race, timeout, allConcurrent, forEach
| Operation | Effect |
|---|---|
race(left, right) | the first to settle wins; the other is interrupted |
timeout(ms, onTimeout)(effect) | fails with onTimeout() if the effect has not settled in time |
allConcurrent([a, b, c]) | runs all, returns a tuple of values; the first failure interrupts the rest |
forEach(f, { concurrency })(items) | maps items to effects; concurrency is a number or 'unbounded', default 1 |
sleep(ms) | completes after ms, cancelling its timer if interrupted |