Skip to content

Commit 570c7a2

Browse files
fix(cli): os serve no longer hangs on a runtime state file directory that can never be made (#24088)
Fixes #23858 Clause-②: no The card's remainder after PR #23979 (which landed as `Part of`): `os serve`'s runtime state file directory, plus the two in-package copies of the bounded walk. The state-file site now goes through the CLI's module-internal `makeDirectorySync`. **The two copies are kept, not folded**, by the claim's own rule: a fold onto `makeDirectorySync` cannot keep their answers and error text byte for byte (measured below). ## What was wrong `runtimeBoundPortChannels().writeRuntimeState` in `packages/cli/src/commands/serve.ts` made `OS_HOME` with `fs.mkdirSync(path.dirname(runtimeFile), { recursive: true })`. Node's recursive `mkdir` (v22.22.0) never returns when a level answers `ENOENT` while its parent exists, as `/proc` answers for a name it does not have. `publishBoundPort` writes the state file after the bind and before the banner, so the server held its port, printed no `Press Ctrl+C to stop`, sent no listening message to an `os dev` parent, and answered nothing. ## What changed - `serve.ts`: that one call is now `makeDirectorySync(path.dirname(runtimeFile))`, imported from `../utils/make-directory.js`. Nothing else in the file moves. The refusal lands in the write's existing best-effort `catch`, so the boot goes on without the file, as it already did for every other refusal (see Acceptance notes: nothing tells the operator). - `packages/cli/test/uncreatable-directory-doors.integration.test.ts` gains two legs (below). No new spawning file, so the `check:cli-test-child-env` population is unchanged. - `.changeset/23858-serve-state-file-directory.md`: `@objectstack/cli` patch, `Clause-②: no`. - `.changeset/23858-uncreatable-directory-refusal.md` (PR #23979's pending note) is NOT in this diff: it was restored byte-for-byte from the merge base by seat ruling B (`6227399342`). This PR's changeset says that it covers the runtime state file that note lists as "Not covered yet". ## The two folds: kept, measured The claim says each fold keeps its callers, its answers and its error text byte for byte, or the copy is kept. Measured function by function on this branch: each copy as it stands, against the fold as it would be written (each copy's own wrapper and message, verbatim, around its package's `makeDirectorySync`). `dev` = `prepareDevDatabaseDirectory` (`dev.ts`); `runtime` = `makeSqliteDatabaseDirectory` (`standalone-stack.ts`), reached through `createStandaloneStack` because it is module-private. | input (the database file's directory) | dev | runtime | |:--|:--|:--| | creatable, three levels deep | same | same | | already exists | same | same | | `/proc/MISSING/a` | same: `ENOENT`, same text | same: `ENOENT`, same text | | through a regular file | same: `ENOTDIR`, same text | same: `ENOTDIR`, same text | | a regular file holds the directory's name | same: `EEXIST`, same text | same: `EEXIST`, same text | | **a dangling symlink is the directory** | **differs:** copy returns; fold refuses `EEXIST` | **differs:** copy returns; fold refuses `EEXIST` | | **a dangling symlink mid-path** | **differs:** copy `ENOENT … mkdir 'ROOT/dl2/sub'`; fold `EEXIST … mkdir 'ROOT/dl2'` | **differs**, the same way | Two causes, both in the walk itself, so neither wrapper can absorb them: 1. **`EEXIST` on a created level.** The copies accept any `EEXIST`; `makeDirectorySync` accepts it only when a directory stands there (PR #23979's acceptance notes named this). A dangling symlink answers `EEXIST` to `mkdir` and `ENOENT` to `stat`. 2. **Async to sync.** Both copies `await` each `stat` and `mkdir`. Measured on `prepareDevDatabaseDirectory`, event-loop turns that ran while the walk was pending: 46 for a three-level create, 100 for `/proc`, against 0 for the fold. So both copies stay, byte for byte. Neither file is in this diff. **What the copies' answer costs at the door** (base `b6805ac0f`, built CLI; unchanged here): - `OS_HOME=SCRATCH/dl-home os dev`, where `SCRATCH/dl-home/data` is a dangling symlink, exits 1 after 11.2 s with `✗ Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'. …`. Nothing names the file, the directory or the symlink. - `OS_DATABASE_URL=file:SCRATCH/dl-db/link/ctl.db os serve`, where `link` is a dangling symlink, exits 1 after 5.3 s with `✗ Plugin package-service failed to start - rollback complete: The database refused to run a raw statement. …`. - The fold would turn both into an immediate refusal that names the file, the directory and `EEXIST`. That is a change of answer, which the claim's rule keeps out of this PR. It is in the report for the seat. ## Measured at the public door Built CLI, Node v22.22.0, a scratch project outside the repo with `export default {};` (and a `defineStack` project for `os dev`). Each run is in its own process group, killed whole at the bound. `strace -f -c -e trace=mkdir,mkdirat` counts the calls. | command | base `b6805ac0f` | this branch | |:--|:--|:--| | `OS_HOME=/proc/os-23858-r2 OS_DATABASE_URL=:memory: os serve` | **hung** after the bind: **794,594** failing `mkdir` in 20 s (all `mkdir("/proc/os-23858-r2")` / `mkdir("/proc")`), no banner, killed at the bound | `Press Ctrl+C to stop` at 4.7 s; 1 failing `mkdir` in the whole run | | `OS_HOME=/proc/os-23858-r2 os dev --database :memory: --no-watch` (the serve child reaches the same write) | **hung**: **1,392,733** failing `mkdir` in 40 s, no banner | banner and MCP connect block at 13.2 s; 1 failing `mkdir` | | `OS_HOME=/proc/os-23858-r2 os serve` (SQLite default inside `OS_HOME`) | exit 1 in 3.5 s: `✗ [StandaloneStack] SQLite database file '/proc/os-23858-r2/data/objectstack.db' cannot be created: … ENOENT …` | the same line, exit 1 in 3.8 s | | `OS_HOME=/proc/os-23858-r2 os start` (also with `OS_DATABASE_URL=:memory:`) | exit 1 in 3.5 s: `✗ Cannot create home directory at /proc/os-23858-r2: ENOENT …` (PR #23979's door, before any serve child) | the same, exit 1 in 4.2 s | | fold door, `OS_HOME=/proc/os-23858-r2 os dev` (unified default) | exit 1 in 3.6 s: `✗ The default database '/proc/os-23858-r2/data/objectstack.db' cannot be created: its directory '/proc/os-23858-r2/data' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-23858-r2'` | the same line, exit 1 in 3.5 s | | fold door, `OS_DATABASE_URL=file:/proc/os-23858-r2-db/a/ctl.db os serve` | exit 1 in 3.3 s: `✗ [StandaloneStack] SQLite database file '/proc/os-23858-r2-db/a/ctl.db' cannot be created: its directory '/proc/os-23858-r2-db/a' could not be made: ENOENT: …, mkdir '/proc/os-23858-r2-db'` | the same line, exit 1 in 3.5 s | `os start` never reaches the state-file write under an uncreatable home: it makes `HOME_DIR/data` first and refuses there. The door that reaches it is `os serve` (or `os dev`'s serve child) with the database outside `OS_HOME`. ## The remaining recursive `mkdir` sites in the two packages `git grep -n "recursive: true"` over `packages/cli/src` and `packages/runtime/src` at base `b6805ac0f`, test files excluded: 18 lines. 3 are `rmSync`, 2 are `readdirSync` in doc comments, and 6 are doc comments naming the old call. That leaves 7 `mkdir` code sites; a multi-line search (`mkdir(Sync)?\(` without `recursive` on the line) finds only the walks' own non-recursive calls. | site | path | can operator input make it uncreatable? | |:--|:--|:--| | `cli/src/commands/serve.ts:1097` | `OS_HOME` (the state file) | **yes**: this PR | | `cli/src/commands/create.ts:740` | template-constant dirs under `targetDir`, just made by `makeDirectorySync` | no, by construction | | `cli/src/commands/dev.ts:572`, `:577` | `--fresh` home's `data` and `uploads`, one level under `mkdtempSync` | no, by construction | | `cli/src/commands/init.ts:1122` | template-constant dirs under `targetDir`, just written into | no, by construction | | `cli/src/utils/build-runtime.ts:198` | `outputDir`; its only caller passes `compile`'s `artifactDir`, made or refused by `makeDirectorySync` | no, by construction | | `cli/src/utils/dev-organization-ownership-upgrade.ts:128` | two constant levels under the SQLite file's own directory, which exists | no, by construction | `packages/runtime/src` has no recursive `mkdir` left. ## Pins `packages/cli/test/uncreatable-directory-doors.integration.test.ts`, two new legs among its concurrent children (6 in all), each its own process group, killed whole at 60 s: 5. `os serve --port 0` under `OS_HOME=/proc/MISSING` with `OS_DATABASE_URL=:memory:` prints `Press Ctrl+C to stop` inside the bound. The group is killed on that text. 6. CONTROL: the same under a creatable `OS_HOME` three levels deep prints the banner, and by then the home holds exactly one `runtime.env_local.PROJECT.json` whose `pid` is the child's own. File wall 6.38 s (6 of 6), under the 10 s line. `--port 0` keeps both off any fixed port. ## Negative control (ablation) On committed head `b3fa3f11d`, through `scripts/ablation-replace.mjs` in wrap mode under the verify lock: - **Mutation:** the anchor `makeDirectorySync(path.dirname(runtimeFile));` (x1 → x0) became `fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });` (x0 → x1), blob `87014c3470c7` → `f238ec2b0a69`. - **Into `dist/`:** `pnpm --filter @objectstack/cli build` exit 0; `node scripts/ablation-dist-preflight.mjs @objectstack/cli 'dirname(runtimeFile), { recursive: true }'` exit 0, marker present in 1 built file. The door pin runs `dist/`. - **Result:** 1 failed, 5 passed in 60.44 s: `'serve-proc' had not settled after 60000 ms and was killed — a directory prep is looping again.` Both serve and start CONTROLs stayed green. - **Restore:** blob after restore == HEAD (`87014c3470c7`), `git diff HEAD` empty. CLI rebuilt exit 0; preflight `--absent`: marker absent from all 652 built files, working tree clean. The pin re-ran 6 of 6 green (6.52 s). ## Tests and gates Exit codes captured before any pipe. After merging `origin/main` (`8133cc780`) into the branch, head `fd250408f`: - `pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2`: exit 0 (61 tasks, 27 cached). - `pnpm --filter @objectstack/cli run typecheck`: exit 0. `pnpm --filter @objectstack/runtime run typecheck`: exit 0. The pin file is in `tsconfig.test.json`'s program (`--listFiles`) with 0 errors. - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit` (the whole unit tier): **exit 0, 308 files, 4,582 tests**. It includes `src/commands/dev-database-directory.test.ts`, `src/utils/make-directory.test.ts`, `serve-bound-port-publication`, `serve-no-transport-announcements`, `serve-bound-port-publish-order` and `serve-publishes-bound-port.e2e`. - `--project integration` for `test/uncreatable-directory-doors.integration.test.ts` and `test/serve-runtime-state-project-key.test.ts` (the other state-file pin): exit 0, 2 files, 18 tests. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local src/standalone-stack-uncreatable-db-directory.test.ts src/make-directory.test.ts` (the runtime copy's own pin, unchanged, and the runtime walk's): exit 0, 2 files, 12 tests. - `pnpm exec eslint --no-inline-config --max-warnings=0` over the two changed `.ts` files: exit 0. The repo-wide run is CI's `Lint & Repo Gates`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands, all run one by one on `fd250408f`: 66 exit 0, and `node scripts/check-empty-changeset.mjs --base origin/main` exit 1 there (the pending-note edit, since restored). On `c31899a8a` all 67 are accounted for with exit 0, and CI's `Check Changeset` is success. - Three first ran as exit 3 (NOT MEASURED) and were re-run to a reading: `check:type-check-debt`, whose closure build had not got the verify lock, re-run under the lock: exit 0, `--re-measure: OK — 1 ledger entr(ies) re-measured … none above its recorded number`. That build also built the packages `check:dual-build-cjs-loads` (exit 0, 110 require entry points across 68 packages load) and `check:i18n-coverage` (exit 0, 14 configs, none new) had found missing. - `--ran` reconciliation: `67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED`, exit 0. - `node scripts/check-nul-bytes.mjs`: exit 0. - Left to CI: the rest of `packages/cli`'s integration tier (no other integration file and no spawn entry is touched), and the runtime package's suites (the runtime is not in this diff). ## Acceptance notes - **Seat ruling B (`6227399342`).** The pending `.changeset/23858-uncreatable-directory-refusal.md` line "Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening." is superseded by this PR's changeset. It is left in place because the #17712 guard gates any edit of another PR's pending changeset on a person's confirmation. `carrier:` the release owner, at `changeset version` time: drop that line from the compiled entry. - **A refused state-file write still tells nobody.** The `catch { /* non-fatal — supervision file is best-effort */ }` around the write prints nothing. Measured on this branch: under `OS_HOME=/proc/os-23858-r2` and under `OS_HOME=SCRATCH/afile/sub`, where `afile` is a regular file (`ENOTDIR`), `os serve` prints its banner at about 4.6 s, and its output names neither the state file nor the error. A supervisor told "ready" then gets `ENOENT` on the file. The `ENOTDIR` case answered that way before this PR too. Before this PR the `/proc` case hung instead. The catch's posture is not this card's; it is in the report. - `dev.ts` and `standalone-stack.ts` are not in this diff (see The two folds). - The walk now has five definitions in three packages, two semantics: the strict one (`makeDirectorySync`, both packages) and the lenient one (`driver-sql`'s `makeDatabaseDirectory` and the two copies above). --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b75129f commit 570c7a2

3 files changed

Lines changed: 103 additions & 14 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os serve` no longer hangs after binding its port when its runtime state file's directory can never be made
6+
7+
Clause-②: no
8+
9+
This covers the runtime state file that the entry `fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging` lists as `Not covered yet`.
10+
11+
**What was wrong.** Once listening, `os serve` writes its runtime state file, `runtime.<environment>.<project>.json`, into `OS_HOME` before it prints the ready banner. It made that directory with Node's recursive `mkdir`, which never returns when a level answers `ENOENT` although its parent exists, as `/proc` and `/sys` answer for a name they do not have. Under such an `OS_HOME`, with the database outside it, the server bound its port and then looped in its own process, holding the event loop: no `Press Ctrl+C to stop`, no listening message to an `os dev` parent, and no request answered.
12+
13+
**What an operator sees now.** The directory is made with the same bounded walk as the other `OS_HOME` paths, so a directory that can never be made is refused at once. The state file has always been best-effort: the boot goes on without it and prints its banner, as it already did when the file could not be written for any other reason.
14+
15+
Nothing else changes. Every state file written before is still written, in the same place, before the banner; a home that can be made is still made, every missing level of it.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js';
1111
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
1212
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1313
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
14+
import { makeDirectorySync } from '../utils/make-directory.js';
1415
// The precedence's last rung — whether the cwd config takes part — decided by
1516
// the SAME predicate the supervisors print their `Config:` row by (#21501).
1617
import { cwdConfigJoinsBoot } from '../utils/artifact-precedence.js';
@@ -1094,7 +1095,12 @@ export function runtimeBoundPortChannels(printBanner: () => void): BoundPortChan
10941095
resolveObjectStackHome(),
10951096
runtimeStateFileName(environmentId, servedAppRootOrCwd()),
10961097
);
1097-
fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
1098+
// Bounded (#23858): an `OS_HOME` that can never be made — a name under
1099+
// `/proc` — is refused here at once, and the best-effort catch below
1100+
// lets the boot go on without the file. Node's recursive mkdir looped
1101+
// on it after the bind and before the banner, holding the event loop,
1102+
// so the server never announced itself.
1103+
makeDirectorySync(path.dirname(runtimeFile));
10981104
fs.writeFileSync(runtimeFile, JSON.stringify({
10991105
pid: process.pid,
11001106
port,

‎packages/cli/test/uncreatable-directory-doors.integration.test.ts‎

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
/**
44
* `os start` and `os create` refuse a directory that can never be made at
55
* once, naming the path and the operating system's code, instead of hanging
6-
* in Node's recursive mkdir (#23858).
6+
* in Node's recursive mkdir (#23858). `os serve` stops hanging on its runtime
7+
* state file's directory the same way.
78
*
89
* ## The defect, measured on `origin/main` 0744f94366 (built CLI, Node v22.22.0)
910
*
@@ -17,15 +18,23 @@
1718
* `Location:` row and hung the same way: 889,246 failing calls in 20 s.
1819
* That is the author-chosen output-path class (`--dir`, `--out`,
1920
* `--output`).
21+
* - `OS_HOME=/proc/os-23858-r2 OS_DATABASE_URL=:memory: os serve` (on
22+
* `origin/main` b6805ac0f) bound its port and then looped on the runtime
23+
* state file's directory, `OS_HOME` itself, before the banner: 794,594
24+
* failing `mkdir` calls in 20 s and no `Press Ctrl+C to stop`. The
25+
* database URL keeps the SQLite default out of `OS_HOME`, whose own
26+
* bounded refusal (#23737) would otherwise end the boot first.
2027
*
2128
* ## Why every run is a bounded child of the BUILT entry
2229
*
2330
* A regression would hang, not fail. Each command runs in its own process
2431
* group with a hard SIGKILL of the whole group at `CHILD_BOUND_MS`, so a
2532
* reintroduced loop fails this file at the bound instead of hanging the suite.
2633
* `bin/run.js` with `NODE_ENV` unset is the entry an operator runs, and it
27-
* starts in about 3 s where the tsx source entry takes about 10 s, so the four
28-
* children (run concurrently) keep this file under the 10 s line.
34+
* starts in about 3 s where the tsx source entry takes about 10 s, so the six
35+
* children (run concurrently) keep this file under the 10 s line. The two
36+
* `serve` children never exit by themselves: each is killed whole as soon as
37+
* its banner tail is out, and `--port 0` keeps both off any fixed port.
2938
*
3039
* ## What is asserted — kind, exit code and named subject, never the prose
3140
*
@@ -34,24 +43,30 @@
3443
* 2. CONTROL: `os start` under a creatable `OS_HOME` three levels deep makes
3544
* `<home>/data` and goes on to its next refusal. `--port 70000` is that
3645
* refusal: start's own invalid-port door, AFTER the home step and BEFORE
37-
* any `serve` child, so no leg of this file ever boots a server. The
38-
* `/proc` leg passes it too, for the same reason.
46+
* any `serve` child, so neither `start` leg boots a server. The `/proc`
47+
* leg passes it too, for the same reason.
3948
* 3. `os create plugin --dir /proc/<missing>/foo`: settles, exits 1, names the
4049
* path and `ENOENT`.
4150
* 4. CONTROL: `os create plugin --dir <tmp>/a/b/foo` exits 0 and writes the
4251
* project.
52+
* 5. `os serve` under `OS_HOME=/proc/<missing>`: prints its banner tail
53+
* inside the bound. The state file cannot be written there, and the
54+
* write's best-effort catch lets the boot go on without it.
55+
* 6. CONTROL: `os serve` under a creatable `OS_HOME` three levels deep
56+
* prints its banner tail, and by then that home holds one
57+
* `runtime.env_local.<project>.json` naming the child's own pid.
4358
*
4459
* They need a `/proc` that refuses `mkdir` with ENOENT, so the `/proc` legs
4560
* run where `/proc/self` exists (Linux, which CI is).
4661
*/
4762

4863
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4964
import { spawn } from 'node:child_process';
50-
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
65+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
5166
import { tmpdir } from 'node:os';
5267
import { join, resolve } from 'node:path';
5368
import { fileURLToPath } from 'node:url';
54-
import { RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js';
69+
import { E2E_SECRET_KEY, RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js';
5570

5671
const HERE = resolve(fileURLToPath(import.meta.url), '..');
5772
const RUN_JS = resolve(HERE, '../bin/run.js');
@@ -65,21 +80,34 @@ const UNSERVABLE_PORT = '70000';
6580
const RUNS_PROC = existsSync('/proc/self');
6681
const PROC_TOP = `/proc/os-23858-${process.pid}`;
6782

83+
/** The last line of `os serve`'s ready banner. */
84+
const BANNER_TAIL = 'Press Ctrl+C to stop';
85+
86+
/** The platform with no application — the cheapest project that reaches a banner. */
87+
const BARE_CONFIG = 'export default {};\n';
88+
6889
interface Run {
6990
code: number | null;
7091
signal: NodeJS.Signals | null;
7192
killedAtBound: boolean;
93+
/** `until` appeared in the output, and the group was killed on it. */
94+
reached: boolean;
95+
pid: number | undefined;
7296
output: string;
7397
}
7498

7599
let workdir: string;
100+
let serveDir: string;
76101
const runs = new Map<string, Run>();
77102

78-
/** Run the built CLI in its own process group, killed whole at the bound. */
79-
function runCli(args: string[], env: Record<string, string>): Promise<Run> {
103+
/**
104+
* Run the built CLI in its own process group, killed whole at the bound — or,
105+
* with `until`, as soon as that text is in its output (a server never exits).
106+
*/
107+
function runCli(args: string[], env: Record<string, string>, opts: { cwd?: string; until?: string } = {}): Promise<Run> {
80108
return new Promise((resolveRun) => {
81109
const child = spawn(process.execPath, [RUN_JS, ...args], {
82-
cwd: workdir,
110+
cwd: opts.cwd ?? workdir,
83111
detached: true,
84112
// Declared in full: no vitest worker variables, a HOME of this file's
85113
// own, and `NODE_ENV` unset — the built entry must resolve `dist/`.
@@ -94,8 +122,16 @@ function runCli(args: string[], env: Record<string, string>): Promise<Run> {
94122
stdio: ['ignore', 'pipe', 'pipe'],
95123
});
96124
let output = '';
97-
child.stdout.on('data', (d) => { output += d; });
98-
child.stderr.on('data', (d) => { output += d; });
125+
let reached = false;
126+
const onData = (d: Buffer) => {
127+
output += d;
128+
if (opts.until !== undefined && !reached && output.includes(opts.until)) {
129+
reached = true;
130+
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
131+
}
132+
};
133+
child.stdout.on('data', onData);
134+
child.stderr.on('data', onData);
99135
let killedAtBound = false;
100136
const timer = setTimeout(() => {
101137
killedAtBound = true;
@@ -104,22 +140,40 @@ function runCli(args: string[], env: Record<string, string>): Promise<Run> {
104140
child.on('exit', (code, signal) => {
105141
clearTimeout(timer);
106142
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
107-
resolveRun({ code, signal, killedAtBound, output });
143+
resolveRun({ code, signal, killedAtBound, reached, pid: child.pid, output });
108144
});
109145
});
110146
}
111147

148+
/**
149+
* `os serve --port 0` over {@link BARE_CONFIG}, until its banner tail. The
150+
* database is in memory so the SQLite default never lands in `OS_HOME`; the
151+
* runtime state file is then the boot's one write there.
152+
*/
153+
function runServe(home: string): Promise<Run> {
154+
return runCli(
155+
['serve', '--port', '0'],
156+
{ OS_HOME: home, OS_DATABASE_URL: ':memory:', OS_SECRET_KEY: E2E_SECRET_KEY, OS_DISABLE_CONSOLE: '1' },
157+
{ cwd: serveDir, until: BANNER_TAIL },
158+
);
159+
}
160+
112161
beforeAll(async () => {
113162
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
114163
workdir = mkdtempSync(join(tmpdir(), 'os-23858-doors-'));
164+
serveDir = join(workdir, 'serve-project');
165+
mkdirSync(serveDir);
166+
writeFileSync(join(serveDir, 'objectstack.config.ts'), BARE_CONFIG);
115167
const legs: Array<[string, Promise<Run>]> = [
116168
['start-control', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: join(workdir, 'home', 'a', 'b', 'c') })],
117169
['create-control', runCli(['create', 'plugin', 'foo', '--dir', join(workdir, 'create', 'a', 'b', 'foo')], {})],
170+
['serve-control', runServe(join(workdir, 'serve-home', 'a', 'b', 'c'))],
118171
];
119172
if (RUNS_PROC) {
120173
legs.push(
121174
['start-proc', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: `${PROC_TOP}-home` })],
122175
['create-proc', runCli(['create', 'plugin', 'foo', '--dir', `${PROC_TOP}-cr/foo`], {})],
176+
['serve-proc', runServe(`${PROC_TOP}-sv`)],
123177
);
124178
}
125179
const settled = await Promise.all(legs.map(([, p]) => p));
@@ -169,4 +223,18 @@ describe('a directory that can never be made is refused at the door, at once (#2
169223
expect(run.code, run.output).toBe(0);
170224
expect(statSync(join(workdir, 'create', 'a', 'b', 'foo', 'package.json')).isFile()).toBe(true);
171225
});
226+
227+
it.runIf(RUNS_PROC)('os serve under OS_HOME=/proc/<missing> prints its banner instead of looping on the state file', () => {
228+
const run = runOf('serve-proc');
229+
expect(run.reached, run.output).toBe(true);
230+
});
231+
232+
it('CONTROL: os serve under a creatable OS_HOME three levels deep writes its state file before the banner', () => {
233+
const run = runOf('serve-control');
234+
expect(run.reached, run.output).toBe(true);
235+
const home = join(workdir, 'serve-home', 'a', 'b', 'c');
236+
const files = readdirSync(home).filter((name) => /^runtime\.env_local\..+\.json$/.test(name));
237+
expect(files, `entries in ${home}: ${readdirSync(home).join(', ')}`).toHaveLength(1);
238+
expect(JSON.parse(readFileSync(join(home, files[0]), 'utf8')).pid).toBe(run.pid);
239+
});
172240
});

0 commit comments

Comments
 (0)