Repository navigation
Commit bb14934
fix(cli): a serve boot that composes no HTTP server stays up until it is stopped (#24019)
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 (#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](https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fd0e72a commit bb14934
4 files changed
Lines changed: 483 additions & 0 deletions
File tree
- .changeset
- packages/cli/src/commands
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
0 commit comments