Skip to content

ExitReport and DrainReport ​

Reference. The value RunningApp.exited settles with when the application came up and went down again. For the handle, see RunningApp; for how these fields become an exit code, see runMain and exit codes; for the three beats behind drain, see Draining, in three beats.

ExitReport ​

ts
type ExitReport = {
  readonly reason: "signal" | "runtimeStopped" | "uncaught";
  readonly drain: DrainReport | undefined;
  readonly teardownErrors: readonly TeardownError[];
  readonly uptimeMs: number;
  readonly abandonedAt?: "build" | "stop";
  readonly unexpectedRuntimeStop?: true;
};

type TeardownError = { readonly port: string; readonly cause: unknown };
FieldSemantics
reasonWhat asked the process to stop. Only the first request counts.
drainThe drain's accounting, or undefined whenever the drain was skipped — including a build abandoned before it served, which never had one to run.
teardownErrorsEvery finaliser of the application scope that failed as it closed — port is the provider's port id, cause whatever it threw or answered. A per-unit finaliser (a bound unit module's) is never recorded here; it is emitted as a teardownError event only. Under runMain a non-empty array is exit code 2.
uptimeMsclock.now() at exit — once the scope's finalisers have run, or when the kernel stopped waiting for them — minus clock.now() at start, on whichever Clock the app was started with.
abandonedAtAbsent on an ordinary exit. "stop" when Serving.stop or a scope finaliser was still running at stopTimeoutMs, "build" when a graph was given up on before it ever served. It reads "stopped waiting", not "cancelled": nothing can cancel a finaliser, so a wedged one can still hold the event loop past this report. Under runMain a set field is exit code 2.

unexpectedRuntimeStop is present only when the runtime stopped before any shutdown request. runMain returns 1 for it unless a crash or unclean teardown requires a higher-priority code. A caller's RunningApp.stop() has the same runtimeStopped reason but leaves this flag absent and exits 0. A late runtime notification during an already-requested shutdown never sets the flag.

reason ​

ValueSet byDrains?
"signal"SIGTERM, SIGINT, or app.requestDrain()Yes — the only reason that does.
"runtimeStopped"app.stop()No. In-flight units are aborted at once and the phase goes straight to stopping.
"uncaught"an uncaughtException or unhandledRejection (with signals: true), including one raised while the scope's finalisers run after another reasonNo. Draining after a crash risks completing in-flight work against corrupted state, so units are aborted at once.

drain is therefore undefined for "runtimeStopped" and "uncaught", and never for "signal": a second signal during the drain skips whichever sleep is pending, but the report still carries a DrainReport, with the counters as sampled at that moment.

The array is aliased on purpose

teardownErrors is the same mutable array the kernel pushes into. di closes the scope after the kernel's use callback settles but before the scope's own result settles, so every finaliser failure lands in the array after the report object is built and before a caller can observe it. Read it once exited has settled, and do not copy it earlier.

DrainReport ​

ts
type DrainReport = {
  readonly inFlightAtStart: number;
  readonly completed: number;
  readonly abandoned: number;
};
FieldSemantics
inFlightAtStartUnits open when the drain began — sampled synchronously in the same turn readiness flipped false, before the pre-drain delay, so it agrees with the draining event emitted from that turn.
completedUnits that closed during the drain, counted from a monotonic total, not as inFlightAtStart - abandoned. It may exceed inFlightAtStart when in-flight work spawned more units during the drain — honest reporting, not a bug: the subtraction would go negative the moment a unit started after the sample and closed before the deadline.
abandonedUnits still open at the deadline. Each is aborted through its AbortSignal and reported here. The field the exit code keys on: > 0 is exit code 2 under runMain. It means "no longer AWAITED", not "did not finish" — see below.

The deadline race is Serving.drain(signal) then awaitIdle(), against clock.sleep(drainTimeoutMs). awaitIdle() is sequenced after drain resolves rather than sampled alongside it, so a unit that opens while the runtime is still winding down is waited for rather than reported abandoned with the budget unspent. Whichever branch wins, signal is aborted at once, so a runtime that treats it as its cue to return is always released.

abandoned means "no longer awaited", not "did not finish". The kernel aborts each unit's signal and stops waiting; it cannot cancel work, and the transport underneath keeps running on its own clock. An abandoned AMQP delivery is usually acked a moment later and an abandoned Temporal activity usually completes — after this report said they did not.

So the process may not end by itself. runMain sets an exit code and deliberately never calls process.exit(), which is what lets pending output flush and an embedding host keep its own lifetime; the process ends when the event loop empties, and a transport still winding down is holding it open. An AMQP connection closes only once the deliveries it already took have drained, and Temporal's native Runtime only once every worker and connection is deregistered.

Under a Kubernetes-initiated shutdown — the case a drain normally comes from — that ends at terminationGracePeriodSeconds, with SIGKILL, so exit 2 is what this report SAYS rather than what the orchestrator observes. Outside that lifecycle nothing kills it: a drain that hits its deadline in a test, a dev loop or an embedder leaves the process alive until the transport finishes on its own clock. The report reaches stderr first either way, which is what kubectl logs --previous is for — and it is the whole reason the stopping phase got a deadline of its own.

Reading one ​

ts
import type { ExitReport } from "@btravstack/core";

const clean = (report: ExitReport): boolean =>
  report.reason !== "uncaught" &&
  report.unexpectedRuntimeStop !== true &&
  report.abandonedAt === undefined &&
  (report.drain?.abandoned ?? 0) === 0 &&
  report.teardownErrors.length === 0;

That predicate is the 0 row of runMain's table; every other outcome earns a non-zero code, in the precedence runMain and exit codes states.

abandonedAt is a term for the same reason the other two are: the kernel stopped waiting, so the process stopped and not cleanly. It is the term a hand-rolled predicate is most likely to miss, because a teardown that outlived stopTimeoutMs abandoned no unit and had no finaliser fail yet — both other terms read false while the kernel had just given up.

Released under the MIT License.