diff --git a/.changeset/runtime-state-file-project-key.md b/.changeset/runtime-state-file-project-key.md new file mode 100644 index 0000000000..7bb9751ae3 --- /dev/null +++ b/.changeset/runtime-state-file-project-key.md @@ -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. + + + +**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..json` and is now named `runtime...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..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...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..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..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. diff --git a/packages/cli/src/commands/serve-bound-port-publication.test.ts b/packages/cli/src/commands/serve-bound-port-publication.test.ts index 031a6626fd..f6957e6411 100644 --- a/packages/cli/src/commands/serve-bound-port-publication.test.ts +++ b/packages/cli/src/commands/serve-bound-port-publication.test.ts @@ -50,6 +50,7 @@ import { publishBoundPort, resolveBoundPort, runtimeBoundPortChannels, + runtimeStateFileName, type BoundPortChannels, } from './serve.js'; import { MAX_PORT } from '../utils/port-contract.js'; @@ -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); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a2b9b26cba..c53cbcb558 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 @@ -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..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...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 @@ -459,8 +460,8 @@ export interface ListeningMessage { */ export interface BoundPortChannels { /** - * Writes `runtime..json`. ⛔ Must COMPLETE before either - * announcement below: it is the file both of them send a consumer to. + * Writes `runtime...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. */ @@ -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..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...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..json * IPC ─▶ the `os dev` parent sees the port * file ─────────────────────────▶ ...written here. The ENOENT already happened. * ``` @@ -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 @@ -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..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...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..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. @@ -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, @@ -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..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...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. // diff --git a/packages/cli/test/helpers/runtime-state-child.ts b/packages/cli/test/helpers/runtime-state-child.ts new file mode 100644 index 0000000000..d3fce328fd --- /dev/null +++ b/packages/cli/test/helpers/runtime-state-child.ts @@ -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); }); diff --git a/packages/cli/test/serve-bound-port-publish-order.test.ts b/packages/cli/test/serve-bound-port-publish-order.test.ts index 7778387bb4..acd5b8b9ab 100644 --- a/packages/cli/test/serve-bound-port-publish-order.test.ts +++ b/packages/cli/test/serve-bound-port-publish-order.test.ts @@ -57,12 +57,20 @@ import { join } from 'node:path'; import { publishBoundPort, runtimeBoundPortChannels, + runtimeStateFileName, type BoundPortChannels, type ListeningMessage, } from '../src/commands/serve.js'; -/** The file name a plain `os serve` writes when `OS_ENVIRONMENT_ID` is unset. */ -const RUNTIME_FILE = 'runtime.env_local.json'; +/** + * The file name a plain `os serve` writes when `OS_ENVIRONMENT_ID` is unset. + * + * Asked of the writer's OWN naming function rather than spelled out, because + * the name is keyed by the project as well as the environment (#15733) and a + * literal copy here would be free to drift away from what is written. These + * publishes run outside a boot, so the project half anchors on the CWD. + */ +const RUNTIME_FILE = runtimeStateFileName('env_local', process.cwd()); const tempDirs: string[] = []; /** `runtimeBoundPortChannels` registers an `exit` cleanup per state file written. */ diff --git a/packages/cli/test/serve-publishes-bound-port.e2e.test.ts b/packages/cli/test/serve-publishes-bound-port.e2e.test.ts index 037059ea28..4d161380f6 100644 --- a/packages/cli/test/serve-publishes-bound-port.e2e.test.ts +++ b/packages/cli/test/serve-publishes-bound-port.e2e.test.ts @@ -62,6 +62,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { runtimeStateFileName } from '../src/commands/serve.js'; import { CLI, TSX, @@ -79,12 +80,19 @@ export default {}; `; /** - * `serve.ts`'s OWN default for the environment id (`OS_ENVIRONMENT_ID ?? - * 'env_local'`), which is what names the runtime state file. The variable is - * UNSET for every child below rather than pinned to a value of this file's - * own, so the name asserted here is the one a plain `os serve` writes. + * The state file the child under test writes, asked of the writer's OWN naming + * function. + * + * `OS_ENVIRONMENT_ID` is UNSET for every child below rather than pinned to a + * value of this file's own, so `'env_local'` here is `serve.ts`'s own default + * and the name is the one a plain `os serve` writes. The second half is the + * PROJECT: the name is keyed by the served app's root as well as the + * environment (#15733), so two projects on one machine stop addressing one + * file — and spelling it out as a literal here would be a second copy of that + * rule, free to keep passing against a writer that had drifted off it. */ -const RUNTIME_FILE = 'runtime.env_local.json'; +const runtimeStatePath = (home: string, projectRoot: string): string => + join(home, runtimeStateFileName('env_local', projectRoot)); /** How long a connect probe waits before calling a port unreachable. */ const CONNECT_TIMEOUT_MS = 2_000; @@ -238,7 +246,7 @@ function bootServe(cwd: string, args: string[], home: string): Promise { /** The three channels, read out of one boot. */ function channelsOf(booted: Booted): { ipc: unknown; banner: unknown; runtimeFile: unknown } { const banner = boundPortFromBanner(booted.stdout + booted.stderr); - const state = JSON.parse(readFileSync(join(booted.home, RUNTIME_FILE), 'utf8')); + const state = JSON.parse(readFileSync(runtimeStatePath(booted.home, bareDir), 'utf8')); return { ipc: booted.ipc?.port, banner: banner.state === 'bound' ? banner.port : banner, @@ -317,7 +325,7 @@ describe('#13062 `os serve --port 0` — the request that can never be the answe // `{ port: 0 }`, `API: http://localhost:0/`, `"port": 0`. expect(ipc, 'the IPC message still announces the REQUESTED port').not.toBe(0); expect(banner, 'the ready banner still names http://localhost:0').not.toBe(0); - expect(runtimeFile, 'runtime.env_local.json still records port 0').not.toBe(0); + expect(runtimeFile, 'the runtime state file still records port 0').not.toBe(0); // One number, not three that happen to be non-zero. expect(banner).toBe(ipc); diff --git a/packages/cli/test/serve-runtime-state-project-key.test.ts b/packages/cli/test/serve-runtime-state-project-key.test.ts new file mode 100644 index 0000000000..d788fb8736 --- /dev/null +++ b/packages/cli/test/serve-runtime-state-project-key.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15733 — the runtime state file is keyed by the PROJECT, not by the + * environment id alone. + * + * ## The collision, driven before it was repaired + * + * `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?"*. Both halves of where that file lived were machine-global: + * `resolveObjectStackHome()` takes NO arguments — `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, + * addressed ONE file. Two real `os serve` 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 the file "is my server running, and where?" + * → pid 9345 on :46175 — B's server — while A's own was still + * alive and still listening on 42693. + * A shuts down → its exit handler deleted the file that by then described B, + * so B served on with no supervision record at all. + * ``` + * + * Two failures, one cause: the NAME answered nothing about *whose* server it + * described. Both are guarded below, because repairing the wrong-answer half + * and leaving the deleted-record half is exactly the shape that reads as fixed. + * + * ## Why child PROCESSES, and why no server + * + * Both properties need more than one process. Distinct pids are what make "the + * second record replaced the first" a reading instead of an artefact of one + * process writing twice, and `runtimeBoundPortChannels` registers its cleanup + * as an `exit` listener — so "whose record does a shutdown delete" needs a + * process that can exit while another keeps running. + * + * ⛔ None of them binds a port. What is under test is the file's NAME; a + * listening socket would add a port race and a boot's worth of seconds to a + * question that has neither. The two-real-server run above is the measurement + * that established the defect; this is the pin that keeps it repaired. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { projectStateKey, runtimeStateFileName } from '../src/commands/serve.js'; +import { childEnv, TSX } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CHILD = resolve(HERE, 'helpers/runtime-state-child.ts'); + +/** The name the file used to have — the one every project shared. */ +const LEGACY_SHARED_NAME = 'runtime.env_local.json'; + +/** + * A fixed root both sides name a file for, so their answers can be compared. + * + * Handed to the child over argv rather than imported from it: that module's + * body RUNS on import (it is a script), so importing a constant out of it would + * take this runner down with a `process.exit(2)`. + */ +const NAMING_CONTROL_ROOT = '/objectstack-15733/naming-control'; + +/** How long one child gets to publish its file and say so. */ +const CHILD_READY_TIMEOUT_MS = 90_000; + +interface Published { + pid: number; + port: number; + cwd: string; + home: string; + namingControl: string; +} + +interface Child { + published: Published; + /** Close stdin and wait for a CLEAN exit, so the `exit` cleanup really runs. */ + stop: () => Promise; +} + +/** Counters, printed at the end: an assertion about runs that never ran is vacuous. */ +let childrenSpawned = 0; +let overwritesObserved = 0; + +/** + * Publish one project's state file from its own process. + * + * @param projectRoot the child's CWD — the project identity under test + * @param home the ONE machine-global home every child here shares + */ +function publishFrom(projectRoot: string, home: string, port: number): Promise { + return new Promise((resolveChild, rejectChild) => { + const child: ChildProcess = spawn(TSX, [CHILD, String(port), NAMING_CONTROL_ROOT], { + cwd: projectRoot, + stdio: ['pipe', 'pipe', 'pipe'], + // ⛔ `childEnv`, never a bare `...process.env`: vitest sets `TEST`, + // `VITEST` and the `VITEST_*` family on its worker, and a child that + // inherits them boots with a different auth and crypto posture than the + // one under test (`check:cli-test-child-env` is the gate that keeps this + // directory on the choke point). + env: childEnv({ + OS_HOME: home, + // UNSET, so the environment half of the name is serve's own default. + OS_ENVIRONMENT_ID: undefined, + }), + }); + childrenSpawned += 1; + + let stdout = ''; + let stderr = ''; + let settled = false; + + const stop = (): Promise => + new Promise((done) => { + if (child.exitCode !== null || child.signalCode !== null) { done(); return; } + child.on('exit', () => done()); + child.stdin?.end(); + }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + rejectChild(new Error( + `child for ${projectRoot} never published.\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + )); + }, CHILD_READY_TIMEOUT_MS); + + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + const line = stdout.split('\n').find((candidate) => candidate.trim().startsWith('{')); + if (line === undefined || settled) return; + settled = true; + clearTimeout(timer); + resolveChild({ published: JSON.parse(line) as Published, stop }); + }); + child.stderr?.on('data', (chunk) => { stderr += String(chunk); }); + child.on('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + rejectChild(new Error( + `child for ${projectRoot} exited ${code} before publishing.\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + )); + }); + child.on('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + rejectChild(error); + }); + }); +} + +const readState = (file: string): Published & { url: string } => + JSON.parse(readFileSync(file, 'utf8')) as Published & { url: string }; + +/** ⚠️ The key is a pure function of the ROOT — no filesystem, no writes. */ +describe('#15733 the key itself', () => { + it('gives two different project roots two different keys', () => { + expect(projectStateKey('/srv/alpha')).not.toBe(projectStateKey('/srv/beta')); + }); + + it('gives one project ONE key however its path is spelled', () => { + // ⛔ Not a nicety: a supervisor that resolved the root differently from the + // server would look in the wrong place and read "not running". + expect(projectStateKey('/srv/alpha/')).toBe(projectStateKey('/srv/alpha')); + expect(projectStateKey('/srv/beta/../alpha')).toBe(projectStateKey('/srv/alpha')); + }); + + it('still keys a root whose basename sanitises away to nothing', () => { + // The readable half is DERIVED and may vanish; uniqueness rests on the + // digest, so this must be a key and not an empty component. + const key = projectStateKey('/srv/___'); + expect(key).toMatch(/^[0-9a-f]{12}$/); + expect(key).not.toBe(projectStateKey('/srv/+++')); + }); + + it('names the file `runtime...json`, and NOT the shared name', () => { + const name = runtimeStateFileName('env_local', '/srv/alpha'); + expect(name).toBe(`runtime.env_local.${projectStateKey('/srv/alpha')}.json`); + expect(name).not.toBe(LEGACY_SHARED_NAME); + // ⭐ The one non-test reader in this repo — `scripts/publish-smoke.sh`'s + // `smoke_wait_for_own_server` — finds the file by globbing + // `runtime.*.json` in the home it pinned. The new name still matches that + // glob, which is why this repair does not have to move that script. + expect(/^runtime\..*\.json$/.test(name)).toBe(true); + }); + + it('keeps the environment apart too — the half that already worked', () => { + expect(runtimeStateFileName('env_local', '/srv/alpha')) + .not.toBe(runtimeStateFileName('env_staging', '/srv/alpha')); + }); +}); + +describe('#15733 two projects, one machine-global home', () => { + let home: string; + let projectA: string; + let projectB: string; + let fileA: string; + let fileB: string; + let a: Child; + let b: Child; + const temps: string[] = []; + const live: Child[] = []; + + beforeAll(async () => { + const mk = (prefix: string): string => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temps.push(dir); + return dir; + }; + // ONE home for both projects. That is not a contrivance: it is what + // `resolveObjectStackHome()` returns for every project on the machine. + home = mk('os-15733-home-'); + projectA = mk('os-15733-project-a-'); + projectB = mk('os-15733-project-b-'); + fileA = join(home, runtimeStateFileName('env_local', projectA)); + fileB = join(home, runtimeStateFileName('env_local', projectB)); + + a = await publishFrom(projectA, home, 45801); + live.push(a); + b = await publishFrom(projectB, home, 45802); + live.push(b); + }, 200_000); + + afterAll(async () => { + for (const child of live) await child.stop(); + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); + }); + + it('CONTROL — both children really ran, in their own processes', () => { + // Everything below is about what two processes did; without this the suite + // could be green on a run where one of them never started. + expect(childrenSpawned).toBeGreaterThanOrEqual(2); + expect(a.published.pid).not.toBe(b.published.pid); + expect(a.published.pid).not.toBe(process.pid); + expect(a.published.cwd).toBe(projectA); + expect(b.published.cwd).toBe(projectB); + expect(a.published.home).toBe(home); + expect(b.published.home).toBe(home); + }); + + it('CONTROL — the children and this file resolve the SAME writer source', () => { + // ⛔ Freshness, not decoration. The children reach the writer by a relative + // import into `packages/cli/src` and so does this file, but two readings of + // one stale build are still one reading — so both sides answer for a FIXED + // root and the answers are compared, and the answer is checked against the + // name the repair replaced. + const mine = runtimeStateFileName('env_local', NAMING_CONTROL_ROOT); + expect(a.published.namingControl).toBe(mine); + expect(b.published.namingControl).toBe(mine); + expect(mine).not.toBe(LEGACY_SHARED_NAME); + }); + + it('⭐ gives each project its OWN file — neither record overwrote the other', () => { + // ⛔ THE CARD. Before the repair both projects wrote `runtime.env_local.json` + // and this is the assertion that failed: one path, and only the second + // boot's record in it. + expect(fileA).not.toBe(fileB); + expect(existsSync(fileA), 'project A has no state file of its own').toBe(true); + expect(existsSync(fileB), 'project B has no state file of its own').toBe(true); + + const stateA = readState(fileA); + const stateB = readState(fileB); + expect(stateA.pid, "project A's file describes another project's process").toBe(a.published.pid); + expect(stateB.pid, "project B's file describes another project's process").toBe(b.published.pid); + expect(stateA.port).toBe(45801); + expect(stateB.port).toBe(45802); + expect(stateA.url).toBe('http://localhost:45801'); + expect(stateB.url).toBe('http://localhost:45802'); + }); + + it('⛔ and nothing is written under the shared name any more', () => { + expect( + existsSync(join(home, LEGACY_SHARED_NAME)), + 'the environment-only name is back, and with it the collision', + ).toBe(false); + }); + + it('CONTROL — the key is the PROJECT, not the process: a second boot of A lands on A\'s file', async () => { + // ⚠️ Discriminates the repair from a much worse one that would pass every + // assertion above: giving every PROCESS its own file. That would separate + // the two projects and simultaneously destroy the file's purpose, since no + // reader could name the file belonging to the project it cares about. + // + // It is also the READ-half control. "Both records survived" means nothing + // unless this harness can see a record being replaced — so here one is, + // deliberately, and the reading changes. + const before = readState(fileA); + const c = await publishFrom(projectA, home, 45803); + live.push(c); + + const after = readState(fileA); + expect(after.pid, 'a second boot of the SAME project wrote somewhere else').toBe(c.published.pid); + expect(after.port).toBe(45803); + expect(after.pid).not.toBe(before.pid); + overwritesObserved += 1; + expect(overwritesObserved).toBe(1); + + // …and it did not touch the other project. + expect(readState(fileB).pid).toBe(b.published.pid); + }, 200_000); + + it('⭐ one project\'s shutdown no longer deletes another project\'s record', async () => { + // The second half of the measured defect: the writer registers an `exit` + // cleanup for the file it wrote, and when every project shared one file, + // ANY project exiting removed the record of whoever was still serving. + expect(existsSync(fileB), 'precondition: B still has a record to lose').toBe(true); + + for (const child of live.filter((candidate) => candidate !== b)) await child.stop(); + + expect(existsSync(fileA), "project A's own file survived its own exit").toBe(false); + expect(existsSync(fileB), "project B's record was deleted by another project's shutdown").toBe(true); + expect(readState(fileB).pid, "project B's record was rewritten by another project").toBe(b.published.pid); + + // …and B still cleans up after ITSELF. + await b.stop(); + expect(existsSync(fileB), 'project B left its own record behind on a clean exit').toBe(false); + }, 200_000); + + it('COUNTS — what actually ran', () => { + expect(childrenSpawned, 'no child processes were driven at all').toBe(3); + expect(overwritesObserved, 'the read half was never exercised').toBe(1); + }); +});