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)({
      inject: { config: 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, and the message ends on the missing piece: required in type '{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": Scope; }'.

Under start, the process is the scope

An application booted by the kernel never calls Module.scoped itself. start accepts a Module<X, E, N> and discharges Scope itself — 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 a module a starter's own unit option binds instead — the runtime forks that scope around every unit it opens, through UnitHost.fork (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)({
  inject: { config: 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.