Skip to content

runMain and exit codes

Reference. The front door of @btravstack/core and the table it turns an outcome into. For the handle underneath, see start and RunningApp; for what a report carries, see ExitReport and DrainReport; for the reasoning, see Nothing throws.

Signature

ts
const runMain: <X, E, UnitX = never, UnitNeeds = never>(
  module: Module<X, E, Scope | Env>,
  options?: StartOptions<UnitX, UnitNeeds>,
  exit?: (code: number) => void,
  ...gate: StartGate<X, UnitNeeds>
) => Promise<void>;

runMain is start composed with the wait for exited, then a fold of the Result into a code. It carries the same phantom gate as start (see The gate), so NO RUNTIME, UNSATISFIED RUNTIME NEEDS and UNSATISFIED UNIT NEEDS fail at this call site too.

ParameterDefaultSemantics
moduleThe composition root; identical to start's.
options{}Passed to start unchanged.
exit(code) => { process.exitCode = code; }Receives the code. The default sets process.exitCode and never calls process.exit(), so pending output flushes, an embedding host keeps control of its own lifetime, and a test observes the code without ending the run. Injectable for exactly that test.

It returns a bare Promise<void> — the one async surface in the package that is not an AsyncResult, deliberately: its whole job is to leave the Result world and become a process exit code. await runMain(Module) at the top of a main.ts is the intended shape:

ts
import { runMain } from "@btravstack/core";

import { OrderApi } from "./order-api.js";
import { RequestModule } from "./request-module.js";

await runMain(OrderApi, { unit: RequestModule });

That is the whole of examples/order-api/src/main.ts.

The exit-code table

Outcome of exitedCodesysexits(3)
Ok — exited cleanly: no crash, nothing abandoned, no teardown error0
Err — a modeled startup failure (the module's own E, or RuntimeStartFailed)1
Err — a ConfigInvalid, or a RuntimeStartFailed whose cause is one (the kernel's PROBE_PORT)78EX_CONFIG
Okdrain.abandoned > 02
OkteardownErrors non-empty2
Okreason === "uncaught"70EX_SOFTWARE
Defect — an unmodeled failure anywhere on the path70EX_SOFTWARE

Precedence

Evaluated in this order on an Ok report:

  1. A crash outranks abandoned work. reason === "uncaught" is 70 no matter what drain or teardownErrors say. In practice the uncaught path skips the drain, so drain is undefined there — but the ordering is written out rather than left to depend on that.
  2. Unclean is 2. Abandoned work or a failed finaliser; both are "we stopped, but not cleanly", and a pool that could not flush is exactly the shutdown an orchestrator must not be told succeeded.
  3. Otherwise 0.

On an Err, 78 is chosen when the error is a ConfigInvalid (by instanceof) or a RuntimeStartFailed carrying one as its cause; every other modeled error is 1. 78 says the deployment is wrong, not the code — the one startup failure fixed without a rebuild.

Both 70s are EX_SOFTWARE, an internal software error, reached through the two channels a bug can take: a throw the process caught (uncaught) and a Defect the Result carried.

Why 70 exists at all

start installs uncaughtException and unhandledRejection handlers when signals is true, and installing either suppresses Node's own default exit code of 1. A process that uses start without runMain and sets no exit code of its own therefore exits 0 after a crash — reporting success to its orchestrator. runMain closes that hole; an embedder that will not use it must fold ExitReport.reason into a code itself, or pass signals: false and give up the signal-driven drain. See Embed without runMain.

Observing the code in a test

ts
import { runMain } from "@btravstack/core";

let code: number | undefined;
await runMain(
  OrderApi,
  { env: { PORT: "abc" }, probes: false, signals: false },
  (c) => {
    code = c;
  },
);
// code === 78

The third argument replaces the default exit, so process.exitCode is left alone.

Released under the MIT License.