Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/17329-seed-settled-ipc-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/cli": minor
---

`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output.

`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY.

- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design.
- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it.
- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed.
- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts.
- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending.

⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves.

Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place.
61 changes: 61 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,67 @@ audit) lands there instead of the business DB (ADR-0057). Opt out with
`OS_TELEMETRY_DB=0`, or point it elsewhere (any mode, including `serve`)
with `OS_TELEMETRY_DB=<path>`.

##### Waiting for the boot from a parent process

`✓ Server is ready` is true about the **HTTP server**, and deliberately says
nothing about the app's data. Seeding races a soft budget
(`OS_INLINE_SEED_BUDGET_MS`, default `8000`); when it runs long the kernel
starts anyway and the rest of the seed finishes **in the background** — so the
banner, and anything that waits for it, can be a minute ahead of the seed's own
result. On a machine where the seed fits its budget the same command settles
before the banner. Both are normal, and which one you get depends on the box.

So a script that spawns a dev server and wants to act **after the boot has come
to rest** should not wait on the banner, and should not need to read the child's
output at all. Spawn with an `ipc` channel and wait for a message:

| Message | Sent by | Means |
|---|---|---|
| `objectstack:listening` | `os serve` | The HTTP server is bound. Carries `{ port, url }` — the port actually bound, which in dev may differ from the one requested. |
| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`. |

```js
import { spawn } from 'node:child_process';

const child = spawn('os', ['dev'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });

child.on('message', (msg) => {
if (msg?.type !== 'objectstack:seed-settled') return;
if (msg.suppressed.length > 0) {
console.log(`boot complete — seeds not run this boot (${msg.suppressed.join(', ')})`);
} else if (!msg.ok) {
console.log('boot complete — but some seed records did not land; see the log above');
} else {
console.log('boot complete — the app is ready to use');
}
});
```

**`os dev` spawns `os serve`, and the two channels are not symmetric.** `os dev`
consumes `objectstack:listening` itself — it is how the `↪ server bound to port`
line and the MCP connect hint learn the real port — and does **not** relay it.
It forwards `objectstack:seed-settled` to its own parent verbatim. Spawn
`os serve` directly if you need both messages in one place.

An `ipc` channel is optional: without one, both sends are no-ops and nothing
about the command changes. There is no polling to do — if you did not open the
channel, the messages simply are not sent.

<Callout type="info" title="What `objectstack:seed-settled` promises, and what it does not">
It is sent when **nothing is still writing** — on success *and* on failure, since
a seed that failed has still come to rest. Read `ok` together with `sources`
rather than alone: `ok` is a verdict on the per-source counts the boot recorded,
and a source that finished by throwing may record no counts at all.

`suppressed` is non-empty when this boot registered a seed source and
deliberately never ran it — `multi-tenant-replay` (rows are written per
organization on `sys_organization` insert) or `skip-seed-data` (a planning boot
that writes nothing). Those sources never settle and no further signal is
coming for them, which is exactly why the message is sent anyway with the reason
attached: a consumer that waited for *every* source to finish would wait
forever.
</Callout>

#### `os serve`

Starts the ObjectStack server with automatic plugin discovery:
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/commands/dev-seed-settled-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { forwardSeedSettledToParent } from './dev.js';

/**
* #17329 — `os dev` relays the `serve` child's settle announcement to its OWN
* parent, and does nothing at all when no parent holds the channel.
*
* ## Why the hop is the card
*
* The producer already exists and is published: `@objectstack/runtime` declares
* every seed source and settles it at the moment its boot-time write is done,
* under the spec's `seed-settlement` contract. `serve` now announces that on the
* ipc channel. But the consumer — a demo script, a test harness, anything that
* spawns a dev server and wants to print one line after the boot — spawns
* `os dev`, not `serve`; `os dev` runs the child over
* `stdio: ['inherit','inherit','inherit','ipc']`, so without this the message
* lands in the middle process and stops. One hop is the whole of what was
* missing.
*
* ⚠️ Under vitest's `forks` pool `process.send` is the RUNNER's own control
* channel. Every swap below is synchronous, spans one call, and is undone in
* `finally` — a real message must never reach it.
*/
describe('#17329 `os dev` forwards `objectstack:seed-settled` outward', () => {
/** Drive `fn` with `process.send` replaced by a recorder. */
const recording = (fn: () => void): unknown[] => {
const sent: unknown[] = [];
const prior = process.send;
(process as { send?: unknown }).send = (m: unknown) => { sent.push(m); return true; };
try { fn(); } finally { (process as { send?: unknown }).send = prior; }
return sent;
};

/** Drive `fn` with NO ipc channel — the ordinary terminal `os dev`. */
const withoutChannel = <T>(fn: () => T): T => {
const prior = process.send;
(process as { send?: unknown }).send = undefined;
try { return fn(); } finally { (process as { send?: unknown }).send = prior; }
};

const settled = {
type: 'objectstack:seed-settled',
ok: false,
suppressed: [],
sources: [{ source: 'showcase', inserted: 24, updated: 0, skipped: 0, rejected: 14 }],
};

it('relays the message VERBATIM, not a re-derivation of it', () => {
// ⛔ This process has no kernel and could only guess. Passing the object
// through is what keeps `os dev`'s parent and the `serve` child from being
// made to say two different things about one boot.
const sent = recording(() => {
expect(forwardSeedSettledToParent(settled)).toBe(true);
});
expect(sent).toEqual([settled]);
expect(sent[0], 'the message was rebuilt rather than relayed').toBe(settled);
});

it('⛔ a parent with no ipc channel is UNAFFECTED — no throw, no send', () => {
// An ipc channel must not become a requirement of running a published
// command. `process.send` is undefined under a terminal `os dev`.
withoutChannel(() => {
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
expect(forwardSeedSettledToParent(settled), 'the message is still HANDLED here').toBe(true);
});
});

it('survives a parent channel that has already closed', () => {
// Best-effort, exactly like the child's own `announceListening`: a
// supervision nicety must never take a healthy dev server down.
const prior = process.send;
(process as { send?: unknown }).send = () => { throw new Error('channel closed'); };
try {
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
} finally {
(process as { send?: unknown }).send = prior;
}
});

describe('⛔ and it claims ONLY its own message', () => {
it.each([
['the listening announcement', { type: 'objectstack:listening', port: 3001, url: 'http://localhost:3001' }],
['an unrelated type', { type: 'something:else' }],
['no type at all', { port: 3001 }],
['null', null],
['undefined', undefined],
['a string', 'objectstack:seed-settled'],
])('%s is left to the caller', (_label, msg) => {
// Returning `true` here would swallow `objectstack:listening` and take
// the bound-port readout and the MCP connect hint down with it.
const sent = recording(() => {
expect(forwardSeedSettledToParent(msg)).toBe(false);
});
expect(sent, 'a message that is not ours was forwarded anyway').toEqual([]);
});

it('…and the positive control on the same path still fires', () => {
// So the zeros above are readings rather than a function that forwards
// nothing at all.
const sent = recording(() => { forwardSeedSettledToParent(settled); });
expect(sent).toHaveLength(1);
});
});
});
48 changes: 48 additions & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,50 @@ export function printMcpConnectHint(
console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false'));
}

/**
* The hop outward: relay the `serve` child's `objectstack:seed-settled`
* announcement to `os dev`'s OWN parent (#17329).
*
* ## Why the hop exists at all
*
* `os dev` is a spawner. It runs `serve --dev` over
* `stdio: ['inherit','inherit','inherit','ipc']`, so the child's settle
* announcement lands HERE and stops — while the consumer that needs it (a demo
* script, a test harness, anything that spawns `os dev` and wants to print one
* line after the boot) holds a channel to `os dev`, not to a grandchild process
* it did not start and cannot name. One hop is the whole of the missing piece:
* the producer already exists, and the child already announces.
*
* ## Relayed verbatim, deliberately
*
* ⛔ Nothing here re-derives, re-summarises or re-grades the message. The child
* read the settlement tally off the kernel that did the seeding; this process
* has no kernel and could only guess. Passing the object through means `os
* dev`'s parent and the `serve` child can never be made to say two different
* things about one boot — the same rule the `MCP:` row above follows for the
* origin, and for the same reason.
*
* ## An IPC channel stays OPTIONAL for this command
*
* ⛔ A parent that holds no channel must be unaffected, and is: `process.send`
* is `undefined` under an ordinary terminal `os dev`, so this returns having
* done nothing, printed nothing, and changed no byte of that transcript. The
* `serve` child's own `announceListening` is best-effort for exactly this
* reason and this is its mirror — ⛔ this message does not make an IPC channel
* a requirement of running a published command.
*
* @returns `true` when the message was a settle announcement (handled here, and
* the caller should stop) — `false` for every other message, which the
* caller's own branches still own.
*/
export function forwardSeedSettledToParent(msg: unknown): boolean {
if ((msg as { type?: unknown } | null | undefined)?.type !== 'objectstack:seed-settled') return false;
try {
if (typeof process.send === 'function') process.send(msg);
} catch { /* the parent's channel closed — best-effort, exactly like the child's */ }
return true;
}

export default class Dev extends Command {
static override description =
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
Expand Down Expand Up @@ -566,6 +610,10 @@ export default class Dev extends Command {
// its HTTP server is up. We surface it so the printed URL is correct
// even when the port was auto-shifted (e.g. 3000 busy → 3001).
child.on('message', (msg: any) => {
// #17329 — the hop outward. Handled first and exclusively: a settle
// announcement carries no port and has nothing to do with the block
// below. See {@link forwardSeedSettledToParent}.
if (forwardSeedSettledToParent(msg)) return;
if (msg?.type === 'objectstack:listening' && msg.port) {
const actual = String(msg.port);
if (actual !== requestedPort) {
Expand Down
Loading
Loading