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
24 changes: 24 additions & 0 deletions .changeset/runtime-state-file-project-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/cli": minor
---

`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.

<!-- 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. -->

**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`.

`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.

Driven with two real boots, two project roots and one home, that produced two failures with one cause:

- 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;
- project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all.

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).

**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.

**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.

Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
publishBoundPort,
resolveBoundPort,
runtimeBoundPortChannels,
runtimeStateFileName,
type BoundPortChannels,
} from './serve.js';
import { MAX_PORT } from '../utils/port-contract.js';
Expand Down Expand Up @@ -287,7 +288,12 @@ describe('#13062 all THREE channels publish that one number', () => {
publishBoundPort(45063, runtimeBoundPortChannels(() => { /* banner not under test here */ }));
});

const runtimeFile = join(home, 'runtime.env_local.json');
// ⛔ Not the literal `runtime.env_local.json`: the name is keyed by the
// PROJECT as well as the environment (#15733), and a literal here would be
// a second copy of that rule — free to keep passing against a writer that
// had drifted off it. `runtimeBoundPortChannels` reached outside a boot
// anchors on the CWD, which is this runner's.
const runtimeFile = join(home, runtimeStateFileName('env_local', process.cwd()));
expect(existsSync(runtimeFile), 'no runtime state file was written at all').toBe(true);
const state = JSON.parse(readFileSync(runtimeFile, 'utf8'));
expect(state.port, 'the state file does not publish the bound port').toBe(45063);
Expand Down
130 changes: 113 additions & 17 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { isEmailTransportProvider, emailProviderRequiresApiKey, unsupportedProvi
// bare `import()` resolved against this CLI's own realpath, so its bundled
// copy wins, never the host's (#10909).
import { isSmsTransportProvider, SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms';
import { createHash } from 'node:crypto';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
// The ONE port contract — range, reader and refusal prose — shared with the two
Expand Down Expand Up @@ -391,9 +392,9 @@ export function formatExhaustedPortSearchNotice(requestedPort: number, cause: un
* `serve` used to publish the port it was ASKED for on all three of the
* channels that ANNOUNCE an address — the `objectstack:listening` IPC message,
* the ready banner's `API:` row (through {@link resolveAuthBaseUrl}), and
* `runtime.<environment>.json`. For every port but one the requested and the
* bound value coincide, which is why it stayed invisible; for `0` they CANNOT
* coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
* `runtime.<environment>.<project>.json`. For every port but one the requested
* and the bound value coincide, which is why it stayed invisible; for `0` they
* CANNOT coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
* so in its own words, from its own measurement, that 0 is "a REQUEST, not an
* error", and `listen(0)` binds a kernel-assigned port. So `os serve --port 0`
* announced `{ port: 0 }`, printed `API: http://localhost:0/` and wrote
Expand Down Expand Up @@ -459,8 +460,8 @@ export interface ListeningMessage {
*/
export interface BoundPortChannels {
/**
* Writes `runtime.<environment>.json`. ⛔ Must COMPLETE before either
* announcement below: it is the file both of them send a consumer to.
* Writes `runtime.<environment>.<project>.json`. ⛔ Must COMPLETE before
* either announcement below: it is the file both of them send a consumer to.
*/
writeRuntimeState: (published: { port: number; url: string }) => void;
/** Sends {@link ListeningMessage}, when an IPC channel is open. */
Expand All @@ -475,14 +476,14 @@ export interface BoundPortChannels {
* ## The bug this shape exists to make impossible
*
* `os serve` announces its address on three channels: the runtime state file
* `runtime.<environment>.json`, the `objectstack:listening` IPC message, and
* the ready banner. Two of those are ANNOUNCEMENTS a consumer reacts to; the
* third is the FILE those consumers then open. Published in the order they
* `runtime.<environment>.<project>.json`, the `objectstack:listening` IPC
* message, and the ready banner. Two of those are ANNOUNCEMENTS a consumer
* reacts to; the third is the FILE those consumers then open. Published in the order they
* happened to be written — banner, IPC, file — every consumer that believes an
* announcement races a file that is not there yet:
*
* ```text
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.json
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.<project>.json
* IPC ─▶ the `os dev` parent sees the port
* file ─────────────────────────▶ ...written here. The ENOENT already happened.
* ```
Expand All @@ -491,10 +492,11 @@ export interface BoundPortChannels {
* claim (`serve-publishes-bound-port.e2e.test.ts`) is an ORDINARY consumer — it
* waits for the banner AND the IPC message, then reads the file — and it
* ejected 14 PRs from the shared merge queue in a rolling 24 hours (10
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json`. A real
* supervisor written the same way loses the same race; all a loaded machine
* does is deschedule the child between the announcement and the write, which is
* why it read as a flake for a day.
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json` — the
* name that file carried then, before {@link runtimeStateFileName} keyed it by
* project as well. A real supervisor written the same way loses the same race;
* all a loaded machine does is deschedule the child between the announcement
* and the write, which is why it read as a flake for a day.
*
* ⛔ The repair is NOT to make the reader poll. A consumer that must poll after
* being told "ready" was told "ready" too early — polling spreads the defect
Expand All @@ -521,6 +523,92 @@ export function publishBoundPort(boundPort: number, channels: BoundPortChannels)
channels.printBanner();
}

/**
* The identity a runtime state file is keyed by: the ROOT OF THE PROJECT this
* process is serving, folded into one filename-safe component.
*
* ## The collision this ends, DRIVEN rather than reasoned (#15733)
*
* The state file used to be `runtime.<environment>.json`, and both halves of
* that name are machine-global. {@link resolveObjectStackHome} takes NO
* arguments — it reads `OS_HOME`, else `~/.objectstack` — and an environment
* id is not a project identity, so two different projects on one machine, both
* in the ordinary `local` environment, wrote ONE file. Two real boots, two
* project roots, one home:
*
* ```text
* project A boots → runtime.env_local.json = { pid 9301, port 42693 }
* project B boots → runtime.env_local.json = { pid 9345, port 46175 }
* A asks "is my server running, and where?"
* → answered pid 9345 on :46175 — which is B — while A's own
* server is still alive and still listening on 42693.
* A shuts down → its exit handler deletes the file that by then described
* B, so B serves on with no supervision record at all.
* ```
*
* Those are one defect seen twice: the NAME carried no answer to *whose*
* server this is, so every project addressed the same file. Keying it by the
* served app's root gives each project its own — a reader that finds a file
* has found its own, and a process cleaning up on exit removes only its own.
*
* ⛔ NOT repaired by putting an identity in the PAYLOAD instead. #15374 ruled
* that one deliberately: the identity of the file a process is serving is a
* property of THAT PROCESS, and a payload key would turn a best-effort
* supervision file into an identity contract while STILL leaving two projects
* overwriting and deleting each other's records. The key is the defect; the
* payload is unchanged here.
*
* ## Why the name carries a readable half as well as a digest
*
* The digest is what makes the name unique; the slug is what makes a home
* directory legible to whoever is standing in front of it. A directory
* answering `runtime.env_local.<12 hex>.json` twice tells a reader nothing
* about which one is theirs, and answering that question is this file's whole
* job. The slug is DERIVED, never trusted: uniqueness rests on the digest
* alone, so a project root whose basename sanitises away to nothing is still
* keyed correctly — it just reads as the digest.
*
* ⚠️ Boundary, stated rather than fixed: the key is the resolved path, not the
* realpath, so two symlinked spellings of ONE project key differently (each
* spelling gets its own file, and each is internally consistent). Two boots of
* the SAME project also still share a file — that is the same-project case,
* which is #15374's in-process watch, not this one.
*
* ⚠️ Second boundary, and the one an OUT-OF-TREE reader has to replicate to
* find the record: WHICH root this is handed is {@link servedAppRootOrCwd},
* which {@link anchorServedApp} sets to the CONFIG'S OWN DIRECTORY only when
* that config exists and that directory carries a `package.json`, and to
* `process.cwd()` otherwise. So one app served from two working directories
* with no manifest beside its config keys TWO files, and a supervisor that
* reconstructs the name out of tree has to apply that same rule rather than
* assume the config's directory. That fallback is #11185's and is deliberate:
* a directory that declares nothing is not anchored at, because anchoring
* there could only turn a working boot into an `undeclared` refusal.
*/
export function projectStateKey(servedAppRoot: string): string {
const absolute = path.resolve(servedAppRoot);
const digest = createHash('sha256').update(absolute).digest('hex').slice(0, 12);
const slug = path.basename(absolute).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
return slug.length > 0 ? `${slug}-${digest}` : digest;
}

/**
* The runtime state file's name: `runtime.<environment>.<project>.json`.
*
* Both components are identities the file has to carry, and neither is
* sufficient alone — the environment id keeps a staging boot from answering
* for a local one, and {@link projectStateKey} keeps ANOTHER PROJECT's local
* boot from answering for this one.
*
* ⚠️ A reader that hard-codes the old `runtime.<environment>.json` now gets
* ENOENT rather than a stale or foreign record. That is the intended trade: a
* missing file is a loud, correct answer to "is my server running", where the
* name it replaces could only give a confident wrong one.
*/
export function runtimeStateFileName(environmentId: string, servedAppRoot: string): string {
return `runtime.${environmentId}.${projectStateKey(servedAppRoot)}.json`;
}

/**
* The real channels: the same three writes this command has always done, with
* their failure handling unchanged.
Expand All @@ -534,7 +622,15 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan
writeRuntimeState: ({ port, url }) => {
try {
const environmentId = process.env.OS_ENVIRONMENT_ID ?? 'env_local';
const runtimeFile = path.join(resolveObjectStackHome(), `runtime.${environmentId}.json`);
// Keyed by the SERVED APP'S ROOT as well as the environment, so two
// projects on one machine stop addressing one file (#15733). The root
// is the one this command already anchored for host resolution; a
// caller reaching these channels outside a boot gets the CWD, which is
// what every path in that situation already resolves against.
const runtimeFile = path.join(
resolveObjectStackHome(),
runtimeStateFileName(environmentId, servedAppRootOrCwd()),
);
fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
fs.writeFileSync(runtimeFile, JSON.stringify({
pid: process.pid,
Expand Down Expand Up @@ -4657,9 +4753,9 @@ export default class Serve extends Command {
// ── The port this process ACTUALLY bound (#13062) ─────────────
// Read ONCE, here, and handed to every channel that announces an address:
// the ready banner below, the `objectstack:listening` IPC message and
// `runtime.<environment>.json`. Those three were three outputs of ONE
// number, and that number was the port that had been REQUESTED — equal to
// the bound one for every value except the one where it can never be
// `runtime.<environment>.<project>.json`. Those three were three outputs
// of ONE number, and that number was the port that had been REQUESTED
// — equal to the bound one for every value except the one where it can never be
// (`--port 0`), which is how all three came to announce `localhost:0`
// with nothing erroring.
//
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/test/helpers/runtime-state-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ONE project publishing its runtime state file, as a REAL separate process.
*
* `serve-runtime-state-project-key.test.ts` drives several of these at once to
* ask a question a single process cannot answer: what happens on a machine
* where TWO projects are serving. Two things about that are only observable
* across processes —
*
* • each child has its OWN pid, so "the second boot's record replaced the
* first's" is a reading rather than an artefact of one process writing
* twice; and
* • `runtimeBoundPortChannels` registers an `exit` cleanup per file it
* writes, so "whose record does a shutdown delete" needs a process that
* can shut down while another keeps running.
*
* ⛔ It writes through the REAL channel — `runtimeBoundPortChannels`, the
* object `os serve` itself publishes with — reached by a RELATIVE import into
* `packages/cli/src`, never through the package name. A bare
* `@objectstack/cli` specifier would resolve through `exports` to `dist/` and
* turn every verdict below into a statement about build state.
*
* ⛔ It does NOT boot a server. What is under test is the NAME the state file
* is keyed by; a listening socket adds cost and a port race to a question that
* has neither.
*
* Protocol: argv[2] is the port to publish, argv[3] a fixed project root the
* child names a file for so the caller can compare that naming with its own.
* One JSON line is written to stdout once the file is on disk. The child then
* stays alive until its stdin closes, and exits CLEANLY on that — a signal
* would skip the `exit` listener whose behaviour is half of what the caller is
* measuring.
*
* ⛔ This module EXPORTS nothing and is never imported: its body runs on
* import, so an `export` here would invite a caller to pull the constant in and
* take a `process.exit(2)` with it. Both arguments come over argv instead.
*/

import { runtimeBoundPortChannels, runtimeStateFileName } from '../../src/commands/serve.js';

const port = Number(process.argv[2]);
if (!Number.isInteger(port) || port <= 0) {
process.stderr.write('runtime-state-child: argv[2] must be a positive integer port\n');
process.exit(2);
}

runtimeBoundPortChannels(() => { /* no banner: this child publishes one channel */ })
.writeRuntimeState({ port, url: `http://localhost:${port}` });

process.stdout.write(`${JSON.stringify({
pid: process.pid,
port,
cwd: process.cwd(),
home: process.env.OS_HOME,
// The name this child's OWN copy of the writer produces, for a fixed root.
// The caller recomputes it from its own import and compares: two independent
// resolutions of the same source agreeing is what rules out a stale build
// answering for either side.
namingControl: runtimeStateFileName('env_local', process.argv[3] ?? ''),
})}\n`);

process.stdin.resume();
process.stdin.on('end', () => { process.exit(0); });
Loading
Loading