diff --git a/.changeset/23737-uncreatable-db-dir-refusal.md b/.changeset/23737-uncreatable-db-dir-refusal.md new file mode 100644 index 00000000000..e51a90122f4 --- /dev/null +++ b/.changeset/23737-uncreatable-db-dir-refusal.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +'@objectstack/cli': patch +--- + +fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging + +Clause-②: no + +**What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`). + +**What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error: + +```text +✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x' +``` + +`os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`. + +Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL. diff --git a/packages/cli/src/commands/dev-database-directory.test.ts b/packages/cli/src/commands/dev-database-directory.test.ts new file mode 100644 index 00000000000..892598e0af8 --- /dev/null +++ b/packages/cli/src/commands/dev-database-directory.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `os dev` refuses a unified-default database directory it cannot make — at +// once, naming the database file, the directory and the operating system's +// code — instead of looping in Node's recursive mkdir (#23737). +// +// ## The defect, measured on `origin/main` 43567a8fcb +// +// `os dev` prepared `/data/` for its unified-default SQLite file with +// `fs.mkdirSync(dir, { recursive: true })`. Under `OS_HOME=/proc/os-23737` that +// call looped `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`, +// retry, in the `os dev` process itself: 64,440 failing calls in 25 s under +// `strace`, and the command never reached its next line. +// +// ## Why every case runs in ONE bounded child process +// +// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires and a +// vitest timeout cannot end it. A regression to it would hang this worker +// rather than fail, so `prepareDevDatabaseDirectory` (the function `os dev`'s +// boot awaits) runs from source in a `node --import tsx` child bounded by +// `spawnSync`'s timeout with SIGKILL. All cases share that one child because +// importing `dev.ts` costs about 7 s of the child's wall (measured, cold and +// warm tsx cache alike), and this worker never imports it at all. +// +// ## What is asserted +// +// 1. `/proc//` (where `/proc/self` exists — Linux, which CI is): the +// child settles inside the bound and the refusal carries `ENOENT`, naming +// the file and the directory — the shape that looped. +// 2. A path through a regular file: refused with `ENOTDIR`, naming the file. +// 3. CONTROL: a creatable unified-default path three levels deep is made. +// 4. CONTROL: any other source is left to the serve child — nothing is made. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(HERE, '../..'); +const SUBJECT = pathToFileURL(resolve(HERE, 'dev.ts')).href; + +/** The child's whole budget; the loop it guards against never ends. */ +const CHILD_BOUND_MS = 45_000; + +interface Outcome { + label: string; + outcome: 'returned' | 'rejected'; + code?: string; + message?: string; +} + +const PROC_DIR = `/proc/os-23737-${process.pid}/data`; +const PROC_FILE = `${PROC_DIR}/objectstack.db`; +const RUNS_PROC = existsSync('/proc/self'); + +let root: string; +let outcomes: Map; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-23737-dev-')); + writeFileSync(join(root, 'plain'), ''); + const cases: Array<{ label: string; url: string; source: string }> = [ + ...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []), + { label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' }, + { label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' }, + { label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' }, + ]; + const script = [ + 'const [subject, json] = process.argv.slice(1);', + 'const { prepareDevDatabaseDirectory } = await import(subject);', + 'const out = [];', + 'for (const { label, url, source } of JSON.parse(json)) {', + ' try { await prepareDevDatabaseDirectory({ url, source }); out.push({ label, outcome: "returned" }); }', + ' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }', + '}', + 'process.stdout.write("\\nDEV-DIR-OUTCOMES " + JSON.stringify(out) + "\\n");', + ].join('\n'); + const t0 = Date.now(); + const child = spawnSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, JSON.stringify(cases)], + { + cwd: PKG_ROOT, + env: { + PATH: process.env.PATH ?? '', + ...(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( + `prepareDevDatabaseDirectory had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` + + `the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`, + ); + } + const line = String(child.stdout).split('\n').find((l) => l.startsWith('DEV-DIR-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('DEV-DIR-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('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#23737)', () => { + it.runIf(RUNS_PROC)('refuses /proc// at once with ENOENT, naming the file and the directory', () => { + const o = outcomeOf('proc'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOENT'); + expect(o.message).toContain(`'${PROC_FILE}'`); + expect(o.message).toContain(`'${PROC_DIR}'`); + }); + + it('refuses a path through a regular file with ENOTDIR, naming the file', () => { + const o = outcomeOf('through-file'); + expect(o.outcome).toBe('rejected'); + expect(o.code).toBe('ENOTDIR'); + expect(o.message).toContain(`'${join(root, 'plain', 'data', 'objectstack.db')}'`); + }); + + it('CONTROL: a creatable unified-default path three levels deep is made', () => { + expect(outcomeOf('creatable').outcome).toBe('returned'); + expect(statSync(join(root, 'home', 'a', 'data')).isDirectory()).toBe(true); + }); + + it('CONTROL: any other source is left to the serve child — nothing is made', () => { + expect(outcomeOf('other-source').outcome).toBe('returned'); + expect(existsSync(join(root, 'env'))).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 5ffd2e6d463..c31d6cd0822 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -90,6 +90,69 @@ export async function resolveDevDatabase(opts: { }); } +/** + * Make the directory of `os dev`'s unified-default SQLite database + * (`/data/`), bounded — or reject at once, naming the database + * file, the directory and the operating system's code (#23737). + * + * Only the `unified-default` source is prepared here; every other source is + * the serve child's to open (and its standalone host prepares the directory + * with the same bounded walk). + * + * ## Why not `fs.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. Under + * `OS_HOME=/proc/os-23737` this command looped `mkdir("/proc/os-23737")` ↔ + * `mkdir("/proc")` in its own process (64,440 failing calls in 25 s) and never + * reached its next line. + * + * The walk is the one `driver-sql`'s connect and the runtime's standalone host + * run (neither is on a package entry, hence this module-level copy): find the + * nearest level that exists, create the missing ones top-down with a + * NON-recursive `mkdir`, accept `EEXIST` on a created level (a concurrent + * creator), and reject on anything else. + */ +export async function prepareDevDatabaseDirectory( + resolved: Pick, +): Promise { + if (resolved.source !== 'unified-default') return; + const filename = resolved.url.replace(/^file:/, ''); + const directory = path.resolve(path.dirname(filename)); + try { + const missing: string[] = []; + let level = directory; + for (;;) { + try { + if (!(await fs.promises.stat(level)).isDirectory() && missing.length === 0) await fs.promises.mkdir(level); + break; + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e; + missing.push(level); + const parent = path.dirname(level); + if (parent === level) break; + level = parent; + } + } + for (const each of missing.reverse()) { + try { + await fs.promises.mkdir(each); + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e; + } + } + } catch (e) { + const code = (e as { code?: unknown } | null)?.code; + const reason = e instanceof Error ? e.message : String(e); + throw Object.assign( + new Error( + `The default database '${filename}' cannot be created: its directory '${directory}' could not be made: ${reason}`, + ), + { code: typeof code === 'string' ? code : undefined, filename, directory, cause: e }, + ); + } +} + /** * The `os dev` MCP connect hint — the three lines a reader PASTES (#3167, * #16734). @@ -573,8 +636,15 @@ export default class Dev extends Command { // prompt (CI-safe), never silent (#6469). console.log(chalk.yellow(` ⚠ ${resolvedDb.notice}`)); } - if (resolvedDb.source === 'unified-default') { - fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true }); + // Bounded, and refused at once when the directory cannot be made (#23737). + try { + await prepareDevDatabaseDirectory(resolvedDb); + } catch (error: any) { + printError(error?.message ?? String(error)); + console.error(chalk.yellow( + ' Point OS_HOME at a directory that can be created, or choose the database with --database.', + )); + process.exit(1); } const effectiveDb = resolvedDb.url; // `dev` has a resolved artifact by this point (it compiled one, or was diff --git a/packages/runtime/src/standalone-stack-uncreatable-db-directory.test.ts b/packages/runtime/src/standalone-stack-uncreatable-db-directory.test.ts new file mode 100644 index 00000000000..6bd3a651de1 --- /dev/null +++ b/packages/runtime/src/standalone-stack-uncreatable-db-directory.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The standalone host refuses a SQLite `default` whose directory cannot be +// made — at once, naming the database file, the directory and the operating +// system's code (#23737). +// +// ## The defect, measured on `origin/main` 43567a8fcb +// +// `createStandaloneStack({ databaseUrl: 'file:/proc/os-23737/ctl.db' })` never +// returned. Its `sqlite` arm (and the `sqlite-wasm` arm, reached through +// `wasm-sqlite://` or `OS_DATABASE_DRIVER=sqlite-wasm`) prepared the directory +// with Node's recursive `mkdirSync`, which loops `mkdir("/proc/os-23737") = +// ENOENT`, `mkdir("/proc") = EEXIST`, retry, without end: 19,640 failing calls +// in 10 s under `strace`. At the public door, `os start` with that URL printed +// "Starting server..." and hung until killed. The SQL driver's own bounded +// refusal (#23696) was never reached, because the host looped first. +// +// ## Why the `/proc` cases run in a child process +// +// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires, and a +// vitest test timeout cannot end it either. A regression to it would hang this +// worker rather than fail. So those cases run `createStandaloneStack` from +// source in a `node --import tsx` child, bounded by `spawnSync`'s timeout with +// SIGKILL: a reintroduced loop fails here at the bound, it never hangs the run. +// They need a `/proc` that refuses `mkdir` with ENOENT, so they run where +// `/proc/self` exists (Linux, which CI is). +// +// ## What is asserted +// +// 1. The `/proc` shape, both arms: the child settles inside the bound, and the +// refusal carries the code `ENOENT` and names the file and the directory. +// 2. A path through a regular file (`ENOTDIR`), and a file holding the +// directory's own name (`EEXIST`): refused, naming the file. In-process — +// the operating system answers these at once whatever the walk. +// 3. CONTROL: a creatable path three levels deep is made, in both arms. +// 4. CONTROL: the read-only boot (`sqliteAbsentFile: 'empty-in-memory'`, #6743) +// over an absent file still makes no directory at all. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createStandaloneStack } from './standalone-stack.js'; + +// Pay the first transform of what `createStandaloneStack` imports lazily at +// MODULE LOAD, never inside a clocked `it()` body: clocked windows measure +// behaviour, never loading (`scripts/check-test-source-alias.mjs`, the +// clocked-window rule). +import '@objectstack/service-datasource'; +import '@objectstack/objectql'; +import '@objectstack/metadata'; +import './default-datasource-plugin.js'; +import './app-plugin.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(HERE, '..'); +const SUBJECT = pathToFileURL(resolve(HERE, 'standalone-stack.ts')).href; + +/** + * The child's whole budget. Measured: about 5 s for a cold tsx child that runs + * both arms on this container; the loop it guards against never ends. + */ +const CHILD_BOUND_MS = 30_000; + +/** Env keys these tests write or that could pick another arm; restored after every case. */ +const ENV_KEYS = [ + 'OS_DATABASE_URL', + 'DATABASE_URL', + 'TURSO_DATABASE_URL', + 'OS_DATABASE_DRIVER', + 'OS_HOME', + 'OS_ARTIFACT_PATH', +] as const; +const ORIGINAL_ENV: Record = Object.fromEntries( + ENV_KEYS.map((k) => [k, process.env[k]]), +); + +let root: string; +beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + root = mkdtempSync(join(tmpdir(), 'os-23737-')); + process.env.OS_HOME = root; +}); +afterEach(() => { + try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ } + for (const key of ENV_KEYS) { + const original = ORIGINAL_ENV[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } +}); + +interface Refusal { + name?: string; + code?: string; + filename?: string; + directory?: string; + message: string; +} + +/** The rejection of `pending`; fails the case when it resolves. */ +async function refusalOf(pending: Promise): Promise { + try { + await pending; + } catch (err) { + return err as Refusal; + } + throw new Error('expected a refusal, but createStandaloneStack returned'); +} + +/** The refusal names the file and the directory, and carries the OS code. */ +function expectNamedRefusal(refusal: Refusal, filename: string, directory: string, code: string): void { + expect(refusal.code).toBe(code); + expect(refusal.message).toContain(`'${filename}'`); + expect(refusal.message).toContain(`'${directory}'`); +} + +/** + * Runs `createStandaloneStack` from source once per URL in one child, and + * returns each outcome. The child's environment is declared, not inherited. + */ +function runBoundedChild(urls: string[]): Array<{ url: string; outcome: string } & Partial> { + const script = [ + 'const [subject, ...urls] = process.argv.slice(1);', + 'const { createStandaloneStack } = await import(subject);', + 'const out = [];', + 'for (const url of urls) {', + ' try { await createStandaloneStack({ databaseUrl: url }); out.push({ url, outcome: "returned" }); }', + ' catch (e) { out.push({ url, outcome: "rejected", name: e?.name, code: e?.code, message: String(e?.message) }); }', + '}', + 'process.stdout.write("\\nSTACK-OUTCOMES " + JSON.stringify(out) + "\\n");', + ].join('\n'); + const t0 = Date.now(); + const child = spawnSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, ...urls], + { + cwd: PKG_ROOT, + env: { + PATH: process.env.PATH ?? '', + OS_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( + `createStandaloneStack had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` + + `the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`, + ); + } + const line = String(child.stdout).split('\n').find((l) => l.startsWith('STACK-OUTCOMES ')); + if (!line) { + throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`); + } + return JSON.parse(line.slice('STACK-OUTCOMES '.length)); +} + +describe('createStandaloneStack — a SQLite directory the host cannot make (#23737)', () => { + it.runIf(existsSync('/proc/self'))( + 'refuses /proc// at once in both SQLite arms — the shape that looped', + () => { + const filename = `/proc/os-23737-${process.pid}/a/ctl.db`; + const directory = `/proc/os-23737-${process.pid}/a`; + const outcomes = runBoundedChild([`file:${filename}`, `wasm-sqlite://${filename}`]); + expect(outcomes.map((o) => o.outcome)).toEqual(['rejected', 'rejected']); + for (const outcome of outcomes) { + expectNamedRefusal(outcome as Refusal, filename, directory, 'ENOENT'); + } + }, + CHILD_BOUND_MS + 15_000, + ); + + it('refuses a path through a regular file (ENOTDIR) and a file holding the directory name (EEXIST)', async () => { + writeFileSync(join(root, 'plain'), ''); + + const through = join(root, 'plain', 'sub', 'ctl.db'); + expectNamedRefusal( + await refusalOf(createStandaloneStack({ databaseUrl: `file:${through}` })), + through, + join(root, 'plain', 'sub'), + 'ENOTDIR', + ); + + const at = join(root, 'plain', 'ctl.db'); + expectNamedRefusal( + await refusalOf(createStandaloneStack({ databaseUrl: `wasm-sqlite://${at}` })), + at, + join(root, 'plain'), + 'EEXIST', + ); + }); + + it('CONTROL: a creatable path three levels deep is made, in both arms', async () => { + const native = join(root, 'n', 'a', 'b', 'ctl.db'); + await createStandaloneStack({ databaseUrl: `file:${native}` }); + expect(statSync(dirname(native)).isDirectory()).toBe(true); + + const wasm = join(root, 'w', 'a', 'b', 'ctl.db'); + await createStandaloneStack({ databaseUrl: `wasm-sqlite://${wasm}` }); + expect(statSync(dirname(wasm)).isDirectory()).toBe(true); + }); + + it('CONTROL: the read-only boot over an absent file still makes no directory (#6743)', async () => { + const filename = join(root, 'ro', 'a', 'ctl.db'); + await createStandaloneStack({ databaseUrl: `file:${filename}`, sqliteAbsentFile: 'empty-in-memory' }); + expect(existsSync(join(root, 'ro'))).toBe(false); + }); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 787073f6c5e..ca7e1c0c79a 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -62,8 +62,9 @@ * builds it like postgres. */ -import { resolve as resolvePath } from 'node:path'; -import { mkdirSync, existsSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { existsSync } from 'node:fs'; +import { mkdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { z } from 'zod'; import { stampSearchPinyinEnabled } from '@objectstack/types'; @@ -589,6 +590,91 @@ function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): s return filename; } +/** + * The directory of a file-backed SQLite `default` that this host could not + * make (#23737). Module-private on purpose: no package entry exports it. + * + * `code` is the operating system's (`ENOENT`, `EACCES`, `ENOTDIR`, `EEXIST`, + * …) and `cause` the error itself. The message names the database file and + * the directory, never the URL: refusals in this file name the scheme, never + * the URL, and for the two SQLite schemes the path is all an operator needs. + */ +class SqliteDatabaseDirectoryError extends Error { + readonly code: string | undefined; + readonly filename: string; + readonly directory: string; + /** The error the operating system answered. */ + readonly cause: unknown; + + constructor(filename: string, directory: string, cause: unknown) { + const reason = cause instanceof Error ? cause.message : String(cause); + super( + `[StandaloneStack] SQLite database file '${filename}' cannot be created: ` + + `its directory '${directory}' could not be made: ${reason}`, + ); + this.name = 'SqliteDatabaseDirectoryError'; + const code = (cause as { code?: unknown } | null | undefined)?.code; + this.code = typeof code === 'string' ? code : undefined; + this.filename = filename; + this.directory = directory; + this.cause = cause; + } +} + +/** + * The host's filesystem prep for a file-backed SQLite `default` — ADR-0062's + * #3826 resolution keeps this `mkdir` a host concern — bounded (#23737). + * + * ## 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. For + * `OS_DATABASE_URL=file:/proc/os-23737/ctl.db` it loops + * `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`, retry — + * about 2,000 failing calls a second. The synchronous form held the event loop, + * so `os start` printed "Starting server..." and hung: no plugin start timeout + * could fire, and the SQL driver's own bounded refusal (#23696) was never + * reached. + * + * The walk below is the one `driver-sql`'s connect runs + * (`makeDatabaseDirectory` in `sqlite-database-file.ts`, which that package's + * entry does not export — hence this module-private copy): find the nearest + * level that exists, then create the missing ones top-down with a + * NON-recursive `mkdir`. `EEXIST` on a created level is a concurrent creator + * winning the race and is accepted; any other answer is the refusal, at once. + * A file holding the nearest existing name answers too: `ENOTDIR` from the + * first missing level under it, or `EEXIST` from asking for the directory + * itself. + */ +async function makeSqliteDatabaseDirectory(filename: string): Promise { + const directory = resolvePath(filename, '..'); + try { + const missing: string[] = []; + let level = directory; + for (;;) { + try { + if (!(await stat(level)).isDirectory() && missing.length === 0) await mkdir(level); + break; + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e; + missing.push(level); + const parent = dirname(level); + if (parent === level) break; + level = parent; + } + } + for (const each of missing.reverse()) { + try { + await mkdir(each); + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e; + } + } + } catch (e) { + throw new SqliteDatabaseDirectoryError(filename, directory, e); + } +} + /** Which database a standalone boot would talk to, and how. */ export interface ResolvedStandaloneDatabase { url: string; @@ -800,7 +886,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro driverId = 'sqlite-wasm'; const filename = sqliteFilenameFromUrl(dbUrl, 'sqlite-wasm'); if (filename !== ':memory:') { - mkdirSync(resolvePath(filename, '..'), { recursive: true }); + await makeSqliteDatabaseDirectory(filename); } driverConfig = { filename }; } else if (dbDriver === 'sqlite') { @@ -812,9 +898,10 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // of the write side effect `os migrate plan` was leaving behind, and a // `mkdir -p` here would recreate the very directory the driver is about // to decline to put a file in. When the file DOES exist the directory - // does too, so this costs the mode nothing. + // does too, so this costs the mode nothing. Bounded, and refused at once + // when the directory cannot be made (#23737, see the helper). if (!(cfg.sqliteAbsentFile === 'empty-in-memory' && !existsSync(filename))) { - mkdirSync(resolvePath(filename, '..'), { recursive: true }); + await makeSqliteDatabaseDirectory(filename); } driverConfig = { filename }; } else {