Reload and shut down
A running app may need to read its config again, be replaced by a fresh one, or shut down without cutting calls in half. What it can do without a restart depends on one distinction:
A value that is CONSULTED can change under a running app. A value that was CONSUMED to build something cannot, without rebuilding what it built.
logLevel is consulted at every emission. sources: opened connections, ports: registered a
class, the entities built facades — those were consumed.
Re-reading the config
applyConfig is the one place that answers what a re-read changes in a running app. It
applies what is consulted and reports the rest instead of ignoring it:
// the host owns the process, so the host owns the signal — Fougere catches none
process.on('SIGHUP', async () => {
const next = await loadConfig(root, { fresh: true });
const { applied, pending } = applyConfig(next, inForce);
inForce = next;
// applied: ['logLevel: warn → debug']
// pending: ['db'] — changed in the file, and it opened a connection
});
{ fresh: true } is not decoration: a module is cached by its specifier, so a second read
of an edited file hands back the first one.
The consulted keys are the ones applyConfig acts on, listed beside the code that does —
logLevel and maxBodyBytes. A logger built before the change obeys it, children included: the level is
held once for the process rather than copied into each instance.
Reloading the app
For everything else, the value cannot move under what it built — so the thing is built again. One app is instantiated, the previous one let go:
await reloadFougere(); // instantiate, drain the old, release it
It works because every door reaches the app through useFougereApp() inside the request it
serves, and none keeps it across two. The new app is up before the old one is
released, so a boot that fails leaves the previous one serving.
Draining
Releasing an app closes its storage connection and every frond scope — which is what the calls still running are standing on. So they are waited for:
await app.drain(); // stop taking calls, wait for the running ones
await app.dispose(); // then close the scopes and the connection
app.inFlight() is the count, taken at the single path all three facades and the wire share,
so one number covers them. A nested call counts twice — the app is not idle while an inner
op is running.
Two refusals, both deliberate:
drain(timeoutMs)rejects on its deadline, naming how many calls are left, rather than resolving as if it had worked. Its caller is about to close a connection under whatever remains, so the decision is theirs.- A call arriving after the facade closed is refused with
SERVICE_UNAVAILABLE. The handle already points at the new app, so that caller kept a reference across the turn.
Waiting and releasing stay two gestures: a script releases at once, a host reloading the app drains first.
Shutting down
The same two gestures answer SIGTERM, and nothing else is needed:
process.on('SIGTERM', async () => {
const app = await useFougereApp();
await app.drain(10_000);
await app.dispose();
process.exit(0);
});
dispose() releases in reverse of construction, at three levels: each extension's down
(built last), then the container — each scope disposing the scopes it opened, deepest first,
then what it built itself — then the storage connection that was handed in and opened before
any of it existed.
A class the container builds that holds something closes it in [Symbol.asyncDispose]() — the
method await using calls — or [Symbol.dispose](): there is then one of it per frond, and that frond's scope closes it.
The method is what is read, at runtime, so no implements is needed and a port's realization
counts like any provider. A class without it is built per consumer and closed by nobody.
Failures travel together in an AggregateError rather than the first one silencing the
rest: everything gets told to close.
What is not here
Fougere catches no signal of its own. A process belongs to its host, and the logger it
ships runs on Cloudflare Workers where process.on does not exist.