Skip to content

Probes

Reference. The kernel's own liveness and readiness endpoints: routes, port, configuration and what each answers when. For the Kubernetes side, see Tune the drain for Kubernetes; for the option that configures it, see start and StartOptions.

Liveness and readiness are process-level concerns, not transport-level ones, so the kernel runs a node:http server of its own on a separate port. A Temporal worker pod with no HTTP runtime still gets probes, and an HTTP runtime never exposes /healthz on the public port.

Routes

Route200503Anything else
GET /livezbody ok — any phase before exitedunavailable
GET /readyzbody ready — phase serving, and not forced unreadyunavailable
GET /healthzevery declared health check passedthe report
other paths404, empty

/readyz answers from the same predicate RunningApp.ready() reads synchronously. Readiness is a one-way latch: forced false by a drain (before the runtime is told to stop accepting) or by an uncaught exception, it never returns to true.

Phase/livez/readyz
building200503
starting200503
serving200200, until forced unready
draining200503
stopping200503
exited503503

/healthz — the dependency report

Every module that declares a health check contributes one to the HealthChecks set port; the kernel reads all of them and folds them into one report. The body is JSON in both the 200 and the 503 case:

json
{
  "status": "unhealthy",
  "components": [
    { "name": "cache", "status": "healthy" },
    {
      "name": "database",
      "status": "unhealthy",
      "reason": "connection refused"
    }
  ]
}

One unhealthy component makes the whole application unhealthy, and the report still names every component — a report naming only the first failure is worth less than one naming all of them. An application that composed no starter declaring a check gets {"status":"healthy","components":[]}: a set port with no contributors is empty, not missing.

/healthz is deliberately NOT folded into /readyz. Readiness removes a pod from its Service's endpoints, so failing it on a dependency several replicas share takes every replica out at once and turns a degraded system into an outage. /readyz answers for the lifecycle; /healthz reports on dependencies, and what to do about a 503 there is an operator's decision — an alert, a dashboard, a dependency-aware rollout gate — not an automatic removal from load balancing.

Each check runs on every request; the kernel caches nothing. A check that is expensive to run is the adapter's problem to make cheap, since only the adapter knows what "cheap" means for it.

A buggy check is contained, not amplified: one that throws instead of answering — or whose AsyncResult defects — becomes an unhealthy component line naming the cause, exactly like a check that failed properly. The request always gets its response, and the throw never reaches the process's uncaught handler.

There is deliberately no startup probe: /livez answers 200 from building onward, so a slow-building graph is covered by /readyz alone.

Configuration

StartOptions.probesBehaviour
unsetThe port is bound from PROBE_PORT in StartOptions.env (default process.env), through Config.port("PROBE_PORT", { default: 9000 }), alongside PRE_DRAIN_DELAY_MS and DRAIN_TIMEOUT_MS. These are the configuration the kernel binds itself, because the probe server is up — and the drain is scheduled — before the graph, and its Env, exists.
{ port: number }Bind that port. { port: 0 } lets the OS choose; read it back from RunningApp.probePort().
falseNo probe server. probePort() resolves undefined. ready() still works — it is what an embedder wires into a health endpoint of its own.

@btravstack/testing's bootFixture defaults to probes: false, and its bootFixture defaults to it; a test that needs the real server passes boot(module, { probes: { port: 0 } }) or calls start directly.

Binding

  • 127.0.0.1 only. The probe server is for the kubelet on the same node, not the network.
  • unref'd. It never keeps the event loop alive; a process whose runtime has stopped exits whether or not a probe agent still holds a keep-alive connection.
  • Up before the graph is built, so /livez answers while construction is still running. Closed as the phase reaches exited, without being awaited — a slow close must not delay the exit report.
  • Errors emitted after listening (an accept failure such as EMFILE) are ignored rather than left unhandled, so a fault in the health endpoint cannot become an uncaughtException that tears the application down.

Failures

A bind failure is a startup failure: it stops the graph being built at all and lands in exited as Err(RuntimeStartFailed({ runtime: "probes", cause })), with a startFailed event first.

CauseRuntimeStartFailed.causerunMain code
port already in use, permission deniedNode's 'error' (EADDRINUSE, EACCES, …)1
PROBE_PORT malformed (abc, 3.5, 70000, "")a ConfigInvalid with port: "kernel", one issue at ["PROBE_PORT"] — plus an issue for PRE_DRAIN_DELAY_MS / DRAIN_TIMEOUT_MS if those are wrong too, since the kernel reads its own variables in one pass78
{ port } outside 0..65535 or not an integerNode's ERR_SOCKET_BAD_PORT, caught rather than let escape as a defect1

Reading the bound port

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

const app = start(OrderApi, { probes: { port: 0 } });
const port = await app.probePort(); // Result<number | undefined, never>

probePort() settles on every route out of the bind attempt — bound, disabled or failed — so it can never hang, and it settles before the graph is built.

Released under the MIT License.