Skip to content

Commit 786cffa

Browse files
fix(cli,core): the ready line reports the degraded boot the kernel already concluded
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
1 parent 85f3399 commit 786cffa

2 files changed

Lines changed: 150 additions & 54 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `` over it.
7+
8+
`✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody.
9+
10+
- **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone.
11+
- **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: <names>`, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move.
12+
- **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in.

packages/cli/test/serve-ready-degraded-boot.e2e.test.ts

Lines changed: 138 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4+
import { spawn, type ChildProcessByStdio } from 'node:child_process';
45
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
56
import { tmpdir } from 'node:os';
6-
import { join } from 'node:path';
7-
import { randomPort, runServe } from './helpers/serve-process.js';
7+
import { join, resolve } from 'node:path';
8+
import type { Readable } from 'node:stream';
9+
import { fileURLToPath } from 'node:url';
10+
import {
11+
childEnv,
12+
E2E_SECRET_KEY,
13+
portContentionError,
14+
requireBuiltCli,
15+
reservePort,
16+
RUN_JS_RESOLVES_FROM_DIST,
17+
} from './helpers/serve-process.js';
818

919
/**
1020
* #16630 — a REAL `os serve` boot, degraded, read end to end.
@@ -26,106 +36,180 @@ import { randomPort, runServe } from './helpers/serve-process.js';
2636
* ⇒ Testing one package's output is the perspective that produced the defect.
2737
* `src/utils/format.server-ready-degraded-boot.test.ts` closes the seam over a
2838
* real `ObjectKernel`; this file closes it over the real COMMAND — the whole
29-
* assembly of plugins, tiers and banner that a user actually runs.
39+
* assembly of plugins, tiers and banner a user actually runs.
3040
*
31-
* ## How the degradation is provoked — the shape both incidents had
41+
* ## Why `bin/run.js` with `NODE_ENV` unset, and not `runServe()`
3242
*
33-
* Not a fault injected into the kernel: `serve` auto-registers `AuthPlugin`
34-
* only when a secret is resolvable, and outside `--dev` there is no fallback
35-
* one — so a production boot with no `OS_AUTH_SECRET` skips auth by its own
36-
* documented rule, and `auth` (a `core` service with no in-memory fallback)
37-
* is genuinely absent. That is the same end state both incidents reached by
38-
* other routes: a plugin that did not load and a core service that is not
39-
* there.
43+
* The degradation has to be REACHED, not injected, and the only reachable
44+
* spelling is production posture. `serve` auto-registers `AuthPlugin` when a
45+
* secret resolves; outside `--dev` there is no fallback secret, so a production
46+
* boot with no `OS_AUTH_SECRET` skips auth by the command's own documented rule
47+
* (`⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`) and `auth` — a `core` service
48+
* with no in-memory fallback — is genuinely absent. That is the end state both
49+
* incidents reached by other routes: a plugin that did not load, and a core
50+
* service that is not there.
4051
*
41-
* ⚠️ This is also the NEGATIVE CONTROL for the acceptance rule that start and
42-
* exit behaviour must not change: that boot is a machine DELIBERATELY running
43-
* without auth, and it must still reach the banner and still be a live server.
44-
* Readiness is not made strict here; the ready line only says what state it is
45-
* ready in.
52+
* ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which
53+
* sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true,
54+
* the dev fallback secret applies, auth ALWAYS loads and the boot is never
55+
* degraded. Measured: switching this file to `runServe()` makes both legs
56+
* healthy and the degraded assertions unreachable — a green that measures
57+
* nothing. `bin/run.js` with `NODE_ENV` genuinely unset is the only shape that
58+
* reaches the gate, which is also why {@link requireBuiltCli} guards the file.
59+
*
60+
* ⚠️ The degraded leg is ALSO the negative control for the acceptance rule that
61+
* start and exit behaviour must not change: it is a machine deliberately
62+
* running without auth, and it still reaches the complete banner and is still a
63+
* live server afterwards. Readiness is not made strict here; the ready line
64+
* only says what state it is ready in.
4665
*/
4766

67+
/** The complete banner — keyed on its LAST line, so the whole block is on the stream. */
68+
const READY_BANNER_TAIL = /Press Ctrl\+C to stop/;
69+
70+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
71+
/** `bin/run.js` — the SHIPPED entrypoint. See the header for why this one. */
72+
const CLI = resolve(HERE, '../bin/run.js');
73+
4874
const BARE_CONFIG = 'export default {};\n';
4975

50-
let degradedDir: string;
51-
let healthyDir: string;
76+
/** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */
77+
type BootChild = ChildProcessByStdio<null, Readable, Readable>;
5278

53-
beforeAll(() => {
54-
degradedDir = mkdtempSync(join(tmpdir(), 'os-ready-degraded-'));
55-
writeFileSync(join(degradedDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8');
79+
const children: BootChild[] = [];
80+
let fixtureDir: string;
81+
82+
interface Boot {
83+
out: string;
84+
err: string;
85+
child: BootChild;
86+
}
87+
88+
/**
89+
* Spawn `os serve` in production posture and resolve once the COMPLETE banner
90+
* is on the stream. Extra `env` entries override the base; `undefined` unsets.
91+
*/
92+
async function boot(env: Record<string, string | undefined> = {}): Promise<Boot> {
93+
const port = reservePort();
94+
const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', String(port)], {
95+
cwd: fixtureDir,
96+
stdio: ['ignore', 'pipe', 'pipe'],
97+
// `childEnv`, never a bare `...process.env` — see its header (#11267).
98+
env: childEnv({
99+
NO_COLOR: '1',
100+
OS_DATABASE_URL: ':memory:',
101+
OS_LOG_LEVEL: '',
102+
OS_DISABLE_CONSOLE: '1',
103+
// Production posture refuses to mint a crypto key; supplying a stable one
104+
// keeps the refusal out of the way of the property under test.
105+
OS_SECRET_KEY: E2E_SECRET_KEY,
106+
// ⭐ Truly unset — the value that leaves the boot in production posture
107+
// (and leaves oclif resolving the command from `dist/`). Node omits an
108+
// `undefined` entry rather than inheriting the vitest worker's own.
109+
NODE_ENV: undefined,
110+
...env,
111+
}),
112+
}) as BootChild;
113+
children.push(child);
56114

57-
healthyDir = mkdtempSync(join(tmpdir(), 'os-ready-healthy-'));
58-
writeFileSync(join(healthyDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8');
115+
let out = '';
116+
let err = '';
117+
118+
await new Promise<void>((ready, fail) => {
119+
const timer = setTimeout(() => {
120+
fail(new Error(
121+
'serve never printed its COMPLETE ready banner (last line: "Press Ctrl+C to stop")'
122+
+ `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`,
123+
));
124+
}, 150_000);
125+
const onData = () => {
126+
if (READY_BANNER_TAIL.test(out + err)) {
127+
clearTimeout(timer);
128+
ready();
129+
}
130+
};
131+
child.stdout.on('data', (d) => { out += String(d); onData(); });
132+
child.stderr.on('data', (d) => { err += String(d); onData(); });
133+
child.on('exit', (code) => {
134+
clearTimeout(timer);
135+
// A lost port race gets its own failure before the generic one (#12441):
136+
// `serve exited 1 before the banner` is what it produces otherwise, and
137+
// that reads as a verdict about this file's subject when it is not.
138+
fail(
139+
portContentionError(out + err, 'os serve (bin/run.js, NODE_ENV unset ⇒ production)', port)
140+
?? new Error(`serve exited ${code} before the ready banner\n--- stdout ---\n${out}\n--- stderr ---\n${err}`),
141+
);
142+
});
143+
});
144+
145+
return { out, err, child };
146+
}
147+
148+
beforeAll(() => {
149+
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
150+
fixtureDir = mkdtempSync(join(tmpdir(), 'os-ready-degraded-'));
151+
writeFileSync(join(fixtureDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8');
59152
});
60153

61154
afterAll(() => {
62-
for (const dir of [degradedDir, healthyDir]) {
63-
if (dir) rmSync(dir, { recursive: true, force: true });
155+
for (const child of children) {
156+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
64157
}
158+
if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true });
65159
});
66160

67161
describe('os serve — the ready line reports a degraded boot (#16630)', () => {
68162
it(
69163
'prints ready AND the missing core service in the same output, with no unconditional tick',
70164
async () => {
71-
const { stdout, stderr } = await runServe(degradedDir, ['--port', randomPort()], {
72-
waitFor: /Press Ctrl\+C to stop/,
73-
timeoutMs: 240_000,
74-
});
75-
const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`;
76-
const output = stdout + stderr;
165+
const { out, err, child } = await boot();
166+
const seen = `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`;
167+
const output = out + err;
77168

78169
// The premise: this boot really is degraded, and the kernel really said
79-
// so. Asserted first — without it every assertion below is vacuous.
170+
// so. Asserted FIRST — without it every assertion below is vacuous.
80171
expect(output, `this boot was not degraded${seen}`).toContain(
81172
'System started with degraded capabilities. Missing core services: auth',
82173
);
83174

84175
// ⭐ BOTH sides, in ONE output — the property the two packages could not
85176
// hold between them before the data path existed.
86-
expect(stderr, `serve never reported ready${seen}`).toContain('Server is ready');
87-
expect(stderr, `the ready line withheld the degradation${seen}`).toContain(
177+
expect(err, `serve never reported ready${seen}`).toContain('Server is ready');
178+
expect(err, `the ready line withheld the degradation${seen}`).toContain(
88179
'⚠ Server is ready — DEGRADED: missing core services: auth',
89180
);
90181

91182
// ⭐ The defect itself, gone: the green tick may not appear on a boot the
92183
// kernel has recorded as degraded.
93-
expect(stderr, `the unconditional green tick survived${seen}`).not.toContain(
94-
'✓ Server is ready',
95-
);
184+
expect(err, `the unconditional green tick survived${seen}`).not.toContain('✓ Server is ready');
96185

97-
// ⛔ Start behaviour unchanged: a machine deliberately without auth still
98-
// boots all the way through the banner. `runServe` only resolves once
99-
// the banner's LAST line is on the stream, so reaching here IS that.
100-
expect(stderr, `boot stopped short of the banner tail${seen}`).toContain(
101-
'Press Ctrl+C to stop',
102-
);
186+
// ⛔ Start behaviour unchanged. Reaching here already means the COMPLETE
187+
// banner printed; the process being alive is the other half.
103188
expect(output).not.toContain('rollback complete');
189+
expect(child.exitCode, `serve exited during a boot it called ready${seen}`).toBeNull();
104190
},
105191
240_000,
106192
);
107193

108194
it(
109195
'leaves a healthy boot printing exactly the tick it always printed',
110196
async () => {
111-
// Same fixture, same command — the ONLY difference is that auth can now
112-
// register, so nothing is missing. An "always append a status line"
113-
// implementation passes the degraded case above and fails here.
114-
const { stdout, stderr } = await runServe(healthyDir, ['--port', randomPort()], {
115-
waitFor: /Press Ctrl\+C to stop/,
116-
timeoutMs: 240_000,
117-
env: { OS_AUTH_SECRET: 'os-16630-healthy-leg-secret-not-a-real-credential' },
197+
// Same fixture, same entrypoint, same posture — the ONLY difference is
198+
// that auth can now register, so nothing is missing. An "always append a
199+
// status line" implementation passes the degraded leg and fails here.
200+
const { out, err } = await boot({
201+
OS_AUTH_SECRET: 'os-16630-healthy-leg-secret-not-a-real-credential',
118202
});
119-
const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`;
120-
const output = stdout + stderr;
203+
const seen = `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`;
204+
const output = out + err;
121205

122206
// The premise for THIS leg: nothing was missing.
123207
expect(output, `the healthy leg booted degraded${seen}`).not.toContain(
124208
'System started with degraded capabilities',
125209
);
126210

127-
expect(stderr, `healthy boot lost its ready tick${seen}`).toContain('✓ Server is ready');
128-
expect(stderr, `healthy boot grew a degraded notice${seen}`).not.toContain('DEGRADED');
211+
expect(err, `healthy boot lost its ready tick${seen}`).toContain('✓ Server is ready');
212+
expect(err, `healthy boot grew a degraded notice${seen}`).not.toContain('DEGRADED');
129213
},
130214
240_000,
131215
);

0 commit comments

Comments
 (0)