diff --git a/.changeset/23858-uncreatable-directory-refusal.md b/.changeset/23858-uncreatable-directory-refusal.md new file mode 100644 index 00000000000..372ecd0bd09 --- /dev/null +++ b/.changeset/23858-uncreatable-directory-refusal.md @@ -0,0 +1,25 @@ +--- +'@objectstack/cli': patch +'@objectstack/runtime': patch +--- + +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 + +Clause-②: no + +**What was wrong.** These commands and runtime paths created their directories with Node's recursive `mkdir`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists — what `/proc` and `/sys` answer for a name they do not have. It looped `mkdir("/proc/x")` and `mkdir("/proc")` without end, holding the event loop, so the command printed nothing more and no timeout could fire: + +- `os start` with `--home` / `OS_HOME` on such a path hung after its header. +- `os serve` hung the same way on the empty-boot artifact stub under `OS_HOME`, and on the cache of an `http(s):` `OS_ARTIFACT_URL` under `OS_HOME`. +- `os create --dir`, `os init `, `os compile --output`, `os generate` (`--dir`, and `--output` for types, client and migration), `os i18n extract --out` and `os plugin build --out` hung on an output path there. +- `os login` and `os cloud login` never finished writing credentials under a `HOME` there, and the package enable / disable state the packages routes write under `OS_HOME` held the event loop of the host serving them. + +**What an operator sees now.** The command exits non-zero at once with the operating system's own error, which names the level that cannot be made: + +```text +✗ Cannot create home directory at /proc/os-x: ENOENT: no such file or directory, mkdir '/proc/os-x' +``` + +Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening. + +Nothing else changes. Every directory these paths made before is still made, every missing level of it; every path that was refused before is still refused, with the operating system's code. The one difference is the path that was never answered at all. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index a5859a5aacb..39e42db391d 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -18,6 +18,7 @@ import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js'; import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; +import { makeDirectorySync } from '../utils/make-directory.js'; import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js'; import { stackFilterJudge } from '../utils/authoring-filter-judge.js'; import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; @@ -989,7 +990,9 @@ export default class Compile extends Command { const artifactDir = path.dirname(artifactPath); if (!fs.existsSync(artifactDir)) { - fs.mkdirSync(artifactDir, { recursive: true }); + // Bounded (#23858): an `--output` directory that can never be made is + // refused at once with the OS's code and the path, never a loop. + makeDirectorySync(artifactDir); } const finalBundle: Record = { ...(result.data as Record) }; diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 097cae42e44..8cde43b6e71 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -129,6 +129,7 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; +import { makeDirectorySync } from '../utils/make-directory.js'; import { getCliVersion, NPM_PACKAGE_NAME_MAX_LENGTH, @@ -726,8 +727,9 @@ export default class Create extends Command { console.log(''); try { - // Create directory - fs.mkdirSync(targetDir, { recursive: true }); + // Create directory. Bounded (#23858): a `--dir` that can never be made + // is refused at once with the OS's code and the path, never a loop. + makeDirectorySync(targetDir); // Create files from template for (const [filePath, contentFn] of Object.entries(template.filesFor(placement))) { diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 52f23e58045..bd9ccfc23f2 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import { createHash } from 'node:crypto'; import fs from 'fs'; import path from 'path'; +import { makeDirectorySync } from '../utils/make-directory.js'; // [#16319] `FieldType` is now imported as a VALUE below, beside the other // `@objectstack/spec/data` helpers — the enum object carries the type of the @@ -1800,9 +1801,11 @@ async function runMetadataGeneration( // never prints a `Created` line for a file that is no longer there. const written: string[] = []; try { - // `mkdirSync` answers the first directory it created, or `undefined` - // when the whole path already existed. - createdDir = fs.mkdirSync(fullDir, { recursive: true }) ?? undefined; + // `makeDirectorySync` answers the first directory it created, or + // `undefined` when the whole path already existed — what the recursive + // `mkdirSync` it replaces answered, bounded (#23858): a `--dir` that can + // never be made is refused at once instead of looping. + createdDir = makeDirectorySync(fullDir); // Write file — the same `content` the parse check above accepted, ⛔ not // a re-render: a second call to `generator.generate` would make the @@ -2224,7 +2227,7 @@ async function runTypesGeneration(configPath: string | undefined, flags: { outpu const outPath = path.resolve(process.cwd(), flags.output); const outDir = path.dirname(outPath); if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); + makeDirectorySync(outDir); } fs.writeFileSync(outPath, content); printSuccess(`Generated types at ${flags.output}`); @@ -2371,7 +2374,7 @@ async function runClientGeneration(configPath: string | undefined, flags: { outp const outPath = path.resolve(process.cwd(), flags.output); const outDir = path.dirname(outPath); if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); + makeDirectorySync(outDir); } fs.writeFileSync(outPath, content); printSuccess(`Generated client SDK at ${flags.output} (${timer.display()})`); @@ -4003,7 +4006,7 @@ async function runMigrationGeneration(configPath: string | undefined, flags: { o const outPath = path.resolve(process.cwd(), output); const outDir = path.dirname(outPath); if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); + makeDirectorySync(outDir); } fs.writeFileSync(outPath, content); printSuccess(`Generated migration at ${output} (${timer.display()})`); diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 44214c3877e..25e99a6a5bf 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -6,6 +6,7 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import { normalizeStackInput } from '@objectstack/spec'; import { loadConfig } from '../../utils/config.js'; +import { makeDirectorySync } from '../../utils/make-directory.js'; import { printHeader, printSuccess, @@ -796,7 +797,9 @@ export default class I18nExtract extends Command { process.exit(1); } - fs.mkdirSync(resolvedOutDir, { recursive: true }); + // Bounded (#23858): an `--out` that can never be made is refused at + // once with the OS's code and the path, never a loop. + makeDirectorySync(resolvedOutDir); let written = 0; for (const { file, content, keys } of emitted) { fs.writeFileSync(file, content, 'utf8'); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 295e1067e54..d00dc1d8790 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -20,6 +20,7 @@ import { isExitSignal, } from '../utils/format.js'; import { validateScaffold } from '../utils/scaffold-validate.js'; +import { makeDirectorySync } from '../utils/make-directory.js'; import { summarizeTree, describeEntry } from 'create-objectstack/created-summary'; import { GENERATOR_SCAFFOLD_TARGETS } from './generate.js'; @@ -1262,7 +1263,10 @@ export default class Init extends Command { this.exit(2); } } else { - fs.mkdirSync(targetDir, { recursive: true }); + // Bounded (#23858): a target that can never be made — this command + // run inside `/proc` — is refused at once with the OS's code and the + // path. Node's recursive mkdir looped on it, printing nothing. + makeDirectorySync(targetDir); } } else { targetDir = startCwd; diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index ededb822884..381ef147b03 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -21,7 +21,7 @@ * unsigned artifact whose `SIGNATURE` is a placeholder. */ -import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, relative, resolve as resolvePath, sep as PATH_SEP } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; @@ -43,6 +43,7 @@ import { createTarGz, sha256Hex, } from '../../utils/osplugin.js'; +import { makeDirectorySync } from '../../utils/make-directory.js'; const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.mjs', 'src/index.js']; @@ -218,7 +219,10 @@ export default class PluginBuild extends Command { // 4. Pack the artifact. ───────────────────────────────────────────── const blob = createTarGz(files); const outPath = resolvePath(cwd, flags.out ?? `${id}-${version}${OSPLUGIN_EXT}`); - await mkdir(resolvePath(outPath, '..'), { recursive: true }); + // Bounded (#23858): an `--out` directory that can never be made is refused + // at once with the OS's code and the path. Node's recursive mkdir, async + // as here too, never settled on it. + makeDirectorySync(resolvePath(outPath, '..')); await writeFile(outPath, blob); printSuccess('Plugin built'); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index e371596371c..be019e79e80 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -9,6 +9,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { printHeader, printKV, printStep, printError } from '../utils/format.js'; +import { makeDirectorySync } from '../utils/make-directory.js'; import { redirectStdoutToStderr } from '../utils/json-stdout.js'; import { redactConnectionUrl } from '../utils/connection-display.js'; import { databaseDriverFlag } from '../utils/database-driver-flag.js'; @@ -186,7 +187,10 @@ export default class Start extends Command { // > ~/.objectstack (global mode) const homeDir = resolveHome(flags.home, { hasProjectConfig, cwd }); try { - fs.mkdirSync(path.join(homeDir, 'data'), { recursive: true }); + // Bounded (#23858): a home that can never be made — a name under + // `/proc` — is refused here, at once, with the OS's code and the path. + // Node's recursive mkdir looped on it in this process, printing nothing. + makeDirectorySync(path.join(homeDir, 'data')); } catch (err: any) { printError(`Cannot create home directory at ${homeDir}: ${err?.message ?? err}`); process.exit(1); @@ -655,7 +659,7 @@ function readOrCreateAuthSecret(homeDir: string): string { } const secret = crypto.randomBytes(32).toString('hex'); try { - fs.mkdirSync(homeDir, { recursive: true }); + makeDirectorySync(homeDir); fs.writeFileSync(secretPath, secret + '\n', { mode: 0o600 }); } catch { // best-effort persist; secret is still returned for this process diff --git a/packages/cli/src/utils/auth-config.ts b/packages/cli/src/utils/auth-config.ts index ac693fc1ede..10251b40186 100644 --- a/packages/cli/src/utils/auth-config.ts +++ b/packages/cli/src/utils/auth-config.ts @@ -2,7 +2,8 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'; +import { readFile, writeFile, chmod } from 'node:fs/promises'; +import { makeDirectorySync } from './make-directory.js'; /** * Authentication configuration stored in ~/.objectstack/credentials.json @@ -70,8 +71,10 @@ export async function writeAuthConfig(config: AuthConfig): Promise { const path = getCredentialsPath(); const dir = join(homedir(), '.objectstack'); - // Ensure directory exists - await mkdir(dir, { recursive: true }); + // Ensure directory exists. Bounded (#23858): a `HOME` under which it can + // never be made is refused at once with the OS's code and the path; Node's + // recursive mkdir never settled on it. + makeDirectorySync(dir); // Write credentials file await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 }); diff --git a/packages/cli/src/utils/cloud-config.ts b/packages/cli/src/utils/cloud-config.ts index b4bf3b78292..7e7d6a2236b 100644 --- a/packages/cli/src/utils/cloud-config.ts +++ b/packages/cli/src/utils/cloud-config.ts @@ -29,9 +29,10 @@ * family this file's token goes only to this file's `url`. */ -import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { chmod, readFile, unlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { makeDirectorySync } from './make-directory.js'; /** Canonical hosted control plane. */ export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai'; @@ -91,7 +92,8 @@ export async function writeCloudConfig(config: CloudConfig): Promise { const path = getCloudCredentialsPath(); const dir = join(homedir(), '.objectstack'); - await mkdir(dir, { recursive: true }); + // Bounded (#23858): see `writeAuthConfig`. + makeDirectorySync(dir); await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 }); try { await chmod(path, 0o600); diff --git a/packages/cli/src/utils/make-directory.test.ts b/packages/cli/src/utils/make-directory.test.ts new file mode 100644 index 00000000000..6dd117d83ab --- /dev/null +++ b/packages/cli/src/utils/make-directory.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `makeDirectorySync` — this package's bounded stand-in for +// `fs.mkdirSync(dir, { recursive: true })` — refuses a directory that can never +// be made at once, with the operating system's code and the path, and makes +// every directory the recursive form makes (#23858). +// +// ## The defect, measured on `origin/main` 0744f94366 +// +// Node's recursive mkdir loops `mkdir("/proc/x") = ENOENT`, `mkdir("/proc") = +// EEXIST`, retry, without end; the synchronous form holds the event loop. +// `OS_HOME=/proc/os-23858-home os start` made 168,465 failing `mkdir` calls in +// 20 s; `os create plugin foo --dir /proc/os-23858-cr/foo` 889,246. +// +// ## Why the `/proc` cases run in ONE bounded child process +// +// A regression to the recursive form would hang this vitest worker rather than +// fail it: no timer fires while the loop holds the event loop. So those cases +// run from source in a `node --import tsx` child bounded by `spawnSync`'s +// timeout with SIGKILL. The async credential writers (`writeAuthConfig`, +// `writeCloudConfig`) are there too: their recursive `mkdir` never SETTLED on +// the same path, which a vitest timeout would end only as an unexplained +// timeout. They need a `/proc` that refuses `mkdir` with ENOENT, so they run +// where `/proc/self` exists (Linux, which CI is). +// +// ## What is asserted +// +// 1. `/proc//…`: the child settles inside the bound and every case +// is refused with `ENOENT`, naming the level that cannot be made — the +// helper itself, and `os login` / `os cloud login`'s credential writers +// under a `HOME` there. +// 2. In-process (the operating system answers these at once whatever the +// walk): a path through a regular file is `ENOTDIR`; a file holding the +// directory's own name is `EEXIST`; a dangling symlink holding it is +// refused, never reported made. +// 3. CONTROL: a creatable path three levels deep is made, and the first +// directory created is returned, as the recursive form returns it. +// 4. CONTROL: an existing directory is accepted and returns `undefined`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { makeDirectorySync } from './make-directory.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(HERE, '../..'); +const SUBJECTS = { + helper: pathToFileURL(resolve(HERE, 'make-directory.ts')).href, + auth: pathToFileURL(resolve(HERE, 'auth-config.ts')).href, + cloud: pathToFileURL(resolve(HERE, 'cloud-config.ts')).href, +}; + +/** The child's whole budget; the loop it guards against never ends. */ +const CHILD_BOUND_MS = 45_000; + +const RUNS_PROC = existsSync('/proc/self'); +const PROC_TOP = `/proc/os-23858-${process.pid}`; + +interface Outcome { + label: string; + outcome: 'returned' | 'rejected'; + code?: string; + message?: string; +} + +let outcomes: Map; +let root: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-23858-cli-')); + if (!RUNS_PROC) return; + const script = [ + 'const subjects = JSON.parse(process.argv[1]);', + 'const top = process.argv[2];', + 'const { makeDirectorySync } = await import(subjects.helper);', + 'const { writeAuthConfig } = await import(subjects.auth);', + 'const { writeCloudConfig } = await import(subjects.cloud);', + 'const out = [];', + 'const record = async (label, fn) => {', + ' try { await fn(); out.push({ label, outcome: "returned" }); }', + ' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }', + '};', + 'await record("helper", () => makeDirectorySync(top + "/a/data"));', + // `homedir()` reads HOME at each call; set here so the child's own + // start-up never looks under /proc. + 'process.env.HOME = top + "-home";', + 'await record("auth", () => writeAuthConfig({ apiUrl: "http://localhost:1", token: "t" }));', + 'await record("cloud", () => writeCloudConfig({ cloudUrl: "http://localhost:1", apiKey: "k", createdAt: "2026-10-16T00:00:00Z" }));', + 'process.stdout.write("\\nMKDIR-OUTCOMES " + JSON.stringify(out) + "\\n");', + ].join('\n'); + const t0 = Date.now(); + const child = spawnSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script, JSON.stringify(SUBJECTS), PROC_TOP], + { + cwd: PKG_ROOT, + env: { + PATH: process.env.PATH ?? '', + HOME: root, + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + }, + encoding: 'utf8', + timeout: CHILD_BOUND_MS, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + }, + ); + if (child.signal) { + throw new Error( + `the /proc cases had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` + + `a directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`, + ); + } + const line = String(child.stdout).split('\n').find((l) => l.startsWith('MKDIR-OUTCOMES ')); + if (!line) { + throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`); + } + const parsed: Outcome[] = JSON.parse(line.slice('MKDIR-OUTCOMES '.length)); + outcomes = new Map(parsed.map((o) => [o.label, o])); +}, CHILD_BOUND_MS + 15_000); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +function outcomeOf(label: string): Outcome { + const found = outcomes.get(label); + if (!found) throw new Error(`no outcome recorded for case '${label}'`); + return found; +} + +function codeOf(fn: () => unknown): unknown { + try { + fn(); + } catch (e) { + return (e as { code?: unknown }).code; + } + return 'returned'; +} + +describe('makeDirectorySync — a directory that can never be made is refused at once (#23858)', () => { + it.runIf(RUNS_PROC)('refuses /proc//… with ENOENT, naming the level that cannot be made', () => { + const o = outcomeOf('helper'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOENT'); + expect(o.message).toContain(`'${PROC_TOP}'`); + }); + + it.runIf(RUNS_PROC)('os login / os cloud login: a HOME under /proc is refused with ENOENT, never left pending', () => { + for (const label of ['auth', 'cloud']) { + const o = outcomeOf(label); + expect(o.outcome, label).toBe('rejected'); + expect(o.code, label).toBe('ENOENT'); + expect(o.message, label).toContain(`'${PROC_TOP}-home'`); + } + }); + + it('refuses a path through a regular file with ENOTDIR, and a file holding the name with EEXIST', () => { + writeFileSync(join(root, 'plain'), ''); + expect(codeOf(() => makeDirectorySync(join(root, 'plain', 'a', 'b')))).toBe('ENOTDIR'); + expect(codeOf(() => makeDirectorySync(join(root, 'plain')))).toBe('EEXIST'); + }); + + it('refuses a dangling symlink holding the name — never reports it made', () => { + symlinkSync(join(root, 'nowhere'), join(root, 'dangling')); + expect(codeOf(() => makeDirectorySync(join(root, 'dangling')))).toBe('EEXIST'); + expect(existsSync(join(root, 'nowhere'))).toBe(false); + }); + + it('CONTROL: a creatable path three levels deep is made, and the first directory created is returned', () => { + const target = join(root, 'made', 'a', 'b'); + expect(makeDirectorySync(target)).toBe(join(root, 'made')); + expect(statSync(target).isDirectory()).toBe(true); + }); + + it('CONTROL: an existing directory is accepted and returns undefined', () => { + mkdirSync(join(root, 'there')); + expect(makeDirectorySync(join(root, 'there'))).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/make-directory.ts b/packages/cli/src/utils/make-directory.ts new file mode 100644 index 00000000000..7fc615b9673 --- /dev/null +++ b/packages/cli/src/utils/make-directory.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { mkdirSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +/** + * `fs.mkdirSync(dir, { recursive: true })`, bounded by the path's depth: make + * `dir` exist, creating each missing level with a NON-recursive `mkdir`, and + * throw the operating system's own error at once for the first level it + * refuses (#23858). + * + * ## Why not `mkdirSync(dir, { recursive: true })` + * + * Node's recursive mkdir (v22.22.0, measured; sync and async alike) never + * returns when a level answers `ENOENT` although its parent exists, which is + * what `/proc` and `/sys` answer for a name they do not have. It loops + * `mkdir("/proc/x") = ENOENT`, `mkdir("/proc") = EEXIST`, retry, and the + * synchronous form holds the event loop while it does. Measured on + * `origin/main` 0744f94366: `OS_HOME=/proc/os-23858-home os start` made + * 168,465 failing `mkdir` calls in 20 s and printed nothing after its header; + * `os create plugin foo --dir /proc/os-23858-cr/foo` and `os init` run in + * `/proc` hung the same way. + * + * ## The walk + * + * The one `driver-sql`'s connect, the runtime's standalone host and `os dev`'s + * database prep already run (#23696, #23737), made synchronous so it can stand + * in for `mkdirSync` where it stood: find the nearest level that exists, then + * create the missing ones top-down. + * + * - `EEXIST` on a created level is a concurrent creator winning the race, and + * it is accepted only when what now stands there is a directory, as the + * recursive form accepts it. Anything else is the refusal, at once. + * - A file holding the nearest existing name is answered by the operating + * system too: `ENOTDIR` from the first level under it, or `EEXIST` from + * asking for `dir` itself. + * + * Every input the recursive form makes, this makes; every input it refuses, + * this refuses, with the operating system's code (`ENOENT`, `ENOTDIR`, + * `EEXIST`, `EACCES`, …) and the path in the message. The one difference is + * the input it never answered at all. + * + * Returns what the recursive form returns: the first directory this call + * created, as an absolute path, or `undefined` when `dir` already existed. + * + * ⛔ Module-internal: no entry of `@objectstack/cli` exports it. + */ +export function makeDirectorySync(dir: string): string | undefined { + const missing: string[] = []; + let level = resolve(dir); + for (;;) { + try { + if (!statSync(level).isDirectory() && missing.length === 0) mkdirSync(level); + break; + } catch (e) { + if (errorCode(e) !== 'ENOENT') throw e; + missing.push(level); + const parent = dirname(level); + if (parent === level) break; + level = parent; + } + } + let first: string | undefined; + for (const each of missing.reverse()) { + try { + mkdirSync(each); + first ??= each; + } catch (e) { + if (errorCode(e) !== 'EEXIST' || !isDirectory(each)) throw e; + } + } + return first; +} + +function errorCode(e: unknown): unknown { + return (e as { code?: unknown } | null)?.code; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/cli/test/remote-api-utils.test.ts b/packages/cli/test/remote-api-utils.test.ts index 60e3b1494d4..1566010ee17 100644 --- a/packages/cli/test/remote-api-utils.test.ts +++ b/packages/cli/test/remote-api-utils.test.ts @@ -3,9 +3,13 @@ import { createApiClient, requireAuth } from '../src/utils/api-client'; import { readAuthConfig, writeAuthConfig, deleteAuthConfig, getCredentialsPath } from '../src/utils/auth-config'; import { formatOutput } from '../src/utils/output-formatter'; import * as fs from 'node:fs/promises'; +import { makeDirectorySync } from '../src/utils/make-directory'; // Mock fs module vi.mock('node:fs/promises'); +// The credentials directory is made through the CLI's bounded walk (#23858), +// which is synchronous `node:fs`: mocked too, so this file writes nothing real. +vi.mock('../src/utils/make-directory'); describe('API Client Utilities', () => { describe('createApiClient', () => { @@ -75,7 +79,7 @@ describe('Auth Config Utilities', () => { describe('writeAuthConfig', () => { it('should write credentials to file with correct permissions', async () => { - const mockMkdir = vi.mocked(fs.mkdir); + const mockMakeDirectory = vi.mocked(makeDirectorySync); const mockWriteFile = vi.mocked(fs.writeFile); const config = { @@ -87,10 +91,7 @@ describe('Auth Config Utilities', () => { await writeAuthConfig(config); - expect(mockMkdir).toHaveBeenCalledWith( - expect.stringContaining('.objectstack'), - { recursive: true } - ); + expect(mockMakeDirectory).toHaveBeenCalledWith(expect.stringContaining('.objectstack')); expect(mockWriteFile).toHaveBeenCalledWith( expect.stringContaining('credentials.json'), diff --git a/packages/cli/test/uncreatable-directory-doors.integration.test.ts b/packages/cli/test/uncreatable-directory-doors.integration.test.ts new file mode 100644 index 00000000000..f0c07616f3a --- /dev/null +++ b/packages/cli/test/uncreatable-directory-doors.integration.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os start` and `os create` refuse a directory that can never be made at + * once, naming the path and the operating system's code, instead of hanging + * in Node's recursive mkdir (#23858). + * + * ## The defect, measured on `origin/main` 0744f94366 (built CLI, Node v22.22.0) + * + * Node's recursive mkdir loops `mkdir("/proc/x") = ENOENT`, `mkdir("/proc") = + * EEXIST`, retry, without end, and the synchronous form holds the event loop: + * + * - `OS_HOME=/proc/os-23858-home os start` printed its header and nothing + * else: 168,465 failing `mkdir` calls in 20 s under `strace`, killed at + * the bound. That is the operator-state class (`--home` / `OS_HOME`). + * - `os create plugin foo --dir /proc/os-23858-cr/foo` printed its + * `Location:` row and hung the same way: 889,246 failing calls in 20 s. + * That is the author-chosen output-path class (`--dir`, `--out`, + * `--output`). + * + * ## Why every run is a bounded child of the BUILT entry + * + * A regression would hang, not fail. Each command runs in its own process + * group with a hard SIGKILL of the whole group at `CHILD_BOUND_MS`, so a + * reintroduced loop fails this file at the bound instead of hanging the suite. + * `bin/run.js` with `NODE_ENV` unset is the entry an operator runs, and it + * starts in about 3 s where the tsx source entry takes about 10 s, so the four + * children (run concurrently) keep this file under the 10 s line. + * + * ## What is asserted — kind, exit code and named subject, never the prose + * + * 1. `os start` under `OS_HOME=/proc/`: settles inside the bound, + * exits 1, and its output names that path and `ENOENT`. + * 2. CONTROL: `os start` under a creatable `OS_HOME` three levels deep makes + * `/data` and goes on to its next refusal. `--port 70000` is that + * refusal: start's own invalid-port door, AFTER the home step and BEFORE + * any `serve` child, so no leg of this file ever boots a server. The + * `/proc` leg passes it too, for the same reason. + * 3. `os create plugin --dir /proc//foo`: settles, exits 1, names the + * path and `ENOENT`. + * 4. CONTROL: `os create plugin --dir /a/b/foo` exits 0 and writes the + * project. + * + * They need a `/proc` that refuses `mkdir` with ENOENT, so the `/proc` legs + * run where `/proc/self` exists (Linux, which CI is). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const RUN_JS = resolve(HERE, '../bin/run.js'); + +/** Each child's whole budget; the loop it guards against never ends. */ +const CHILD_BOUND_MS = 60_000; + +/** Start's own invalid-port refusal — after the home step, before any child. */ +const UNSERVABLE_PORT = '70000'; + +const RUNS_PROC = existsSync('/proc/self'); +const PROC_TOP = `/proc/os-23858-${process.pid}`; + +interface Run { + code: number | null; + signal: NodeJS.Signals | null; + killedAtBound: boolean; + output: string; +} + +let workdir: string; +const runs = new Map(); + +/** Run the built CLI in its own process group, killed whole at the bound. */ +function runCli(args: string[], env: Record): Promise { + return new Promise((resolveRun) => { + const child = spawn(process.execPath, [RUN_JS, ...args], { + cwd: workdir, + detached: true, + // Declared in full: no vitest worker variables, a HOME of this file's + // own, and `NODE_ENV` unset — the built entry must resolve `dist/`. + env: { + PATH: process.env.PATH ?? '', + HOME: join(workdir, 'user-home'), + NO_COLOR: '1', + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + NODE_ENV: undefined, + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (d) => { output += d; }); + child.stderr.on('data', (d) => { output += d; }); + let killedAtBound = false; + const timer = setTimeout(() => { + killedAtBound = true; + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + }, CHILD_BOUND_MS); + child.on('exit', (code, signal) => { + clearTimeout(timer); + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + resolveRun({ code, signal, killedAtBound, output }); + }); + }); +} + +beforeAll(async () => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + workdir = mkdtempSync(join(tmpdir(), 'os-23858-doors-')); + const legs: Array<[string, Promise]> = [ + ['start-control', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: join(workdir, 'home', 'a', 'b', 'c') })], + ['create-control', runCli(['create', 'plugin', 'foo', '--dir', join(workdir, 'create', 'a', 'b', 'foo')], {})], + ]; + if (RUNS_PROC) { + legs.push( + ['start-proc', runCli(['start', '-p', UNSERVABLE_PORT], { OS_HOME: `${PROC_TOP}-home` })], + ['create-proc', runCli(['create', 'plugin', 'foo', '--dir', `${PROC_TOP}-cr/foo`], {})], + ); + } + const settled = await Promise.all(legs.map(([, p]) => p)); + legs.forEach(([label], i) => runs.set(label, settled[i])); +}, CHILD_BOUND_MS + 30_000); + +afterAll(() => { + if (workdir) rmSync(workdir, { recursive: true, force: true }); +}); + +function runOf(label: string): Run { + const run = runs.get(label); + if (!run) throw new Error(`no run recorded for '${label}'`); + if (run.killedAtBound) { + throw new Error( + `'${label}' had not settled after ${CHILD_BOUND_MS} ms and was killed — a directory prep is looping again. ` + + `output tail: ${run.output.slice(-1500)}`, + ); + } + return run; +} + +describe('a directory that can never be made is refused at the door, at once (#23858)', () => { + it.runIf(RUNS_PROC)('os start under OS_HOME=/proc/ exits 1, naming the path and ENOENT', () => { + const run = runOf('start-proc'); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain(`${PROC_TOP}-home`); + expect(run.output).toContain('ENOENT'); + }); + + it('CONTROL: os start under a creatable OS_HOME three levels deep makes /data', () => { + const run = runOf('start-control'); + expect(run.code, run.output).toBe(1); + expect(statSync(join(workdir, 'home', 'a', 'b', 'c', 'data')).isDirectory()).toBe(true); + expect(run.output).not.toContain('ENOENT'); + }); + + it.runIf(RUNS_PROC)('os create --dir /proc//foo exits 1, naming the path and ENOENT', () => { + const run = runOf('create-proc'); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain(`${PROC_TOP}-cr`); + expect(run.output).toContain('ENOENT'); + }); + + it('CONTROL: os create --dir under a creatable path three levels deep writes the project', () => { + const run = runOf('create-control'); + expect(run.code, run.output).toBe(0); + expect(statSync(join(workdir, 'create', 'a', 'b', 'foo', 'package.json')).isFile()).toBe(true); + }); +}); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 47de2192337..1798467dbe0 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -347,6 +347,12 @@ const NOT_A_GATE: Readonly> = { // build's warning count. It reads no stack and refuses nothing: every row // it selects is one `lowerCallables` produced. 'isExtractionRefusal', + // [#23858] Makes the `--output` directory `os build` writes the artifact + // into — the bounded stand-in for the `fs.mkdirSync(…, { recursive: true })` + // that stood there (`fs` is classified below). It reads no stack and + // judges nothing; what it can refuse is a path the operating system + // refuses, with that system's own code. + 'makeDirectorySync', ], // [commit 8b4890343] Reads a fact about the TOOLCHAIN, not about the input. The // ADR-0087 D4 `release` section is computed at publish time from the two diff --git a/packages/runtime/src/artifact-reference.ts b/packages/runtime/src/artifact-reference.ts index c11e341cd14..4f7c675044a 100644 --- a/packages/runtime/src/artifact-reference.ts +++ b/packages/runtime/src/artifact-reference.ts @@ -73,11 +73,12 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { resolve as resolvePath } from 'node:path'; import { fileURLToPath } from 'node:url'; import { checkProtocolCompat } from '@objectstack/metadata-core'; +import { makeDirectorySync } from './make-directory.js'; /** The one environment variable this feature adds. */ export const OS_ARTIFACT_URL_ENV = 'OS_ARTIFACT_URL'; @@ -392,7 +393,10 @@ export function stagedArtifactPath(homeDir: string, redacted: string): string { /** Write bytes to `target` atomically (temp file + rename). */ function writeArtifactFile(target: string, bytes: Uint8Array): void { - mkdirSync(resolvePath(target, '..'), { recursive: true }); + // Bounded (#23858): a cache directory under an `OS_HOME` that cannot be + // made is refused at once, and the caller's OS_ARTIFACT_UNREACHABLE + // refusal names it — never a loop. + makeDirectorySync(resolvePath(target, '..')); const tmp = `${target}.tmp-${process.pid}`; writeFileSync(tmp, bytes); renameSync(tmp, target); diff --git a/packages/runtime/src/default-host.ts b/packages/runtime/src/default-host.ts index c0f7e879952..ea4a67e84c4 100644 --- a/packages/runtime/src/default-host.ts +++ b/packages/runtime/src/default-host.ts @@ -25,9 +25,10 @@ */ import { resolve as resolvePath } from 'node:path'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { createStandaloneStack, resolveObjectStackHome, type StandaloneStackConfig, type StandaloneStackResult } from './standalone-stack.js'; import { isHttpUrl } from './load-artifact-bundle.js'; +import { makeDirectorySync } from './make-directory.js'; export interface DefaultHostConfigOptions extends StandaloneStackConfig { /** @@ -133,7 +134,9 @@ export async function createDefaultHostConfig( const home = resolveObjectStackHome(); const stubPath = resolvePath(home, 'dist/objectstack.json'); if (!existsSync(stubPath)) { - mkdirSync(resolvePath(stubPath, '..'), { recursive: true }); + // Bounded (#23858): an `OS_HOME` that cannot be made is refused + // here, at once, with the OS's code and the path — never a loop. + makeDirectorySync(resolvePath(stubPath, '..')); writeFileSync( stubPath, JSON.stringify( diff --git a/packages/runtime/src/make-directory.test.ts b/packages/runtime/src/make-directory.test.ts new file mode 100644 index 00000000000..4bcb40ebcc8 --- /dev/null +++ b/packages/runtime/src/make-directory.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The runtime's directory preps under `OS_HOME` refuse a directory that can +// never be made at once, with the operating system's code and the path, +// instead of looping in Node's recursive mkdir (#23858). +// +// ## The defect, measured on `origin/main` 0744f94366, at `os serve` +// +// Node's recursive mkdir loops `mkdir("/proc/x") = ENOENT`, `mkdir("/proc") = +// EEXIST`, retry, without end; the synchronous form holds the event loop, so +// no plugin timeout can fire. Each of these was reached with an operator's +// `OS_HOME`: +// +// - `createDefaultHostConfig`'s empty-boot stub artifact +// (`OS_HOME=/proc/os-23858-dh OS_BOOT_EMPTY=1 os serve`): 1,107,199 failing +// `mkdir` calls in 25 s, killed at the bound; +// - `resolveArtifactReference`'s cache for a fetched `OS_ARTIFACT_URL` +// (`OS_HOME=/proc/os-23858-ar`, an `http:` reference): 1,103,490; +// - `setPackageDisabled`'s state file, written by the packages routes' +// enable / disable / install handlers — read, the same call shape. +// +// ## Why the `/proc` cases run in ONE bounded child process +// +// A regression to the recursive form would hang this vitest worker rather than +// fail it. So those cases run from source in a `node --import tsx` child +// bounded by `spawnSync`'s timeout with SIGKILL: a reintroduced loop fails here +// at the bound. They need a `/proc` that refuses `mkdir` with ENOENT, so they +// run where `/proc/self` exists (Linux, which CI is). +// +// ## What is asserted +// +// 1. `/proc//…`, in the child: every subject settles inside the +// bound and is refused with `ENOENT`, naming the level that cannot be +// made. The artifact cache refusal stays the resolver's own +// `OS_ARTIFACT_UNREACHABLE`, now carrying the OS's answer. +// 2. CONTROL, in the same child: under a creatable home three levels deep, +// the empty-boot stub artifact is written and the stack is built, the +// package state file is written and read back, and the fetched artifact +// is cached. +// 3. CONTROL, in-process: the helper makes a creatable path and returns the +// first directory it created. +// +// Every subject but the helper is imported by the CHILD only: their graph +// (`standalone-stack.ts` and what it pulls in) costs this worker ~10 s of +// transform, which tsx in the child pays from its own cache. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { makeDirectorySync } from './make-directory.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(HERE, '..'); +const SUBJECTS = { + helper: pathToFileURL(resolve(HERE, 'make-directory.ts')).href, + defaultHost: pathToFileURL(resolve(HERE, 'default-host.ts')).href, + packageState: pathToFileURL(resolve(HERE, 'package-state-store.ts')).href, + artifact: pathToFileURL(resolve(HERE, 'artifact-reference.ts')).href, +}; + +/** The child's whole budget; the loop it guards against never ends. */ +const CHILD_BOUND_MS = 60_000; + +const RUNS_PROC = existsSync('/proc/self'); +const PROC_TOP = `/proc/os-23858-${process.pid}`; + +/** The smallest artifact the resolver accepts — the empty-boot stub's shape. */ +const STUB_ARTIFACT = JSON.stringify({ + manifest: { id: 'com.example.os23858', name: 'os23858', version: '0.0.0', type: 'app' }, + objects: [], +}); + +interface Outcome { + label: string; + outcome: 'returned' | 'rejected'; + value?: unknown; + code?: string; + message?: string; +} + +let outcomes: Map; +let root: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-23858-runtime-')); + const script = [ + 'const [subjectsJson, top, scratch, stub] = process.argv.slice(1);', + 'const subjects = JSON.parse(subjectsJson);', + 'const { makeDirectorySync } = await import(subjects.helper);', + 'const { createDefaultHostConfig } = await import(subjects.defaultHost);', + 'const { setPackageDisabled } = await import(subjects.packageState);', + 'const { resolveArtifactReference } = await import(subjects.artifact);', + // No `/dist/objectstack.json` may answer for the empty boot. + 'process.chdir(scratch);', + 'const out = [];', + 'const record = async (label, fn) => {', + ' try { const value = await fn(); out.push({ label, outcome: "returned", value: typeof value === "string" || typeof value === "boolean" ? value : undefined }); }', + ' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }', + '};', + // `top` is empty where there is no `/proc` to refuse: the controls alone. + 'if (top) {', + ' await record("helper", () => makeDirectorySync(top + "/a/data"));', + ' process.env.OS_HOME = top + "-dh";', + ' await record("default-host", () => createDefaultHostConfig({ requireArtifact: false }));', + ' process.env.OS_HOME = top + "-ps";', + ' await record("package-state", () => setPackageDisabled(undefined, "com.example.os23858", true));', + ' await record("artifact", () => resolveArtifactReference("http://artifact.invalid/objectstack.json", {', + ' homeDir: top + "-ar",', + ' fetchImpl: async () => new Response(stub, { status: 200 }),', + ' }));', + '}', + 'process.env.OS_HOME = scratch + "/ps-home/a/b";', + 'const { loadDisabledPackageIds } = await import(subjects.packageState);', + 'await record("package-state-control", () => {', + ' setPackageDisabled(undefined, "com.example.os23858", true);', + ' return loadDisabledPackageIds(undefined).has("com.example.os23858");', + '});', + 'await record("artifact-control", async () => (await resolveArtifactReference("http://artifact.invalid/objectstack.json", {', + ' homeDir: scratch + "/ar-home/a/b",', + ' fetchImpl: async () => new Response(stub, { status: 200 }),', + '})).localPath);', + 'process.env.OS_HOME = scratch + "/home/a/b";', + 'await record("default-host-control", () => createDefaultHostConfig({ requireArtifact: false }));', + 'process.stdout.write("\\nMKDIR-OUTCOMES " + JSON.stringify(out) + "\\n");', + // The control built a stack that holds handles; the outcomes are written. + 'process.exit(0);', + ].join('\n'); + const t0 = Date.now(); + const child = spawnSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script, JSON.stringify(SUBJECTS), RUNS_PROC ? PROC_TOP : '', root, STUB_ARTIFACT], + { + cwd: PKG_ROOT, + env: { + PATH: process.env.PATH ?? '', + HOME: root, + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + }, + encoding: 'utf8', + timeout: CHILD_BOUND_MS, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + }, + ); + if (child.signal) { + throw new Error( + `the OS_HOME directory preps had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` + + `a directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`, + ); + } + const line = String(child.stdout).split('\n').find((l) => l.startsWith('MKDIR-OUTCOMES ')); + if (!line) { + throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`); + } + const parsed: Outcome[] = JSON.parse(line.slice('MKDIR-OUTCOMES '.length)); + outcomes = new Map(parsed.map((o) => [o.label, o])); +}, CHILD_BOUND_MS + 15_000); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +function outcomeOf(label: string): Outcome { + const found = outcomes.get(label); + if (!found) throw new Error(`no outcome recorded for case '${label}'`); + return found; +} + +describe('OS_HOME directory preps — a directory that can never be made is refused at once (#23858)', () => { + it.runIf(RUNS_PROC)('the helper refuses /proc//… with ENOENT, naming the level', () => { + const o = outcomeOf('helper'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOENT'); + expect(o.message).toContain(`'${PROC_TOP}'`); + }); + + it.runIf(RUNS_PROC)('createDefaultHostConfig: the empty-boot stub under a /proc OS_HOME is refused with ENOENT', () => { + const o = outcomeOf('default-host'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOENT'); + expect(o.message).toContain(`'${PROC_TOP}-dh'`); + }); + + it.runIf(RUNS_PROC)('setPackageDisabled: the state file under a /proc OS_HOME is refused with ENOENT', () => { + const o = outcomeOf('package-state'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOENT'); + expect(o.message).toContain(`'${PROC_TOP}-ps'`); + }); + + it.runIf(RUNS_PROC)('resolveArtifactReference: the cache under a /proc home is OS_ARTIFACT_UNREACHABLE, carrying ENOENT', () => { + const o = outcomeOf('artifact'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('OS_ARTIFACT_UNREACHABLE'); + expect(o.message).toContain('ENOENT'); + expect(o.message).toContain(`'${PROC_TOP}-ar'`); + }); + + it('CONTROL: under a creatable OS_HOME three levels deep the empty-boot stub is written', () => { + expect(outcomeOf('default-host-control').outcome).toBe('returned'); + expect(statSync(join(root, 'home', 'a', 'b', 'dist', 'objectstack.json')).isFile()).toBe(true); + }); + + it('CONTROL: the package state file is written and read back under a creatable OS_HOME three levels deep', () => { + const o = outcomeOf('package-state-control'); + expect(o.outcome).toBe('returned'); + expect(o.value).toBe(true); + expect(statSync(join(root, 'ps-home', 'a', 'b', 'package-state')).isDirectory()).toBe(true); + }); + + it('CONTROL: a fetched artifact is cached under a creatable home three levels deep', () => { + const o = outcomeOf('artifact-control'); + expect(o.outcome).toBe('returned'); + const localPath = String(o.value); + expect(localPath.startsWith(join(root, 'ar-home', 'a', 'b', 'artifacts'))).toBe(true); + expect(readFileSync(localPath, 'utf8')).toBe(STUB_ARTIFACT); + }); + + it('CONTROL: the helper makes a creatable path and returns the first directory it created', () => { + const target = join(root, 'made', 'a', 'b'); + expect(makeDirectorySync(target)).toBe(join(root, 'made')); + expect(statSync(target).isDirectory()).toBe(true); + expect(makeDirectorySync(target)).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/make-directory.ts b/packages/runtime/src/make-directory.ts new file mode 100644 index 00000000000..496e6dab39e --- /dev/null +++ b/packages/runtime/src/make-directory.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { mkdirSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +/** + * `mkdirSync(dir, { recursive: true })`, bounded by the path's depth: make + * `dir` exist, creating each missing level with a NON-recursive `mkdir`, and + * throw the operating system's own error at once for the first level it + * refuses (#23858). + * + * ## Why not `mkdirSync(dir, { recursive: true })` + * + * Node's recursive mkdir (v22.22.0, measured; sync and async alike) never + * returns when a level answers `ENOENT` although its parent exists, which is + * what `/proc` and `/sys` answer for a name they do not have. It loops + * `mkdir("/proc/x") = ENOENT`, `mkdir("/proc") = EEXIST`, retry, and the + * synchronous form holds the event loop while it does. Measured on + * `origin/main` 0744f94366 at `os serve`, each in the serve process itself: + * under `OS_HOME=/proc/os-23858-dh OS_BOOT_EMPTY=1` the empty-boot stub + * artifact's directory made 1,107,199 failing `mkdir` calls in 25 s, and under + * `OS_HOME=/proc/os-23858-ar` with an `http:` `OS_ARTIFACT_URL` the fetched + * artifact's cache directory made 1,103,490. + * + * ## The walk + * + * The one `driver-sql`'s connect and this package's standalone host already + * run (#23696, #23737; `makeSqliteDatabaseDirectory` in `standalone-stack.ts` + * is that module's own async copy), made synchronous so it can stand in for + * `mkdirSync` where it stood: find the nearest level that exists, then create + * the missing ones top-down. + * + * - `EEXIST` on a created level is a concurrent creator winning the race, and + * it is accepted only when what now stands there is a directory, as the + * recursive form accepts it. Anything else is the refusal, at once. + * - A file holding the nearest existing name is answered by the operating + * system too: `ENOTDIR` from the first level under it, or `EEXIST` from + * asking for `dir` itself. + * + * Every input the recursive form makes, this makes; every input it refuses, + * this refuses, with the operating system's code (`ENOENT`, `ENOTDIR`, + * `EEXIST`, `EACCES`, …) and the path in the message. The one difference is + * the input it never answered at all. + * + * Returns what the recursive form returns: the first directory this call + * created, as an absolute path, or `undefined` when `dir` already existed. + * + * ⛔ Module-internal: `src/index.ts` does not export it, and the package's + * `exports` map names that entry alone. + */ +export function makeDirectorySync(dir: string): string | undefined { + const missing: string[] = []; + let level = resolve(dir); + for (;;) { + try { + if (!statSync(level).isDirectory() && missing.length === 0) mkdirSync(level); + break; + } catch (e) { + if (errorCode(e) !== 'ENOENT') throw e; + missing.push(level); + const parent = dirname(level); + if (parent === level) break; + level = parent; + } + } + let first: string | undefined; + for (const each of missing.reverse()) { + try { + mkdirSync(each); + first ??= each; + } catch (e) { + if (errorCode(e) !== 'EEXIST' || !isDirectory(each)) throw e; + } + } + return first; +} + +function errorCode(e: unknown): unknown { + return (e as { code?: unknown } | null)?.code; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/runtime/src/package-state-store.ts b/packages/runtime/src/package-state-store.ts index 312d4c54e32..133439349f4 100644 --- a/packages/runtime/src/package-state-store.ts +++ b/packages/runtime/src/package-state-store.ts @@ -61,10 +61,11 @@ * legacy file is never deleted here — a machine that rolls back to the previous * release must still find its operator's disables where that release looks. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { basename, dirname, join, resolve } from 'node:path'; +import { makeDirectorySync } from './make-directory.js'; import { resolveObjectStackHome } from './standalone-stack.js'; const DEFAULT_ENVIRONMENT_ID = 'default'; @@ -150,7 +151,10 @@ function readState(environmentId?: string): PackageStateFile { function writeState(environmentId: string | undefined, state: PackageStateFile): void { const file = stateFilePath(environmentId); - mkdirSync(dirname(file), { recursive: true }); + // Bounded (#23858): a state directory under an `OS_HOME` that cannot be + // made throws the OS's answer to the caller instead of holding the event + // loop — the HTTP handlers already catch it and keep the in-memory state. + makeDirectorySync(dirname(file)); writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); } diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 9ed816d1be8..284e2eb05e6 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the nine files that spawn bin/run.js', + t('the built-entrypoint population is exactly the ten files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ // [#21733] Joined the population deliberately: the card's acceptance is // a plain `os dev` restart on the BUILT packages, and its production leg @@ -2672,6 +2672,12 @@ export function selfTest() { // so the tsx shim every `runServe()` caller uses cannot reach it at all. 'packages/cli/test/serve-ready-degraded-boot.e2e.test.ts', 'packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts', + // [#23858] Joined the population deliberately: its four bounded + // children (`os start` and `os create`, each with a creatable-path + // control) must start fast enough to keep the file under the 10 s + // line — about 3 s each from `dist/` against about 10 s through the + // tsx shim, measured. Every spawn hands its child `NODE_ENV: undefined`. + 'packages/cli/test/uncreatable-directory-doors.integration.test.ts', ]), JSON.stringify(builtFiles)); const liveJudgement = live.refusal ? null : judge(live.findings, live.deliberate, readBaseline(), live.deliberateReroute);