Skip to content

Commit bb14934

Browse files
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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): an `os serve` boot that composes no HTTP server now stays up until it is stopped, as its banner's `Press Ctrl+C to stop` says
6+
7+
Clause-②: no
8+
9+
- **Before.** `os serve --no-server --preset minimal`, in production with no storage driver (no `OS_DATABASE_URL`), printed the ready banner ending `Press Ctrl+C to stop` and then exited 0 by itself, about 50 ms later, with no signal and without the kernel shutting down. The same command stayed up whenever the storage driver kept a connection pool (`--dev --no-server`, or `--no-server` with `OS_DATABASE_URL` set). Measured, that pool's reaping interval was the only thing keeping those boots alive, so whether a kernel-only boot stayed up depended on the driver.
10+
- **After, for an operator or supervisor of a boot with no HTTP server composed** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). The process stays up after its banner, whatever the driver, until SIGINT, SIGTERM or SIGQUIT. The kernel's own signal handling then drains it and exits with the kernel's code, 0 on a clean shutdown, as on a server boot. No signal listener is added.
11+
- **Why "stay up" and not "run and exit".** The banner says `Press Ctrl+C to stop`. The kernel is built to own the process: a signal drains it, then exits it. The platform's background loops never keep a process alive by themselves, so the host decides the lifetime. The run-and-exit kernel boot already exists, and is explicit: `OS_MIGRATE_AND_EXIT=1`. Every in-repo kernel-only boot that is meant to exit sets it, and every other one is stopped by a signal.
12+
- **Unchanged.** A boot with an HTTP server composed, including a `--no-server` boot whose config mounts its own `HonoServerPlugin`. `OS_MIGRATE_AND_EXIT=1`, which still shuts the kernel down and exits 0 before any banner. The IPC messages and the runtime state file.
13+
- **If you relied on the exit.** A script that ran `os serve --no-server` and waited for the process to end now has to set `OS_MIGRATE_AND_EXIT=1` (bootstrap once, then exit 0), or stop the process with a signal.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23744 — a `serve` boot that composes no HTTP transport is held alive until
5+
* its kernel stops.
6+
*
7+
* ## The defect
8+
*
9+
* `os serve objectstack.config.ts --no-server --preset minimal`, in production
10+
* with no storage driver, printed its ready banner ending `Press Ctrl+C to
11+
* stop` and exited 0 by itself milliseconds later. Nothing this command owns
12+
* held the event loop. The same command stayed up only when the storage driver
13+
* kept a connection pool, whose reaping interval was the one ref'd handle left.
14+
*
15+
* ## What this file pins, and what it leaves to the boot
16+
*
17+
* The BEHAVIOUR of {@link holdKernelOnlyProcess} is driven against a real
18+
* `ObjectKernel`: its handle is ref'd (an `unref()`ed one holds nothing), and
19+
* the kernel's own shutdown releases it. The WIRING inside `run()` (one
20+
* ~3000-line method that needs a whole kernel to enter) is a source pin with
21+
* comments masked, as `serve-no-transport-announcements.test.ts` pins its gate.
22+
* `serve-kernel-only-lifetime.integration.test.ts` drives both through real
23+
* boots of the built CLI and reads the process state and its exit code.
24+
*/
25+
26+
import { afterEach, describe, expect, it, vi } from 'vitest';
27+
import { readFileSync } from 'node:fs';
28+
import { resolve } from 'node:path';
29+
import { fileURLToPath } from 'node:url';
30+
31+
import { ObjectKernel } from '@objectstack/core';
32+
33+
// The repo's ONE code/prose separator: a comment saying the process is held
34+
// must not answer for code that does not hold it.
35+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
36+
37+
import { holdKernelOnlyProcess } from './serve.js';
38+
39+
/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
40+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
41+
const SERVE_RAW = readFileSync(resolve(HERE, 'serve.ts'), 'utf8');
42+
/** `serve.ts`'s CODE, with every comment span blanked. */
43+
const SERVE = maskComments(SERVE_RAW);
44+
45+
/** The one service a kernel refuses to start without. Nothing here reads data. */
46+
const DATA_STUB = {
47+
name: 'com.example.fx23744.data-stub',
48+
version: '1.0.0',
49+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
50+
start: async () => {},
51+
};
52+
53+
/** A running kernel. `gracefulShutdown: false`: this test process must not gain the kernel's signal listeners. */
54+
async function runningKernel(): Promise<ObjectKernel> {
55+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
56+
await kernel.use(DATA_STUB);
57+
await kernel.bootstrap();
58+
return kernel;
59+
}
60+
61+
const held: Array<ReturnType<typeof setInterval>> = [];
62+
63+
afterEach(() => {
64+
vi.restoreAllMocks();
65+
// A failed assertion must not leave a ref'd handle holding the worker.
66+
for (const hold of held.splice(0)) clearInterval(hold);
67+
});
68+
69+
describe('holdKernelOnlyProcess — the process lives until the kernel stops (#23744)', () => {
70+
it('holds the event loop: the handle is ref\'d', async () => {
71+
const kernel = await runningKernel();
72+
const hold = holdKernelOnlyProcess(kernel);
73+
held.push(hold);
74+
expect(hold.hasRef(), 'an unref()ed handle holds nothing — the boot exits after its banner again').toBe(true);
75+
await kernel.shutdown();
76+
});
77+
78+
it('the kernel\'s own shutdown releases it, and nothing before that does', async () => {
79+
const kernel = await runningKernel();
80+
const cleared = vi.spyOn(globalThis, 'clearInterval');
81+
const hold = holdKernelOnlyProcess(kernel);
82+
held.push(hold);
83+
expect(cleared, 'released before the kernel stopped').not.toHaveBeenCalledWith(hold);
84+
await kernel.shutdown();
85+
expect(cleared, 'a stopped kernel still holds the process').toHaveBeenCalledWith(hold);
86+
});
87+
});
88+
89+
describe('the wiring inside `run()` — held on exactly the boots no transport holds (#23744)', () => {
90+
it('holds when no HTTP server is composed, reading the SAME composition the banner and the publish site read', () => {
91+
expect(SERVE.match(/holdKernelOnlyProcess\(kernel\)/g) ?? [], 'not one hold call site').toHaveLength(1);
92+
expect(SERVE, 'the hold is no longer gated on the composed transport').toContain(
93+
"if (httpServer !== 'composed') holdKernelOnlyProcess(kernel);",
94+
);
95+
expect(SERVE.match(/const httpServer =/g) ?? [], 'a second composition read can disagree with the first').toHaveLength(1);
96+
});
97+
98+
it('holds after the boot\'s last announcement, the seed-settled release', () => {
99+
const release = SERVE.indexOf('seedSettlement.release();');
100+
const hold = SERVE.indexOf("if (httpServer !== 'composed') holdKernelOnlyProcess(kernel);");
101+
expect(release).toBeGreaterThan(-1);
102+
expect(hold, 'the hold moved ahead of the ready path').toBeGreaterThan(release);
103+
});
104+
105+
it('⛔ serve installs no signal listener of its own — the kernel\'s is the only one', () => {
106+
expect(SERVE).not.toMatch(/process\.(?:on|once|addListener|prependListener)\(\s*['"]SIG/);
107+
});
108+
109+
it('ANTI-VACUITY: the scan reads code, and a comment is invisible to it', () => {
110+
expect(SERVE.length).toBe(SERVE_RAW.length);
111+
const proseOnly = 'just keep the process alive';
112+
expect(SERVE_RAW, 'the control sentence was reworded — pick another').toContain(proseOnly);
113+
expect(SERVE).not.toContain(proseOnly);
114+
expect(SERVE).toContain('export function holdKernelOnlyProcess(');
115+
});
116+
});

0 commit comments

Comments
 (0)