Skip to content

Manage a resource's lifetime

How-to. Own a service that must be torn down — a pool, a file handle, a subscription — and have it released exactly once, on every path out. For why Scope is a phantom port and what the scope guarantees, see Scopes and resource safety.

Goal: a resource acquired once, released exactly once, and a graph that cannot compile if nothing would release it.

Declare the resourceful provider

acquire and release come as a pair — there is no release with nothing to release, nor an acquire never torn down:

ts
const Persistence = Module("Persistence")({
  imports: [Config],
  provides: [
    Provider(Database)([AppConfig], {
      acquire: (config) => openPool(config.dbUrl), // Result | AsyncResult — may fail
      release: (pool) => pool.close(), // void | Promise<void>
    }),
  ],
  exports: [Database],
});

acquire is make's fallible twin: it returns a Result or an AsyncResult, and a failed acquisition surfaces through the module's error channel like any other construction failure.

Choosing this arm puts the phantom Scope into the provider's Needs. That is the whole mechanism: Needs propagates through every module that imports this one, and only an entry point that actually opens a scope can discharge it.

Build through Module.scoped

ts
const result = await Module.scoped(App, (ctx) => runServer(ctx));

Module.scoped opens a scope, builds the graph, runs your callback and closes the scope before its own result settles. The close runs on every path:

  • your callback succeeded — released after it settles;
  • your callback failed — released, and the failure passed through untouched;
  • construction itself failed halfway — everything acquired before the failure is released, in reverse order.

Module.build — no scope, no teardown — refuses the graph at compile time: the call fails on arity with UNSATISFIED DEPENDENCIES and Scope named as the missing piece.

Under start, the process is the scope

An application booted by the kernel never calls Module.scoped itself. start accepts a Module<X, E, Scope | Env> — the resourceful module is welcome as it is — wraps it and hands it to Module.scoped, so the application scope spans the whole process: opened during building, closed on every exit path (stop(), a signal, an uncaught exception, a runtime that stopped on its own). What a finaliser reports on the way down lands in ExitReport.teardownErrors and a teardownError event, and runMain exits 2 over a non-empty list rather than 0. A resource that must live per unit rather than per process goes in the unit module instead — the kernel forks a scope around every unit for you (see Open a per-request scope).

Release order and failing finalisers

Finalisers run LIFO — reverse acquisition order — so a resource is always released before whatever it was built from: the transaction before the connection, the connection before the pool. Teardown is sequential for the same reason.

A finaliser that fails is reported and swallowed, never rethrown: shutdown is not abandoned halfway, and a failed close never masks the failure that triggered the unwind. Route the report with ScopedOptions:

ts
const options: ScopedOptions = {
  onTeardownError: (portId, cause) =>
    logger.error({ portId, cause }, "teardown failed"),
};
await Module.scoped(App, use, options);

The default reporter writes to console.error, tagged with the port id.

onStart and onStop

Every arm — not only acquire/release — accepts optional lifecycle hooks in the same options literal:

ts
Provider(Cache)([AppConfig], {
  make: (config) => connectCache(config),
  onStart: (cache) => cache.warm(), // after the WHOLE graph is built
  onStop: (cache) => cache.flush(), // during teardown, LIFO with releases
});
  • onStart fires only once the entire graph has finished constructing — never while another provider is mid-construction — in declaration order. A hook that throws or rejects is a defect, and every hook after it is skipped; the finalisers already registered still run.
  • onStop is teardown, so declaring one puts Scope in Needs exactly as release does: only a scope can run it, and the compiler routes the module to Module.scoped accordingly.

Use release for undoing an acquisition; use onStop for shutdown work on a service you did not acquire — flushing a cache built with make, stopping a consumer built with class.

See also

Released under the MIT License.