Repository navigation
fix(cli): a serve boot that composes no HTTP server stays up until it is stopped - #24019
Conversation
… its kernel stops `os serve --no-server --preset minimal` (production, no storage driver) printed a banner ending "Press Ctrl+C to stop" and exited 0 by itself milliseconds later: nothing this command owns held the event loop, and whether a kernel-only boot stayed up depended on whether the storage driver kept a connection pool. Hold the process with one ref'd handle, released by the kernel's own shutdown, on a boot with no transport. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
…s hold wiring Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
…time pin's measured budget Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
…n type-checks Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ff603ae8a462ff0d7617476c6e02153416a96505 && git checkout ff603ae8a462ff0d7617476c6e02153416a96505
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd9664fface534cd3fab47d80fa7269fe48a7616 bd15f3ad439ef4bc35e8d22694301e62a01b6f22 && git checkout -B drift-repro fd9664fface534cd3fab47d80fa7269fe48a7616 && git merge --no-ff bd15f3ad439ef4bc35e8d22694301e62a01b6f22
node scripts/docs-audit/affected-docs.mjs --json fd9664fface534cd3fab47d80fa7269fe48a7616 |
✅ ACCEPT: PR #24019 at
|
Fixes #23744
Clause-②: no
What was wrong
os serve objectstack.config.ts --no-server --preset minimal, in production on a host config with noOS_DATABASE_URLand no auth secret, printed its ready banner endingPress Ctrl+C to stopand then exited 0 by itself, about 50 ms later. This happened with and without anipcchannel, and before any signal. The process emittedbeforeExit, so this was the event loop draining.process.exit()does not emitbeforeExit, so the kernel's signal path did not end it. Nothing on that path callskernel.shutdown(), so no plugindestroy()ran either.Which reading: "stay up", established by measurement
The card left the defect to the claimant: is a kernel-only boot meant to stay up, or to run and exit? The measurements below all point the same way: it is meant to stay up.
1. The lifetime came from the storage driver, not from a decision. These readings were taken at base
adce192974, spawning the built CLI (bin/run.js,NODE_ENVunset). A--requirepreload recorded the ref'd handles and timers, with their creation stacks, 5 to 12 s after spawn.--dev)--no-server --preset minimal, no driver, withipcbeforeExitat +4275 ms: only the stdout/stderr pipes)seed-settled+4330 ms, exit 0 at +4387 ms, no signalipc--no-server --preset minimal+OS_DATABASE_URL=file:./a.dbsetInterval(1000)from tarn'sKnexPool._startReaping--no-server(default preset: job, queue, messaging composed) + DB + auth secret--dev --no-server(SqlDriver(better-sqlite3) → :memory:)The same command, with the same preset, stays up or exits depending on whether the driver keeps a connection pool. The job and queue services do not hold the loop. The platform's background loops are
unref()ed on purpose ("the loop never keeps a process alive by itself",DispatchLoopinpackages/core/src/dispatch-loop.ts), so the host is meant to decide the lifetime. On a kernel-only boot, the host isos serve, and before this change it did not decide.2. Everything else already says "stay up":
Press Ctrl+C to stop;serve.ts's own comment at the end of the ready path: "No duplicate handler needed here — just keep the process alive";gracefulShutdowncontract: the kernel owns the process, and a signal drains it and then exits;content/docs/deployment/cli.mdxsays "Skip HTTP server (kernel only)" and "Toggle HTTP server plugin", andpackages/cli/README.mdsays "Skip starting HTTP server plugin". None of them says the boot runs once.3. A run-and-exit kernel boot already exists, and it is explicit.
OS_MIGRATE_AND_EXIT=1shuts the kernel down and callsprocess.exit(0)before any banner.4. Who runs a kernel-only boot (tree
objectstack-ai/objectstackatadce192974,git grep -- --no-server):OS_MIGRATE_AND_EXIT=1:packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts:99packages/cli/src/commands/migrate/plan.telemetry-sibling.integration.test.ts:119packages/cli/test/serve-migrate-exit-schema-parity.integration.test.tsserve.tsalso names cloud'sapps/cloud/scripts/migrate.ts. It is not in this repository and was not measured.--no-serverboot is stopped by a signal:serve-no-server-banner.integration.test.tsserve-no-transport-announcements.integration.test.ts, whose header already calls the self-exit "a separate card"test/serve-no-storage-boot-guard.integration.test.tsos devandos startnever pass--no-server. No script, workflow, example or Dockerfile runs it.--no-serverboot and expect it to exit: 0.The evidence is not split, so the banner line stays and the process now matches it.
The fix
packages/cli/src/commands/serve.ts:holdKernelOnlyProcess(kernel): one ref'dsetIntervalwhose callback does nothing. Its period is the largest a Node timer accepts. It is a handle, not a timeout: it ends nothing and decides nothing.kernel.onShutdown. So the kernel's SIGINT/SIGTERM/SIGQUIT handling stays the only thing that ends the process, and the process exits with the kernel's code.httpServer !== 'composed'. That is the same composition read the banner's address rows and thepublishBoundPortgate already use, so all three agree on whether there is a transport.Unchanged:
--no-serverwith a config-mountedHonoServerPlugin;OS_MIGRATE_AND_EXIT=1;publishBoundPortand its channels, and theMCP:gate;utils/format.tsand the docs, because the behaviour they state is now true.After, at the same door (built CLI at
c29a1d1f90)--no-server --preset minimal,ipcholdKernelOnlyProcess's; SIGTERM, then exit 0, signal nullipc[objectstack:listening, objectstack:seed-settled]; alive 5 s; SIGTERM, then exit 0--dev --no-serverOS_MIGRATE_AND_EXIT=1 --no-server --preset minimal+ DBPins
src/commands/serve-kernel-only-lifetime.integration.test.tsis the fixed shape at the door. It spawnsbin/run.jswith anipcchannel and a hard timeout on the boot (150 s) and on the stop (60 s). Each child is SIGKILLed infinallyif it is still alive. It runs three boots, and for each one waits for the banner andseed-settled, leaves it alone for 3 s, then sends SIGTERM:{ code: 0, signal: null };--dev --no-server: the same;[listening, seed-settled], alive,{ code: 0, signal: null }.src/commands/serve-kernel-only-hold.test.ts(unit) runs against a realObjectKernel:shutdown()and by nothing before it.It also carries comment-masked source pins:
httpServer !== 'composed', afterseedSettlement.release();serve.tsinstalls noprocess.on('SIG…')listener.Negative control (ablation)
The fix was committed first, at
7438857540. The script was trap-restored, and every path in it was absolute.scripts/ablation-replace.mjsreplacedif (httpServer !== 'composed') holdKernelOnlyProcess(kernel);withvoid 'ABLATION_23744_NO_HOLD';: anchor x1 → x0, marker x0 → x1, blobee844f32bdfc→98880fdd130a.pnpm --filter @objectstack/cli buildexited 0.ablation-dist-preflight.mjs @objectstack/cli ABLATION_23744_NO_HOLDfound the marker indist/commands/serve.js(exit 0), and the dist call-site count was 0.expected { code: 0, signal: null, msAfterBanner: 50 } to be null. Both controls stayed green.ee844f32bdfcandgit diff HEADis empty. The rebuild exited 0.--absentpreflight exited 0 (marker absent from all 652 built files, tree clean), and the dist call-site count was back to 1. Integration was green 3/3 and unit green 6/6.Tests and gates (final head
bd15f3ad43)pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project integrationover the new file and the three sibling--no-serverintegration files: 4 files, 15 tests passed. Run locally because this diff adds an integration file.unittier of@objectstack/cli: 307 files, 4573 tests passed.pnpm --filter @objectstack/cli typecheck(tsc --noEmitpluscheck:test-typecheck): exit 0.pnpm exec eslintover the three changed.tsfiles: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave 66 commands. All 66 were run atbd15f3ad43and every one exited 0. The--ranreconciliation reads66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN(exit 0). In an earlier pass two families answeredPREREQUISITE NOT MET(exit 3), because nine packages outside this diff were unbuilt. Those packages were built and both families re-ran green. The derivation warns that the tree is 7 commits behindorigin/mainand thatscripts/check-route-envelope.mjshas changed there since; CI reads the new copy.Acceptance notes
serve-no-transport-announcements.integration.test.ts's header says "Shape 1 exits on its own a few milliseconds after its banner (a separate card)". It no longer does. That file's directory watch still has a reason to exist: it catches a state file written and then deleted mid-boot. Suggested one-line correction for whoever next touches that file: "Shape 1 stays up until it is stopped (cli/serve:--no-server --preset minimalprints the ready banner ending "Press Ctrl+C to stop" and then exits 0 on its own about 40 ms later, removing the runtime state file it had just written #23744)".origin/mainbefore opening. Seven commits landed onmainafteradce192974. None touchespackages/cli,packages/runtimeor the kernel lifecycle; they touchspec,core(api-key text),rest,plugin-securityand docs. Nothing conflicts textually. The queue rebuilds on mergedmain.// test-budget: 40in the class "spawns the real producer once per single-cause fixture". It does three boots, each differing only in the input under test, held 3 s each. Local readings were 26.2 s and 27.0 s on a shared container; 27.0 × 1.25, rounded up, gives 40. There is no CI reading yet.min: 0would let tarn stop reaping after its idle timeout, so the old self-exit could also have reached a driver-backed boot after about 30 s idle. This was not measured: the hold makes the lifetime independent of the driver either way.Generated by Claude Code