Logging, shutdown and timeouts
For @arki/[email protected] · source docs/operations.md
What an app needs once it leaves your laptop: one logging plugin, a clean
shutdown on SIGTERM, and a watchdog so a hung hook fails loudly.
The lifecycle at a glance
defineApp(name) returns a builder. Calling .use(plugin) accumulates
plugins. The lifecycle then flows:
defined ──configure()──▶ configured ──boot()──▶ booted ──start()──▶ started
│
disposed ◀──dispose()── stopped ◀──stop()───────┘boot() runs configure() implicitly if you skipped it. start() runs
boot() implicitly. stop() and dispose() always run in reverse
The full contract, with every failure rule, is in Lifecycle.
Logging
Dot logs through @arki/log. The kernel's own records go to the
arki:dot:lifecycle category. Importing Dot switches nothing on: the app
does, with log() from @arki/dot/log as its first .use(...).
import { defineApp } from '@arki/dot';
import { log } from '@arki/dot/log';
import { isFailure } from '@arki/failure';
import { asyncContext, fromEnv, stdout } from '@arki/log/node';
const app = await defineApp('checkout')
.use(
log(() => ({
...fromEnv(),
context: asyncContext(),
channels: { stdout: stdout() },
default: { level: 'info', channels: ['stdout'] },
})),
)
.boot();
if (isFailure(app)) throw new Error(app.code);- The config is a thunk. It runs when the app boots, never at import.
resource['service.name']defaults to the app name.- The
configure()handle is published as theloggingservice (needs: { logging: service<Configured<'stdout'>>() }). log()reads no env on its own (spreadfromEnv()), installs no OpenTelemetry SDK and does not patchconsole.
log() fills Dot's logging slot. Any plugin can fill it with
plugin({ name, logging: ({ appName }) => handle }), where handle has
close(): Promise<void>. The kernel calls the factory before its first
boot lifecycle record (boot: starting) and closes the handle after its
last one (dispose: complete). Logging is inactive during synchronous, I/O-free configure. On a failed boot or start,
the failure is written first and logging closes last. An app has at most one
logging plugin: two is ARKI_DOT_E018.
Without a logging plugin, @arki/log keeps only the first 64 records at
warn and above, and writes nothing else.
Shutdown and timeouts
Two kernel helpers close the gap between "boots on my machine" and "runs under a process manager":
import { defineApp, hookSignals, plugin } from '@arki/dot';
import { isFailure } from '@arki/failure';
const worker = plugin({
name: 'worker',
provides: { jobs: () => ({ poll: () => setInterval(() => {}, 1_000) }) },
start: ({ jobs }) => {
jobs.poll(); // begin processing
},
});
const app = await defineApp('shop', { hookTimeoutMs: 30_000 }).use(worker).start();
if (isFailure(app)) throw new Error(app.code);
hookSignals(app); // SIGTERM/SIGINT → stop() + dispose() → re-raisehookSignals(app, { timeoutMs? })— graceful shutdown wiring. The first signal drains the app (bounded bytimeoutMs, default 10 s) and re-raises itself so the exit status keeps standard signal semantics; a second signal falls through to the runtime default (immediate kill). Returns an unhook function.createAppRuntime(app, { hot: import.meta.hot, onStarted? })(from@arki/dot/runtime) — one started app per server process, for hosts that boot the app lazily (TanStack Start, Vite SSR).get()starts the app once and retries a failed start; the started app getshookSignals. Returned failures are never cached as ready; failed disposal blocks a replacement owner. On a Vite hot update the old app is disposed, and the replacing module starts its app only after that disposal finished —import.meta.hot.disposealone does not wait, so two apps would share the pool or the PGlite directory.hookTimeoutMs— a per-hook watchdog. It requests cancellation and recordsARKI_DOT_E015diagnostics. Owned construction returnsDeadlineafter admitted work settles; cleanup never races a callback still using resources. A hook that ignores cancellation can keep teardown pending. See Ownership.