e2 · concurrency and resources

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.

Fibers

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
})
Structured concurrency

A fiber cannot complete while it still has children

parent fiber child: subscription child: download fork fork when the parent completes 1. its children are interrupted 2. it waits for every child to settle 3. its scope's finalizers run, with the Exit 4. only then does it settle the parent's abort signal is forwarded to each child, deterministically
Forked work can never outlive what forked it. A subscription started with 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.

Interruption

Delivered once, by signal

  • Interruption travels by AbortSignal. fromPromise and async_ receive the fiber's signal explicitly, so fetch cancels for free and there is no AsyncLocalStorage anywhere.
  • It is delivered once. Everything that runs afterwards, scope finalizers, ensuring handlers, a generator's finally, 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 finally fires on interruption and on failure, because the interpreter calls iterator.return() while unwinding.
  • catchAll does not see interruption. Only finalizers do, through the Exit they receive.
Scopes and resources

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.

OperationEffect
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
acquired, during construction and later on demand config connection store (lazy) released at rt.close(), most recent first store connection config a resource acquired inside a memo or batch run registers on the scope the cell was created in — the runtime's — not on the request that triggered it
Whichever order things were actually acquired in, including lazily, teardown reverses it.
Schedules

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.

ConstructorMeaning
Schedule.recurs(n)n more times, immediately
Schedule.spaced(ms)forever, every ms
Schedule.exponential(base, factor = 2)base, base·factor, base·factor², …
Schedule.stopnever
ModifierMeaning
.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))
Racing, timing out, iterating

race, timeout, allConcurrent, forEach

OperationEffect
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