|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// `os dev` refuses a unified-default database directory it cannot make — at |
| 4 | +// once, naming the database file, the directory and the operating system's |
| 5 | +// code — instead of looping in Node's recursive mkdir (#23737). |
| 6 | +// |
| 7 | +// ## The defect, measured on `origin/main` 43567a8fcb |
| 8 | +// |
| 9 | +// `os dev` prepared `<state dir>/data/` for its unified-default SQLite file with |
| 10 | +// `fs.mkdirSync(dir, { recursive: true })`. Under `OS_HOME=/proc/os-23737` that |
| 11 | +// call looped `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`, |
| 12 | +// retry, in the `os dev` process itself: 64,440 failing calls in 25 s under |
| 13 | +// `strace`, and the command never reached its next line. |
| 14 | +// |
| 15 | +// ## Why every case runs in ONE bounded child process |
| 16 | +// |
| 17 | +// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires and a |
| 18 | +// vitest timeout cannot end it. A regression to it would hang this worker |
| 19 | +// rather than fail, so `prepareDevDatabaseDirectory` (the function `os dev`'s |
| 20 | +// boot awaits) runs from source in a `node --import tsx` child bounded by |
| 21 | +// `spawnSync`'s timeout with SIGKILL. All cases share that one child because |
| 22 | +// importing `dev.ts` costs about 7 s of the child's wall (measured, cold and |
| 23 | +// warm tsx cache alike), and this worker never imports it at all. |
| 24 | +// |
| 25 | +// ## What is asserted |
| 26 | +// |
| 27 | +// 1. `/proc/<missing>/` (where `/proc/self` exists — Linux, which CI is): the |
| 28 | +// child settles inside the bound and the refusal carries `ENOENT`, naming |
| 29 | +// the file and the directory — the shape that looped. |
| 30 | +// 2. A path through a regular file: refused with `ENOTDIR`, naming the file. |
| 31 | +// 3. CONTROL: a creatable unified-default path three levels deep is made. |
| 32 | +// 4. CONTROL: any other source is left to the serve child — nothing is made. |
| 33 | + |
| 34 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 35 | +import { spawnSync } from 'node:child_process'; |
| 36 | +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; |
| 37 | +import { tmpdir } from 'node:os'; |
| 38 | +import { dirname, join, resolve } from 'node:path'; |
| 39 | +import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 40 | + |
| 41 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 42 | +const PKG_ROOT = resolve(HERE, '../..'); |
| 43 | +const SUBJECT = pathToFileURL(resolve(HERE, 'dev.ts')).href; |
| 44 | + |
| 45 | +/** The child's whole budget; the loop it guards against never ends. */ |
| 46 | +const CHILD_BOUND_MS = 45_000; |
| 47 | + |
| 48 | +interface Outcome { |
| 49 | + label: string; |
| 50 | + outcome: 'returned' | 'rejected'; |
| 51 | + code?: string; |
| 52 | + message?: string; |
| 53 | +} |
| 54 | + |
| 55 | +const PROC_DIR = `/proc/os-23737-${process.pid}/data`; |
| 56 | +const PROC_FILE = `${PROC_DIR}/objectstack.db`; |
| 57 | +const RUNS_PROC = existsSync('/proc/self'); |
| 58 | + |
| 59 | +let root: string; |
| 60 | +let outcomes: Map<string, Outcome>; |
| 61 | + |
| 62 | +beforeAll(() => { |
| 63 | + root = mkdtempSync(join(tmpdir(), 'os-23737-dev-')); |
| 64 | + writeFileSync(join(root, 'plain'), ''); |
| 65 | + const cases: Array<{ label: string; url: string; source: string }> = [ |
| 66 | + ...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []), |
| 67 | + { label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' }, |
| 68 | + { label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' }, |
| 69 | + { label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' }, |
| 70 | + ]; |
| 71 | + const script = [ |
| 72 | + 'const [subject, json] = process.argv.slice(1);', |
| 73 | + 'const { prepareDevDatabaseDirectory } = await import(subject);', |
| 74 | + 'const out = [];', |
| 75 | + 'for (const { label, url, source } of JSON.parse(json)) {', |
| 76 | + ' try { await prepareDevDatabaseDirectory({ url, source }); out.push({ label, outcome: "returned" }); }', |
| 77 | + ' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }', |
| 78 | + '}', |
| 79 | + 'process.stdout.write("\\nDEV-DIR-OUTCOMES " + JSON.stringify(out) + "\\n");', |
| 80 | + ].join('\n'); |
| 81 | + const t0 = Date.now(); |
| 82 | + const child = spawnSync( |
| 83 | + process.execPath, |
| 84 | + ['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, JSON.stringify(cases)], |
| 85 | + { |
| 86 | + cwd: PKG_ROOT, |
| 87 | + env: { |
| 88 | + PATH: process.env.PATH ?? '', |
| 89 | + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), |
| 90 | + }, |
| 91 | + encoding: 'utf8', |
| 92 | + timeout: CHILD_BOUND_MS, |
| 93 | + killSignal: 'SIGKILL', |
| 94 | + maxBuffer: 16 * 1024 * 1024, |
| 95 | + }, |
| 96 | + ); |
| 97 | + if (child.signal) { |
| 98 | + throw new Error( |
| 99 | + `prepareDevDatabaseDirectory had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` + |
| 100 | + `the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`, |
| 101 | + ); |
| 102 | + } |
| 103 | + const line = String(child.stdout).split('\n').find((l) => l.startsWith('DEV-DIR-OUTCOMES ')); |
| 104 | + if (!line) { |
| 105 | + throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`); |
| 106 | + } |
| 107 | + const parsed: Outcome[] = JSON.parse(line.slice('DEV-DIR-OUTCOMES '.length)); |
| 108 | + outcomes = new Map(parsed.map((o) => [o.label, o])); |
| 109 | +}, CHILD_BOUND_MS + 15_000); |
| 110 | + |
| 111 | +afterAll(() => { |
| 112 | + if (root) rmSync(root, { recursive: true, force: true }); |
| 113 | +}); |
| 114 | + |
| 115 | +function outcomeOf(label: string): Outcome { |
| 116 | + const found = outcomes.get(label); |
| 117 | + if (!found) throw new Error(`no outcome recorded for case '${label}'`); |
| 118 | + return found; |
| 119 | +} |
| 120 | + |
| 121 | +describe('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#23737)', () => { |
| 122 | + it.runIf(RUNS_PROC)('refuses /proc/<missing>/ at once with ENOENT, naming the file and the directory', () => { |
| 123 | + const o = outcomeOf('proc'); |
| 124 | + expect(o.outcome).toBe('rejected'); |
| 125 | + expect(o.code).toBe('ENOENT'); |
| 126 | + expect(o.message).toContain(`'${PROC_FILE}'`); |
| 127 | + expect(o.message).toContain(`'${PROC_DIR}'`); |
| 128 | + }); |
| 129 | + |
| 130 | + it('refuses a path through a regular file with ENOTDIR, naming the file', () => { |
| 131 | + const o = outcomeOf('through-file'); |
| 132 | + expect(o.outcome).toBe('rejected'); |
| 133 | + expect(o.code).toBe('ENOTDIR'); |
| 134 | + expect(o.message).toContain(`'${join(root, 'plain', 'data', 'objectstack.db')}'`); |
| 135 | + }); |
| 136 | + |
| 137 | + it('CONTROL: a creatable unified-default path three levels deep is made', () => { |
| 138 | + expect(outcomeOf('creatable').outcome).toBe('returned'); |
| 139 | + expect(statSync(join(root, 'home', 'a', 'data')).isDirectory()).toBe(true); |
| 140 | + }); |
| 141 | + |
| 142 | + it('CONTROL: any other source is left to the serve child — nothing is made', () => { |
| 143 | + expect(outcomeOf('other-source').outcome).toBe('returned'); |
| 144 | + expect(existsSync(join(root, 'env'))).toBe(false); |
| 145 | + }); |
| 146 | +}); |
0 commit comments