Repository navigation
Commit 161aee1
fix(cli): a serve boot that binds no HTTP server announces no address, and MCP: prints only when its route is mounted (#23947)
Fixes #23743
Clause-②: no
## What this changes
This is the remainder of #23623 / PR #23741. That PR gated the ready
banner's address rows on a composed HTTP server. It left two things as
they were, and this PR fixes both.
**1. The machine channels on a boot that binds no HTTP server.** `os
serve --no-server`, with no transport composed, sent `{ type:
'objectstack:listening', port, url }` and wrote
`runtime.ENV.PROJECT.json` with the same `port` and `url`. Nothing
listened on that port. The docs row (`content/docs/deployment/cli.mdx`)
says: "`objectstack:listening` | `os serve` | The HTTP server is bound."
- **`packages/cli/src/commands/serve.ts`, the publish site.**
`publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner),
boundProtocol)` now runs only when `httpServer === 'composed'`.
Otherwise only the banner prints. `httpServer` is the
`resolveHttpServerComposition(kernel, flags.server)` value the banner's
address rows already read. One predicate, read once, decides all three
channels, so the banner, the IPC message and the state file cannot
disagree about whether there is an address. `publishBoundPort`, its
three channels and their order are unchanged. `resolveBoundPort` is
unchanged too: it still falls back to the requested port, and the
publish site no longer publishes that fallback.
- **`objectstack:seed-settled` on that boot: still sent, and it is the
only message.** Consumers measured in this repository: the `os dev`
parent (`dev.ts`, read, not edited) relays it and never spawns a
`--no-server` child, because `os dev` has no `server` flag. The docs
example waits for `seed-settled` alone. `SeedSettledMessage`'s own rule
is that a waiting parent must never hang. A kernel-only boot still
seeds, so dropping the message would hang exactly the parent that rule
protects. Both docs rows are updated together. The listening row now
says the message is not sent by a boot that binds no HTTP server. The
seed-settled row keeps "always after `objectstack:listening`" and adds
that on such a boot it is still sent and is the only message.
- **The runtime state file on that boot: not written.** Readers measured
in this repository: `scripts/publish-smoke.sh` (reads `.port`, then
curls `/api/v1/health`; it spawns `objectstack dev`, which never runs
`--no-server`), plus the CLI's own tests
(`serve-bound-port-publication.test.ts`,
`test/serve-bound-port-publish-order.test.ts`,
`test/serve-publishes-bound-port.e2e.test.ts`,
`test/serve-runtime-state-project-key.test.ts`,
`test/helpers/runtime-state-child.ts`). No docs page states a contract
for the file. I chose "not written" over "written with no URL or an
unbound marker" for three reasons. The file's whole payload answers "is
my server running, and where?", and `runtimeStateFileName`'s docblock
already calls a missing file "a loud, correct answer". An unbound record
would add a second payload shape that every reader would have to handle;
`publish-smoke.sh`'s `jq '.port | numbers'` would read it as "no port
yet" and probe until its timeout. And the payload stays the shape the
project-key ruling (`serve.ts`, `projectStateKey`) chose not to widen.
- `os start` never passes `--no-server`, and `os dev` never does either,
so every in-repo consumer of these two channels sees the same bytes as
before.
**2. The banner's `MCP:` row under a config-mounted server.** Under
`--no-server`, with a config that mounts its own `HonoServerPlugin`, the
banner printed `MCP: http://localhost:PORT/api/v1/mcp` and its connect
hint, but `GET /api/v1/mcp` answered 404. The plugin that mounts that
route is the runtime dispatcher, `createDispatcherPlugin`, and `serve`
composes it only inside `if (flags.server)`.
- **`serve.ts`: `readComposedMcpRouteOwner(kernel)`** asks the booted
kernel `hasPlugin('com.objectstack.runtime.dispatcher')`. This is a
kernel read like `readComposedHttpServer`, so a config that mounts its
own dispatcher is answered as well. The dispatcher registers no service;
it only consumes `http.server`. So the kernel's plugin registry is the
read, the same one `HonoServerPlugin` already asks before it warns that
no data or discovery API is mounted. The banner gets `mcpEnabled:
isMcpServerEnabled() && mcpRouteMounted`, where `mcpRouteMounted =
httpServer === 'composed' && readComposedMcpRouteOwner(kernel)`. If the
kernel cannot answer, the result is `false`.
- **`packages/cli/src/utils/format.ts`**: doc comment only, on the
`mcpEnabled` field the `MCP:` row reads, saying what `serve` now passes.
`printServerReady` is unchanged.
⛔ Not changed: exports of `@objectstack/cli`, flags, exit codes, process
lifetime, `packages/spec`, `packages/runtime`, `dev.ts`.
## Measured at the door
Each boot runs `node packages/cli/bin/run.js serve objectstack.config.ts
--port PORT ARGS` on the built CLI, spawned with an `ipc` channel.
`NODE_ENV` is unset except under `--dev`, and `OS_CLOUD_URL=off`. The
fixture is a throwaway host config with one object and one plugin
instance. Before is `7a6ba03109`, after is `cccb00ea51`. "State file"
means `OS_HOME/runtime.env_local.PROJECT.json` as read when the
listening message arrived.
| Shape | IPC before | IPC after | State file before / after | Banner
address rows after | Probe (before and after) |
|---|---|---|---|---|---|
| production `--no-server --preset minimal` (the card's command) |
`objectstack:listening` with `url: 'http://localhost:38393'`, then
`seed-settled` | `seed-settled` only | written with that `url` / none |
`No HTTP server: …` line (unchanged from #23623) | `ECONNREFUSED` on
`/`, `/api/v1/health`, `/api/v1/mcp` |
| `--dev --no-server` | listening, then `seed-settled` | `seed-settled`
only | written / none | the same line | `ECONNREFUSED` |
| production default (control) | listening with the bound port, then
`seed-settled` | unchanged | written / written, same `port` and `url` |
`API:`, `MCP:` and the hint (unchanged) | `/api/v1/health` 200,
`/api/v1/mcp` 401 |
| `--no-server --preset minimal` + config-mounted `HonoServerPlugin` |
listening, then `seed-settled` | unchanged: a transport is bound |
written / written | `API:` only; before: `API:`, `MCP:` and the hint |
`/api/v1/mcp` 404, `/api/v1/data/fx_task` 401 |
| default preset + config-mounted `HonoServerPlugin`, `--server`
(control) | not measured before | listening, then `seed-settled` | — /
written | `API:`, `MCP:` and the hint | `/api/v1/health` 200,
`/api/v1/mcp` 401 |
## Tests
- **`src/commands/serve-no-transport-announcements.test.ts`** (new,
unit, 10 cases).
- `readComposedMcpRouteOwner` against a real `ObjectKernel`: `true` with
`createDispatcherPlugin()` registered, and `false` with a transport-only
composition.
- `MCP_ROUTE_OWNER_PLUGIN` is read off `createDispatcherPlugin().name`,
so a rename of the dispatcher reds here instead of silently dropping the
row.
- It asks for exactly that name, and answers `false` for no kernel, no
`hasPlugin`, a throwing `hasPlugin`, and a non-boolean answer.
- The `run()` wiring, with comments masked by
`scripts/js-comment-mask.mjs`: the publish call sits in the `httpServer
=== 'composed'` arm with `printBanner()` alone in the other arm;
`httpServer` is resolved once and is what the banner reads; the
`mcpEnabled` / `mcpRouteMounted` spelling is pinned; the settle latch is
released once, after the gated publication on both arms. An anti-vacuity
case checks that the mask blanked comments.
-
**`src/commands/serve-no-transport-announcements.integration.test.ts`**
(new). `vitest-tiers.ts` puts it in the integration tier. It runs three
boots of the built CLI, each with an `ipc` channel and an `OS_HOME`
directory watched from before the spawn.
- Production `--no-server`: no `objectstack:listening`; the messages are
exactly `['objectstack:seed-settled']`; and no `runtime.*.json` was ever
created. The watch matters: this shape exits on its own and its exit
handler deletes the file, so a listing taken afterwards cannot tell
"never written" from "cleaned up".
- Control, default boot: listening and then seed-settled; the message
equals `{ port, url }` of the `API:` row; the state file present at that
moment carries the same `port` and `url`; an `MCP:` row prints; health
answers 200 and `/api/v1/mcp` answers something other than 404.
- Config-mounted Hono under `--no-server`: no `MCP:` row and no connect
hint; `/api/v1/mcp` answers 404 (the reason the row must not print); the
IPC message and the state file both carry the `API:` address.
- The three no-transport assertions are `expect.soft`, so each channel
reports on its own. The file declares `// test-budget: 30`: the three
local runs took 19.4 s, 18.9 s and 20.9 s on a shared container; 20.9 ×
1.25, rounded up.
- **Unit tier**, `pnpm --filter @objectstack/cli exec vitest run
--project unit --maxWorkers=2 --shard=K/4`. It is sharded because one
unsharded run does not fit the container's foreground cap. Shards 1 to 3
ran on `23b3033ffc` (this branch merged with `origin/main`); shard 4
also ran there, and its one failure was fixed in `cccb00ea51`.
- shard 1: 76 files, 1418 tests passed.
- shard 2: 76 files, 1099 passed and 3 failed. All 3 were `Test timed
out in 5000ms` while my unlocked gate battery shared the cores. The two
files (`src/utils/boot-preparation-parity.test.ts`,
`test/hook-timeout-override-refusal.test.ts`) re-run alone: 2 files, 41
tests passed.
- shard 3: 76 files, 918 passed.
- shard 4: 75 files, 1071 passed and 1 failed.
`serve-observability-registration.test.ts` finds the transport by the
first raw-source occurrence of the `HonoServerPlugin` constructor call,
and my new docblock spelled that call above the registration.
`cccb00ea51` rewords the docblock.
- **On the final head `cccb00ea51`**, after rebuilding the CLI:
- six unit files (`serve-observability-registration`, the new unit file,
`serve-bound-port-publication`, `serve-http-server-composition`,
`format.server-ready-no-http-server`,
`test/serve-bound-port-publish-order`): 6 files, 82 tests passed;
- integration, scoped (the diff adds an integration file and edits
`serve.ts`): the new file, `serve-no-server-banner.integration`,
`test/serve-no-storage-boot-guard.integration`,
`test/dev-boot-output-order.integration`: 4 files, 15 tests passed;
- `OS_TEST_TIERS=nightly`, `test/serve-publishes-bound-port.e2e.test.ts`
(the publication's own end-to-end pin, which runs nightly and not on
this PR): 1 file, 6 tests passed;
- the rest of the integration tier is declared to CI.
## Ablation
The fix was committed first. Each leg went through `node
scripts/ablation-replace.mjs` in wrap mode (it restores on exit, and a
second `trap` restores from `HEAD`). Then the CLI was rebuilt, `node
scripts/ablation-dist-preflight.mjs @objectstack/cli MARKER` was run,
and the two new files were run.
- **The publish gate.** The anchor `if (httpServer === 'composed') {`
was replaced with an always-true condition carrying the marker
`ablation-23743-gate`.
- On disk: anchor x1 to x0, marker x0 to x1, blob `8de5619d0a58` to
`f04d083f4255`. The marker was present in 1 built file
(`dist/commands/serve.js`).
- Result: `Tests 3 failed | 10 passed (13)`. Integration case 1 reported
all three soft assertions: "objectstack:listening was sent by a boot
that bound no HTTP server", "seed-settled is not the boot's one
message", "a runtime state file was written by a boot that bound no HTTP
server". So the watched-directory read does see a file that the exit
handler later deletes. The two wiring source pins also failed. Both
controls passed.
- Direction: turned red, as predicted.
- Restore: blob back to `8de5619d0a58`, which is HEAD; `git diff HEAD`
empty; `git status --porcelain` empty. After a rebuild, `--absent` found
the marker in none of the 640 built files. `Tests 13 passed (13)`.
- **The `MCP:` gate.** `mcpEnabled: isMcpServerEnabled() &&
mcpRouteMounted,` was replaced with an always-true right operand
carrying `ablation-23743-mcp`.
- On disk: anchor x1 to x0, blob `8de5619d0a58` to `630307660e27`; the
marker was present in 1 built file.
- Result: `Tests 2 failed | 11 passed (13)`: "an MCP: row printed for an
unmounted route" (Hono case) and the `mcpEnabled` source pin. The
no-transport case and the default control passed.
- Direction: turned red, as predicted.
- Restore: proven the same way; `--absent` in all 640 files; `Tests 13
passed (13)`.
The ablations ran against the `serve.ts` blob `8de5619d0a58`, which is
unchanged from `99c982b4cd` through `23b3033ffc`. `cccb00ea51` changes
only a docblock in it.
## Gates
All on the final head `cccb00ea51`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derives 96 commands. Its
stderr names the tree as `objectstack-ai/objectstack` at `cccb00ea51`,
with 6 paths changed against merge base `944f03850`. Each command was
run as `cmd > log 2>&1; ec=$?`, and all 96 exited 0.
- On the first pass (on `23b3033ffc`), three gates exited 3 with
`PREREQUISITE NOT MET`: `spec check:skill-examples`,
`check:dual-build-cjs-loads` and `check:i18n-coverage`. The packages
they read had no `dist/`: studio, client-react, connector-slack, the
example apps and others outside the CLI's closure. After `turbo run
build` of those (75 tasks, exit 0), all three exit 0 on `cccb00ea51`.
- `--ran ran.list` prints: `96 derived famil(ies) accounted for — 96
run, 0 NOT-MEASURED (a DERIVED zero — all 96 recorded an exit code and
none of them is 3)`.
- `pnpm --filter @objectstack/cli typecheck` exits 0: `tsc --noEmit`,
then `check:test-typecheck: OK`. `tsc -p tsconfig.json --noEmit
--listFilesOnly` lists both new files (2 of 2).
- Lint lane: `pnpm exec eslint --no-inline-config` over the four changed
`.ts` files exits 0 with no output. The repo-wide `pnpm lint` is CI's.
- Not run locally, by rule: `check:dev-prereqs` and
`check:skill-frame-freshness` (no `packages/spec` build or frame file is
touched), and `check-platform-checklist.mjs --changed-from` (no exported
symbol is renamed or removed; two are added).
- CI check-runs: not read yet; they start when this PR opens.
## Acceptance notes
- **Process lifetime (#23744, not touched here).** Measured on base
`7a6ba03109` and after `cccb00ea51`: production `--no-server --preset
minimal` prints its banner about 5.1 s to 5.8 s after spawn, and then
exits 0 by itself within milliseconds, with or without an `ipc` channel.
`seed-settled` arrives before that exit in every run measured. `--dev
--no-server` stays up until stopped. This PR changes neither.
- **A stale state file is no longer overwritten by a no-transport
boot.** If an earlier boot of the same project died without running its
exit handler (SIGKILL, a crash), its `runtime.ENV.PROJECT.json`
survives. Before, a later `--no-server` boot overwrote it with its own
record, carrying a URL nothing listened on. Now that boot writes
nothing, so the stale record stays, naming the dead `pid`. A reader that
checks `pid` liveness is unaffected. The file is documented as
best-effort and was never cleaned up on a hard kill. Observation only;
no carrier.
- **`start.ts` comment.** A comment in
`packages/cli/src/commands/start.ts` says `serve` publishes
`objectstack:listening` "unconditionally". It is now conditional on a
composed transport. `os start` never passes `--no-server`, so for its
child the sentence still holds in practice. It is outside this card's
file surface and is left as is. Observation only.
- **Dispatcher prefix.** A config that mounts its own
`createDispatcherPlugin({ prefix })` with a non-default prefix gets an
`MCP:` row naming `/api/v1/mcp` whatever the prefix. `serve`'s own
dispatcher always uses the default. No in-repo producer of that shape
was found, and the behaviour is unchanged by this PR. Observation only.
- **`readComposedMcpRouteOwner` answers "is the route's owner composed",
not "is the route mounted".** The dispatcher mounts `/api/v1/mcp`
unconditionally in its `start()` once an `http.server` is registered,
and the row also requires a composed transport. So with today's
dispatcher the two are the same. No live route table exists on
`IHttpServer` to read instead.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 61e5ee9 commit 161aee1
6 files changed
Lines changed: 655 additions & 12 deletions
File tree
- .changeset
- content/docs/deployment
- packages/cli/src
- commands
- utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
286 | 286 | | |
287 | 287 | | |
288 | 288 | | |
289 | | - | |
290 | | - | |
| 289 | + | |
| 290 | + | |
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
| |||
0 commit comments