Skip to content

Commit acab609

Browse files
os-litantclaude
andauthored
fix(cli): key the runtime state file by project, not by environment alone (#15968)
* fix(cli): key the runtime state file by project, not by environment alone `os serve` publishes `{ pid, port, url, environmentId, startedAt }` under the ObjectStack home so a supervisor can answer "is my server running, and where?". The file was `runtime.<environment>.json`, and both halves of where it lived are machine-global: `resolveObjectStackHome()` takes no arguments, and an environment id is not a project identity. Two projects on one machine, both in the ordinary `local` environment, wrote one file. Driven with two real boots, two project roots and one home — two failures, one cause: * project B's boot replaced project A's record, so a reader asking about A was answered with B's pid/port/url while A's server was still alive elsewhere; * project A's shutdown then deleted the file that by then described B, leaving a running server with no supervision record at all. The file is now `runtime.<environment>.<project>.json`, keyed on the served app's root — the root `serve` already resolves for host-anchored loads. The payload is unchanged: no new key, and no database path (issue 15374 ruled that one out deliberately, because it would turn a best-effort supervision file into an identity contract). The three in-repo readers that spelled the old name out now ask the writer's own naming function instead, so a second copy of the rule cannot drift from it. `scripts/publish-smoke.sh` globs `runtime.*.json` in a home it pins itself and is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): build the collision children's env through `childEnv`, not a bare spread `check:cli-test-child-env` is the gate that keeps `packages/cli/test`'s spawners off `{ ...process.env }`: vitest sets `TEST`, `VITEST` and the `VITEST_*` family on its worker, and a child inheriting them boots with a different auth and crypto posture than the one under test. The new collision pin spawned its children with a bare spread and the gate's shrink-only ratchet named it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(cli): carry the runtime-state rename's breaking-ness in the changeset The launch window refuses `major`, so the bump level is not the carrier: a breaking change ships as `minor` and its only signals are the BREAKING banner in the changeset body and the ADR-0087 disposition marker that `check-adr-0087-registration.mjs` re-verifies. This diff retires a shipped observable -- the on-disk name a supervisor opens -- and the changeset said so in its own prose while carrying neither signal. Adds both. The disposition is `not-required (no-migration-prescription)`, derived from the gate's own `CATEGORIES` const rather than its prose header: nothing authorable moves, `packages/spec` is untouched, and what is retired is a path on disk rather than a metadata surface the ledger can project into `spec-changes.json` or the upgrade guide. `unpublished` is false (the package publishes), `already-registered` names no entry that covers this, and neither `runtime-interface-only` nor `type-surface-only` has a `path#Symbol` to name. Also ends the two names `serve.ts` was stating at once. The `BoundPortChannels.writeRuntimeState` interface doc and the four other current-tense sites named the retired file; they now name the shipped one, while the measured ENOENT quote and the pre-repair collision diagram keep the name they observed and say that is what it was. The anchoring boundary an out-of-tree reader has to replicate -- the app root is the config's own directory only when that config exists and that directory carries a `package.json`, else the CWD -- is stated beside the symlink boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e35765 commit acab609

7 files changed

Lines changed: 566 additions & 27 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record.
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, `packages/spec` is untouched, and `objectstack migrate meta` has nothing to rewrite. What is retired is the NAME of a best-effort supervision file that `os serve` writes under the ObjectStack home — a path on disk, not a metadata surface the ledger can project into `spec-changes.json` or the generated upgrade guide. The affected party is an out-of-tree supervisor that opens that path, and the one action it takes is deleting a single stale file; there is no authored artifact for a metadata upgrader to rewrite, and no ledger entry could reach the party that is affected. -->
8+
9+
**BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime.<environment>.json` and is now named `runtime.<environment>.<project>.json`.
10+
11+
`os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime.<environment>.json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file.
12+
13+
Driven with two real boots, two project roots and one home, that produced two failures with one cause:
14+
15+
- project B's boot replaced project A's record, so a reader asking about A's server was answered `pid`/`port`/`url` belonging to **B** — confidently, while A's own server was still alive and still listening elsewhere;
16+
- project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all.
17+
18+
The file is now `runtime.<environment>.<project>.json`, where the project component is a sanitised basename plus a short digest of the served app's root — the same root `serve` already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract).
19+
20+
**If you read this file:** a reader that hard-codes `runtime.<environment>.json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime.<environment>.json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once.
21+
22+
**Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent.
23+
24+
Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here.

packages/cli/src/commands/serve-bound-port-publication.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
publishBoundPort,
5151
resolveBoundPort,
5252
runtimeBoundPortChannels,
53+
runtimeStateFileName,
5354
type BoundPortChannels,
5455
} from './serve.js';
5556
import { MAX_PORT } from '../utils/port-contract.js';
@@ -287,7 +288,12 @@ describe('#13062 all THREE channels publish that one number', () => {
287288
publishBoundPort(45063, runtimeBoundPortChannels(() => { /* banner not under test here */ }));
288289
});
289290

290-
const runtimeFile = join(home, 'runtime.env_local.json');
291+
// ⛔ Not the literal `runtime.env_local.json`: the name is keyed by the
292+
// PROJECT as well as the environment (#15733), and a literal here would be
293+
// a second copy of that rule — free to keep passing against a writer that
294+
// had drifted off it. `runtimeBoundPortChannels` reached outside a boot
295+
// anchors on the CWD, which is this runner's.
296+
const runtimeFile = join(home, runtimeStateFileName('env_local', process.cwd()));
291297
expect(existsSync(runtimeFile), 'no runtime state file was written at all').toBe(true);
292298
const state = JSON.parse(readFileSync(runtimeFile, 'utf8'));
293299
expect(state.port, 'the state file does not publish the bound port').toBe(45063);

packages/cli/src/commands/serve.ts

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { isEmailTransportProvider, emailProviderRequiresApiKey, unsupportedProvi
4747
// bare `import()` resolved against this CLI's own realpath, so its bundled
4848
// copy wins, never the host's (#10909).
4949
import { isSmsTransportProvider, SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms';
50+
import { createHash } from 'node:crypto';
5051
import { resolveObjectStackHome } from '@objectstack/runtime';
5152
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
5253
// The ONE port contract — range, reader and refusal prose — shared with the two
@@ -391,9 +392,9 @@ export function formatExhaustedPortSearchNotice(requestedPort: number, cause: un
391392
* `serve` used to publish the port it was ASKED for on all three of the
392393
* channels that ANNOUNCE an address — the `objectstack:listening` IPC message,
393394
* the ready banner's `API:` row (through {@link resolveAuthBaseUrl}), and
394-
* `runtime.<environment>.json`. For every port but one the requested and the
395-
* bound value coincide, which is why it stayed invisible; for `0` they CANNOT
396-
* coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
395+
* `runtime.<environment>.<project>.json`. For every port but one the requested
396+
* and the bound value coincide, which is why it stayed invisible; for `0` they
397+
* CANNOT coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
397398
* so in its own words, from its own measurement, that 0 is "a REQUEST, not an
398399
* error", and `listen(0)` binds a kernel-assigned port. So `os serve --port 0`
399400
* announced `{ port: 0 }`, printed `API: http://localhost:0/` and wrote
@@ -459,8 +460,8 @@ export interface ListeningMessage {
459460
*/
460461
export interface BoundPortChannels {
461462
/**
462-
* Writes `runtime.<environment>.json`. ⛔ Must COMPLETE before either
463-
* announcement below: it is the file both of them send a consumer to.
463+
* Writes `runtime.<environment>.<project>.json`. ⛔ Must COMPLETE before
464+
* either announcement below: it is the file both of them send a consumer to.
464465
*/
465466
writeRuntimeState: (published: { port: number; url: string }) => void;
466467
/** Sends {@link ListeningMessage}, when an IPC channel is open. */
@@ -475,14 +476,14 @@ export interface BoundPortChannels {
475476
* ## The bug this shape exists to make impossible
476477
*
477478
* `os serve` announces its address on three channels: the runtime state file
478-
* `runtime.<environment>.json`, the `objectstack:listening` IPC message, and
479-
* the ready banner. Two of those are ANNOUNCEMENTS a consumer reacts to; the
480-
* third is the FILE those consumers then open. Published in the order they
479+
* `runtime.<environment>.<project>.json`, the `objectstack:listening` IPC
480+
* message, and the ready banner. Two of those are ANNOUNCEMENTS a consumer
481+
* reacts to; the third is the FILE those consumers then open. Published in the order they
481482
* happened to be written — banner, IPC, file — every consumer that believes an
482483
* announcement races a file that is not there yet:
483484
*
484485
* ```text
485-
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.json
486+
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.<project>.json
486487
* IPC ─▶ the `os dev` parent sees the port
487488
* file ─────────────────────────▶ ...written here. The ENOENT already happened.
488489
* ```
@@ -491,10 +492,11 @@ export interface BoundPortChannels {
491492
* claim (`serve-publishes-bound-port.e2e.test.ts`) is an ORDINARY consumer — it
492493
* waits for the banner AND the IPC message, then reads the file — and it
493494
* ejected 14 PRs from the shared merge queue in a rolling 24 hours (10
494-
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json`. A real
495-
* supervisor written the same way loses the same race; all a loaded machine
496-
* does is deschedule the child between the announcement and the write, which is
497-
* why it read as a flake for a day.
495+
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json` — the
496+
* name that file carried then, before {@link runtimeStateFileName} keyed it by
497+
* project as well. A real supervisor written the same way loses the same race;
498+
* all a loaded machine does is deschedule the child between the announcement
499+
* and the write, which is why it read as a flake for a day.
498500
*
499501
* ⛔ The repair is NOT to make the reader poll. A consumer that must poll after
500502
* being told "ready" was told "ready" too early — polling spreads the defect
@@ -521,6 +523,92 @@ export function publishBoundPort(boundPort: number, channels: BoundPortChannels)
521523
channels.printBanner();
522524
}
523525

526+
/**
527+
* The identity a runtime state file is keyed by: the ROOT OF THE PROJECT this
528+
* process is serving, folded into one filename-safe component.
529+
*
530+
* ## The collision this ends, DRIVEN rather than reasoned (#15733)
531+
*
532+
* The state file used to be `runtime.<environment>.json`, and both halves of
533+
* that name are machine-global. {@link resolveObjectStackHome} takes NO
534+
* arguments — it reads `OS_HOME`, else `~/.objectstack` — and an environment
535+
* id is not a project identity, so two different projects on one machine, both
536+
* in the ordinary `local` environment, wrote ONE file. Two real boots, two
537+
* project roots, one home:
538+
*
539+
* ```text
540+
* project A boots → runtime.env_local.json = { pid 9301, port 42693 }
541+
* project B boots → runtime.env_local.json = { pid 9345, port 46175 }
542+
* A asks "is my server running, and where?"
543+
* → answered pid 9345 on :46175 — which is B — while A's own
544+
* server is still alive and still listening on 42693.
545+
* A shuts down → its exit handler deletes the file that by then described
546+
* B, so B serves on with no supervision record at all.
547+
* ```
548+
*
549+
* Those are one defect seen twice: the NAME carried no answer to *whose*
550+
* server this is, so every project addressed the same file. Keying it by the
551+
* served app's root gives each project its own — a reader that finds a file
552+
* has found its own, and a process cleaning up on exit removes only its own.
553+
*
554+
* ⛔ NOT repaired by putting an identity in the PAYLOAD instead. #15374 ruled
555+
* that one deliberately: the identity of the file a process is serving is a
556+
* property of THAT PROCESS, and a payload key would turn a best-effort
557+
* supervision file into an identity contract while STILL leaving two projects
558+
* overwriting and deleting each other's records. The key is the defect; the
559+
* payload is unchanged here.
560+
*
561+
* ## Why the name carries a readable half as well as a digest
562+
*
563+
* The digest is what makes the name unique; the slug is what makes a home
564+
* directory legible to whoever is standing in front of it. A directory
565+
* answering `runtime.env_local.<12 hex>.json` twice tells a reader nothing
566+
* about which one is theirs, and answering that question is this file's whole
567+
* job. The slug is DERIVED, never trusted: uniqueness rests on the digest
568+
* alone, so a project root whose basename sanitises away to nothing is still
569+
* keyed correctly — it just reads as the digest.
570+
*
571+
* ⚠️ Boundary, stated rather than fixed: the key is the resolved path, not the
572+
* realpath, so two symlinked spellings of ONE project key differently (each
573+
* spelling gets its own file, and each is internally consistent). Two boots of
574+
* the SAME project also still share a file — that is the same-project case,
575+
* which is #15374's in-process watch, not this one.
576+
*
577+
* ⚠️ Second boundary, and the one an OUT-OF-TREE reader has to replicate to
578+
* find the record: WHICH root this is handed is {@link servedAppRootOrCwd},
579+
* which {@link anchorServedApp} sets to the CONFIG'S OWN DIRECTORY only when
580+
* that config exists and that directory carries a `package.json`, and to
581+
* `process.cwd()` otherwise. So one app served from two working directories
582+
* with no manifest beside its config keys TWO files, and a supervisor that
583+
* reconstructs the name out of tree has to apply that same rule rather than
584+
* assume the config's directory. That fallback is #11185's and is deliberate:
585+
* a directory that declares nothing is not anchored at, because anchoring
586+
* there could only turn a working boot into an `undeclared` refusal.
587+
*/
588+
export function projectStateKey(servedAppRoot: string): string {
589+
const absolute = path.resolve(servedAppRoot);
590+
const digest = createHash('sha256').update(absolute).digest('hex').slice(0, 12);
591+
const slug = path.basename(absolute).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
592+
return slug.length > 0 ? `${slug}-${digest}` : digest;
593+
}
594+
595+
/**
596+
* The runtime state file's name: `runtime.<environment>.<project>.json`.
597+
*
598+
* Both components are identities the file has to carry, and neither is
599+
* sufficient alone — the environment id keeps a staging boot from answering
600+
* for a local one, and {@link projectStateKey} keeps ANOTHER PROJECT's local
601+
* boot from answering for this one.
602+
*
603+
* ⚠️ A reader that hard-codes the old `runtime.<environment>.json` now gets
604+
* ENOENT rather than a stale or foreign record. That is the intended trade: a
605+
* missing file is a loud, correct answer to "is my server running", where the
606+
* name it replaces could only give a confident wrong one.
607+
*/
608+
export function runtimeStateFileName(environmentId: string, servedAppRoot: string): string {
609+
return `runtime.${environmentId}.${projectStateKey(servedAppRoot)}.json`;
610+
}
611+
524612
/**
525613
* The real channels: the same three writes this command has always done, with
526614
* their failure handling unchanged.
@@ -534,7 +622,15 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan
534622
writeRuntimeState: ({ port, url }) => {
535623
try {
536624
const environmentId = process.env.OS_ENVIRONMENT_ID ?? 'env_local';
537-
const runtimeFile = path.join(resolveObjectStackHome(), `runtime.${environmentId}.json`);
625+
// Keyed by the SERVED APP'S ROOT as well as the environment, so two
626+
// projects on one machine stop addressing one file (#15733). The root
627+
// is the one this command already anchored for host resolution; a
628+
// caller reaching these channels outside a boot gets the CWD, which is
629+
// what every path in that situation already resolves against.
630+
const runtimeFile = path.join(
631+
resolveObjectStackHome(),
632+
runtimeStateFileName(environmentId, servedAppRootOrCwd()),
633+
);
538634
fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
539635
fs.writeFileSync(runtimeFile, JSON.stringify({
540636
pid: process.pid,
@@ -4657,9 +4753,9 @@ export default class Serve extends Command {
46574753
// ── The port this process ACTUALLY bound (#13062) ─────────────
46584754
// Read ONCE, here, and handed to every channel that announces an address:
46594755
// the ready banner below, the `objectstack:listening` IPC message and
4660-
// `runtime.<environment>.json`. Those three were three outputs of ONE
4661-
// number, and that number was the port that had been REQUESTED — equal to
4662-
// the bound one for every value except the one where it can never be
4756+
// `runtime.<environment>.<project>.json`. Those three were three outputs
4757+
// of ONE number, and that number was the port that had been REQUESTED
4758+
// — equal to the bound one for every value except the one where it can never be
46634759
// (`--port 0`), which is how all three came to announce `localhost:0`
46644760
// with nothing erroring.
46654761
//
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ONE project publishing its runtime state file, as a REAL separate process.
5+
*
6+
* `serve-runtime-state-project-key.test.ts` drives several of these at once to
7+
* ask a question a single process cannot answer: what happens on a machine
8+
* where TWO projects are serving. Two things about that are only observable
9+
* across processes —
10+
*
11+
* • each child has its OWN pid, so "the second boot's record replaced the
12+
* first's" is a reading rather than an artefact of one process writing
13+
* twice; and
14+
* • `runtimeBoundPortChannels` registers an `exit` cleanup per file it
15+
* writes, so "whose record does a shutdown delete" needs a process that
16+
* can shut down while another keeps running.
17+
*
18+
* ⛔ It writes through the REAL channel — `runtimeBoundPortChannels`, the
19+
* object `os serve` itself publishes with — reached by a RELATIVE import into
20+
* `packages/cli/src`, never through the package name. A bare
21+
* `@objectstack/cli` specifier would resolve through `exports` to `dist/` and
22+
* turn every verdict below into a statement about build state.
23+
*
24+
* ⛔ It does NOT boot a server. What is under test is the NAME the state file
25+
* is keyed by; a listening socket adds cost and a port race to a question that
26+
* has neither.
27+
*
28+
* Protocol: argv[2] is the port to publish, argv[3] a fixed project root the
29+
* child names a file for so the caller can compare that naming with its own.
30+
* One JSON line is written to stdout once the file is on disk. The child then
31+
* stays alive until its stdin closes, and exits CLEANLY on that — a signal
32+
* would skip the `exit` listener whose behaviour is half of what the caller is
33+
* measuring.
34+
*
35+
* ⛔ This module EXPORTS nothing and is never imported: its body runs on
36+
* import, so an `export` here would invite a caller to pull the constant in and
37+
* take a `process.exit(2)` with it. Both arguments come over argv instead.
38+
*/
39+
40+
import { runtimeBoundPortChannels, runtimeStateFileName } from '../../src/commands/serve.js';
41+
42+
const port = Number(process.argv[2]);
43+
if (!Number.isInteger(port) || port <= 0) {
44+
process.stderr.write('runtime-state-child: argv[2] must be a positive integer port\n');
45+
process.exit(2);
46+
}
47+
48+
runtimeBoundPortChannels(() => { /* no banner: this child publishes one channel */ })
49+
.writeRuntimeState({ port, url: `http://localhost:${port}` });
50+
51+
process.stdout.write(`${JSON.stringify({
52+
pid: process.pid,
53+
port,
54+
cwd: process.cwd(),
55+
home: process.env.OS_HOME,
56+
// The name this child's OWN copy of the writer produces, for a fixed root.
57+
// The caller recomputes it from its own import and compares: two independent
58+
// resolutions of the same source agreeing is what rules out a stale build
59+
// answering for either side.
60+
namingControl: runtimeStateFileName('env_local', process.argv[3] ?? ''),
61+
})}\n`);
62+
63+
process.stdin.resume();
64+
process.stdin.on('end', () => { process.exit(0); });

0 commit comments

Comments
 (0)