Skip to content

fix(cli): a serve boot that composes no HTTP server stays up until it is stopped - #24019

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23744-kernel-only-lifetime
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23744-kernel-only-lifetime

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23744

Clause-②: no

What was wrong

os serve objectstack.config.ts --no-server --preset minimal, in production on a host config with no OS_DATABASE_URL and no auth secret, printed its ready banner ending Press Ctrl+C to stop and then exited 0 by itself, about 50 ms later. This happened with and without an ipc channel, and before any signal. The process emitted beforeExit, so this was the event loop draining. process.exit() does not emit beforeExit, so the kernel's signal path did not end it. Nothing on that path calls kernel.shutdown(), so no plugin destroy() 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_ENV unset). A --require preload recorded the ref'd handles and timers, with their creation stacks, 5 to 12 s after spawn.

boot (production unless --dev) what held the event loop outcome
--no-server --preset minimal, no driver, with ipc nothing (beforeExit at +4275 ms: only the stdout/stderr pipes) banner +4329 ms, seed-settled +4330 ms, exit 0 at +4387 ms, no signal
the same, without ipc nothing banner +3997 ms, exit 0 at +4046 ms
--no-server --preset minimal + OS_DATABASE_URL=file:./a.db ONLY setInterval(1000) from tarn's KnexPool._startReaping alive 8 s past its banner; SIGTERM, then exit 0
--no-server (default preset: job, queue, messaging composed) + DB + auth secret ONLY the same tarn reaper alive 8 s; SIGTERM, then exit 0
--dev --no-server (SqlDriver(better-sqlite3) → :memory:) ONLY the same tarn reaper alive 15 s; SIGTERM, then exit 0

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", DispatchLoop in packages/core/src/dispatch-loop.ts), so the host is meant to decide the lifetime. On a kernel-only boot, the host is os serve, and before this change it did not decide.

2. Everything else already says "stay up":

  • the banner's 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";
  • the kernel's gracefulShutdown contract: the kernel owns the process, and a signal drains it and then exits;
  • the docs: content/docs/deployment/cli.mdx says "Skip HTTP server (kernel only)" and "Toggle HTTP server plugin", and packages/cli/README.md says "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=1 shuts the kernel down and calls process.exit(0) before any banner.

4. Who runs a kernel-only boot (tree objectstack-ai/objectstack at adce192974, git grep -- --no-server):

  • Every boot that is meant to exit sets OS_MIGRATE_AND_EXIT=1:
    • packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts:99
    • packages/cli/src/commands/migrate/plan.telemetry-sibling.integration.test.ts:119
    • packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts
    • serve.ts also names cloud's apps/cloud/scripts/migrate.ts. It is not in this repository and was not measured.
  • Every other --no-server boot is stopped by a signal:
    • serve-no-server-banner.integration.test.ts
    • serve-no-transport-announcements.integration.test.ts, whose header already calls the self-exit "a separate card"
    • test/serve-no-storage-boot-guard.integration.test.ts
  • os dev and os start never pass --no-server. No script, workflow, example or Dockerfile runs it.
  • Count of readers that run a plain --no-server boot 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'd setInterval whose 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.
  • The kernel's own shutdown releases it, through 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.
  • ⛔ No signal listener is added.
  • It is called at the end of the ready path only when httpServer !== 'composed'. That is the same composition read the banner's address rows and the publishBoundPort gate already use, so all three agree on whether there is a transport.

Unchanged:

  • a composed boot, including --no-server with a config-mounted HonoServerPlugin;
  • OS_MIGRATE_AND_EXIT=1;
  • publishBoundPort and its channels, and the MCP: gate;
  • the banner text, utils/format.ts and the docs, because the behaviour they state is now true.

After, at the same door (built CLI at c29a1d1f90)

boot outcome
prod --no-server --preset minimal, ipc banner +4254 ms; alive 5 s later; at +7002 ms the only ref'd timer is holdKernelOnlyProcess's; SIGTERM, then exit 0, signal null
the same, without ipc alive 5 s; SIGTERM, then exit 0
default composed production boot [objectstack:listening, objectstack:seed-settled]; alive 5 s; SIGTERM, then exit 0
--dev --no-server alive 5 s; SIGTERM, then exit 0
OS_MIGRATE_AND_EXIT=1 --no-server --preset minimal + DB exit 0 at +4.6 s, no banner (unchanged)

Pins

  • src/commands/serve-kernel-only-lifetime.integration.test.ts is the fixed shape at the door. It spawns bin/run.js with an ipc channel and a hard timeout on the boot (150 s) and on the stop (60 s). Each child is SIGKILLed in finally if it is still alive. It runs three boots, and for each one waits for the banner and seed-settled, leaves it alone for 3 s, then sends SIGTERM:

    • the card's command: still alive, and SIGTERM ends it with { code: 0, signal: null };
    • CONTROL --dev --no-server: the same;
    • CONTROL default composed boot: [listening, seed-settled], alive, { code: 0, signal: null }.
  • src/commands/serve-kernel-only-hold.test.ts (unit) runs against a real ObjectKernel:

    • the handle is ref'd;
    • it is released by the kernel's shutdown() and by nothing before it.

    It also carries comment-masked source pins:

    • exactly one hold site, gated on httpServer !== 'composed', after seedSettlement.release();
    • serve.ts installs no process.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.mjs replaced if (httpServer !== 'composed') holdKernelOnlyProcess(kernel); with void 'ABLATION_23744_NO_HOLD';: anchor x1 → x0, marker x0 → x1, blob ee844f32bdfc → 98880fdd130a.
  • pnpm --filter @objectstack/cli build exited 0. ablation-dist-preflight.mjs @objectstack/cli ABLATION_23744_NO_HOLD found the marker in dist/commands/serve.js (exit 0), and the dist call-site count was 0.
  • Integration: RED, bounded. The card's command failed after 4491 ms with expected { code: 0, signal: null, msAfterBanner: 50 } to be null. Both controls stayed green.
  • Unit: RED on both wiring pins: 2 failed, 4 passed.
  • Restore: the blob equals HEAD ee844f32bdfc and git diff HEAD is empty. The rebuild exited 0. --absent preflight 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 integration over the new file and the three sibling --no-server integration files: 4 files, 15 tests passed. Run locally because this diff adds an integration file.
  • The new unit file: 6/6.
  • The full unit tier of @objectstack/cli: 307 files, 4573 tests passed.
  • pnpm --filter @objectstack/cli typecheck (tsc --noEmit plus check:test-typecheck): exit 0.
  • pnpm exec eslint over the three changed .ts files: exit 0.
  • Derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gave 66 commands. All 66 were run at bd15f3ad43 and every one exited 0. The --ran reconciliation reads 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). In an earlier pass two families answered PREREQUISITE 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 behind origin/main and that scripts/check-route-envelope.mjs has changed there since; CI reads the new copy.
  • CI runs the rest of the farm.

Acceptance notes

  • A comment this change made stale (outside the claim's file surface, so not edited here). 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 minimal prints 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)".
  • Branch not merged with origin/main before opening. Seven commits landed on main after adce192974. None touches packages/cli, packages/runtime or the kernel lifecycle; they touch spec, core (api-key text), rest, plugin-security and docs. Nothing conflicts textually. The queue rebuilds on merged main.
  • Budget. The integration file declares // test-budget: 40 in 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.
  • Not covered here. A pool configured with min: 0 would 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

… 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>
…time pin's measured budget

Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fd9664fface534cd3fab47d80fa7269fe48a7616 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ff603ae8a462ff0d7617476c6e02153416a96505 — the merge of head bd15f3ad439ef4bc35e8d22694301e62a01b6f22 into base fd9664fface534cd3fab47d80fa7269fe48a7616, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #24019 at bd15f3ad43 (#23744, Fixes #23744). It queues on a true-green head

domain:cli seat 1 (#6024) · marchtian · session session_01PFB9ZGRWYvjhTXsd8i6KBp · 2026-10-16T19:08Z · holder of claim 6220729537. Report: os-dev-report 6222472811.

席位核对

  1. Shape, read on GitHub: draft against main, head bd15f3ad43. Line 1 Fixes #23744, the only closing keyword. Line 3 Clause-②: no, line-initial and bare, as the claim declares. Assignee marchtian. 4 files: serve.ts (+64), a new unit file, a new integration file (test-budget: 40, class "spawns the real producer once per single-cause fixture", as on cli/serve --no-server, the remainder of #23623: the objectstack:listening IPC message and the runtime state file still announce http://localhost:PORT with nothing listening, and a config-mounted server's banner names an unmounted MCP route #23743's sibling file), and one changeset. format.ts, the docs and publishBoundPort are untouched. check-governed-merges --pr 24019: NOT governed. mergeable_state clean; git merge-tree against current origin/main fd0e72abc2 is clean. Four single-parent commits with the model-free trailer pair.

  2. The card's fork, answered with measured evidence: stay up. At base adce192974:

    • the card's command (no driver) drained its event loop and exited 0 about 50 ms after its banner, with no kernel shutdown;
    • --dev --no-server, --no-server with OS_DATABASE_URL, and the default preset with a database each stayed up, and in all three the only ref'd handle was the SQL pool's reaping interval. So the lifetime was an accident of the driver;
    • every in-repo kernel-only boot meant to exit sets OS_MIGRATE_AND_EXIT=1, and every other --no-server boot is stopped by a signal. Zero readers expect a plain --no-server boot to exit.

    The banner's Press Ctrl+C to stop and the comment at the ready path ("just keep the process alive") agree.

  3. The change, read against the diff: holdKernelOnlyProcess(kernel) adds one ref'd no-op interval and releases it from kernel.onShutdown. It is called once, at the end of the ready path, only when httpServer !== 'composed': the same read the banner rows and the publish site use. No signal listener is added (core: ObjectKernel.shutdown() never removes the SIGINT/SIGTERM/SIGQUIT listeners its constructor registers, so every kernel built in a process leaves three handlers that each run shutdown and process.exit on a later signal #22286's lesson), so the kernel's own handling stays the only thing that ends the process, with its own exit code. Composed boots and OS_MIGRATE_AND_EXIT are unchanged. holdKernelOnlyProcess is a named export of commands/serve.ts, whose only package-entry re-export is the ServeCommand default, so it is not a published symbol.

  4. Pins and measurement:

    • The integration file boots bin/run.js three times, bounded, and SIGKILLs in finally: the card's command (alive 3 s past its banner, then SIGTERM → exit 0, no signal death), --dev --no-server, and a default composed boot ([listening, seed-settled], stays up, SIGTERM → 0).
    • The unit file drives the hold against a real kernel and pins the single call site and its predicate.
    • Ablation of the call site (ablation-replace.mjs, CLI rebuilt, dist preflight): the card's shape went red, bounded ({ code: 0, signal: null, msAfterBanner: 50 }), the controls stayed green, and both wiring pins were red. Restored to the HEAD blob and rebuilt.
  5. CI at bd15f3ad43: 40 check-runs, 34 success, 6 skipped (each on the EXPECTED_SKIPS roster); the five required are success, the six Test Core shards included.

  6. The changeset (@objectstack/cli patch, line-initial Clause-②: no): before, after, why "stay up", what is unchanged, and the one-line path for a script that relied on the exit (OS_MIGRATE_AND_EXIT=1, or a signal). Each matches the diff. The lifetime was undocumented and depended on the driver; it is not an accept set, so the line stays no.

  7. Out of scope:

Landing: this seat flips ready and arms auto-merge through the relay.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 19:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 19:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit bb14934 Oct 16, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23744-kernel-only-lifetime branch October 16, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants