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
| Route | 200 | 503 | Anything else |
|---|---|---|---|
GET /livez | body ok — any phase before exited | unavailable | — |
GET /readyz | body ready — phase serving, and not forced unready | unavailable | — |
GET /healthz | every declared health check passed | the report | — |
| other paths | — | — | 404, 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 |
|---|---|---|
building | 200 | 503 |
starting | 200 | 503 |
serving | 200 | 200, until forced unready |
draining | 200 | 503 |
stopping | 200 | 503 |
exited | 503 | 503 |
/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:
{
"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.probes | Behaviour |
|---|---|
| unset | The 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(). |
false | No 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.1only. 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
/livezanswers while construction is still running. Closed as the phase reachesexited, 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 anuncaughtExceptionthat 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.
| Cause | RuntimeStartFailed.cause | runMain code |
|---|---|---|
| port already in use, permission denied | Node'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 pass | 78 |
{ port } outside 0..65535 or not an integer | Node's ERR_SOCKET_BAD_PORT, caught rather than let escape as a defect | 1 |
Reading the bound port
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.