Repository navigation
fix(cli,runtime): refuse an uncreatable state, output or cache directory at once instead of looping in recursive mkdir - #23979
Conversation
…looping in recursive mkdir Every recursive mkdir in packages/cli and packages/runtime that an operator- or author-supplied path reaches now goes through a bounded, module-internal walk (makeDirectorySync, one per package, exported from no package entry): find the nearest existing level, create the missing ones top-down with a non-recursive mkdir, and throw the operating system's own error at once for the first level it refuses. Node's recursive mkdir never returns on a level that answers ENOENT while its parent exists (a name under /proc), and the sync form held the event loop: OS_HOME=/proc/... os start hung after its header. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…d in bounded children; changeset Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…ls on every platform Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
… the built-entrypoint census Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…cursive-mkdir-family Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…dger; the credentials test mocks the bounded walk Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 45 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 690891272e0658bad59b58b2228616f6b388529a && git checkout 690891272e0658bad59b58b2228616f6b388529a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b6956490a1697bf6d5f3cda468d2ee3dd37d786 a2ef81b41b6b39b25e4316452d93da51e6794e7f && git checkout -B drift-repro 0b6956490a1697bf6d5f3cda468d2ee3dd37d786 && git merge --no-ff a2ef81b41b6b39b25e4316452d93da51e6794e7f
node scripts/docs-audit/affected-docs.mjs --json 0b6956490a1697bf6d5f3cda468d2ee3dd37d786
|
席位核对ACCEPT · #23858 (
Seat ruling on Carried by this seat on #23858, which stays open: Landing: once every check on the head is green or an expected skip, this seat flips the PR ready and arms auto-merge through the relay, then follows it to Generated by Claude Code |
Part of #23858
Clause-②: no
Part of, not a full close:packages/cli/src/commands/serve.ts:978(the runtime state file's directory) is in scope by the classification below and measured hanging, but it sits inside seat 1's claim on #23743 (publishBoundPortand the state-file write it drives), so this PR does not edit it. The dispatching seat carries:978to after #23743 lands; #23858 remains open for that one site.What was wrong
Node's recursive
mkdir(v22.22.0, sync and async alike) never returns when a level answersENOENTwhile its parent exists./procand/sysanswer that way for a name they do not have. It loopsmkdir("/proc/x") = ENOENT,mkdir("/proc") = EEXIST, retry. The synchronous form holds the event loop, so no timer or plugin timeout can fire. Every recursivemkdirinpackages/cliandpackages/runtimethat an operator- or author-supplied path reaches had this shape.What changed
makeDirectorySync(dir)lives inpackages/cli/src/utils/make-directory.tsandpackages/runtime/src/make-directory.ts. It is the bounded walkdriver-sql(makeDatabaseDirectory) and PR fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 (makeSqliteDatabaseDirectory,prepareDevDatabaseDirectory) already run, made synchronous so it replacesmkdirSyncwhere it stood:mkdir;EEXISTon a created level only when a directory now stands there (a concurrent creator). The recursive form accepts exactly that. The fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 copies accept anyEEXIST, so a dangling symlink there read as made;codeis intact, and its message names the level that cannot be made.undefined, the same asmkdirSync(…, { recursive: true }).os generate's rollback (generate.ts:1805) reads that value.@objectstack/runtime'sexportsnames.alone andsrc/index.tsdoes not export the module; the builtdist/index.d.tshas 0 hits formakeDirectorySync.@objectstack/cli'sexportsnames.,./consoleand./hook-body, and none of them reaches the module. Nopackages/specedit, no new error code, and noERROR_CODE_LEDGERrow. The refusal is the operating system's code, as runtime/cli: os start on an OS_DATABASE_URL sqlite file whose directory cannot be made hangs with no timeout: createStandaloneStack's synchronous recursive mkdirSync loops on ENOENT under an existing parent (the #23696 class, a second producer) #23737's was.Cannot create home directory at …, the artifact resolver'sOS_ARTIFACT_UNREACHABLE, create'sFailed to create project:, oclif's error block forinit, and so on..changeset/23858-uncreatable-directory-refusal.md:@objectstack/clipatch,@objectstack/runtimepatch,Clause-②: no.test/remote-api-utils.test.tsasserted the credentials directory went through a mockedfs.promises.mkdir(…, { recursive: true }). It now mocks the bounded walk and asserts that, so it writes nothing real.test/validate-build-gate-parity.test.tsrequires everycompile.tscallee to be classified.makeDirectorySyncjoins the "local helpers, no diagnostic of its own" bucket.scripts/check-cli-test-child-env.mjs. Its self-test pins the set of test files that spawn the builtbin/run.js("so a file joining or leaving the population has to be seen by a PR"). The door pin joins that set, nine → ten, with a reason line in the same form as the five earlier joiners. The tsx entry would have kept the pin out of the set, but it costs about 10 s per child against about 3 s, measured, which would put the file over the 10 s line.The family, classified
Instrument, on
origin/main0744f94366(this branch's base):git grep -nE "mkdir(Sync)?\(.*recursive: *true"overpackages/cli/srcandpackages/runtime/src, test files excluded. That gives 24 lines: 21 synchronous lines (two of them are doc comments,dev.ts:102andstandalone-stack.ts:677) and 3 asyncmkdircalls. Calls wrapped across lines were searched with a multilinemkdir(Sync)?\([^;]*\n[^;]*recursiveover both trees: 0 code hits (only the two doc comments). That leaves 22 code sites.In scope = an operator- or author-supplied path reaches the site. Out = the path is creatable by construction.
0744f94366)cli/src/commands/start.ts:189HOME_DIR/data--home/OS_HOME/ cwdcli/src/commands/start.ts:658HOME_DIR(auth secret)HOME_DIR/datacli/src/commands/serve.ts:978dirname(runtimeFile)=OS_HOMEOS_HOMEcli/src/commands/compile.ts:992artifactDir--outputcli/src/commands/create.ts:730targetDir--dir, or the name under cwdcli/src/commands/create.ts:738template dirstargetDir, which site 5 just madecli/src/commands/dev.ts:571freshHome/datamkdtempSync(os.tmpdir()/…), just made. An uncreatableTMPDIRis refused bymkdtempSyncitself, which is not recursivecli/src/commands/dev.ts:576freshHome/uploadscli/src/commands/generate.ts:1805fullDir--dir(joined to cwd;..climbs out)cli/src/commands/generate.ts:2227typesoutDir--outputcli/src/commands/generate.ts:2374clientoutDir--outputcli/src/commands/generate.ts:4006migrationoutDir--outputcli/src/commands/i18n/extract.ts:799resolvedOutDir--outcli/src/commands/init.ts:1121template src dirstargetDir, which had just taken theobjectstack.config.tswritecli/src/commands/init.ts:1265startCwd/NAMEcli/src/utils/build-runtime.ts:198outputDircompile.ts:1070passesartifactDir, which site 4 made or refusedcli/src/commands/plugin/build.ts:221(async)--outcli/src/utils/auth-config.ts:74(async)HOME/.objectstackHOME(os login)cli/src/utils/cloud-config.ts:94(async)HOME/.objectstackHOME(os cloud login)runtime/src/default-host.ts:136OS_HOME/dist(empty-boot stub)OS_HOMEruntime/src/package-state-store.ts:153OS_HOME/package-stateOS_HOME, through the packages routes (install,PATCH …/enable,PATCH …/disable)os servetoday site 3 hangs first)runtime/src/artifact-reference.ts:395OS_HOME/artifactsOS_HOMEwith anhttp(s):OS_ARTIFACT_URLcli/src/utils/dev-organization-ownership-upgrade.ts:128backups/organization-ownership-STAMPos devis upgrading, which exists and was just opened. Landed onmainafter this branch's base (PR #23906) and arrived with the merge oforigin/mainTotals: 17 in scope, 16 bounded here, 1 held off (site 3); 6 out (sites 6, 7, 8, 14, 16 and 23). The same instrument re-run on the merged tree finds exactly these 23 code sites and no call wrapped across lines.
standalone-stack.tsanddev.ts's database prep were already bounded by PR #23851 and are not touched.Measured at the public door
Built CLI on this container, Node v22.22.0. Each run is in its own process group, killed whole at the bound by a harness.
strace -f -e trace=mkdir,mkdiratcounts the calls.origin/main0744f94366OS_HOME=/proc/os-23858-home os startmkdirin 20 s, killed at the boundmkdir:✗ Cannot create home directory at /proc/os-23858-home: ENOENT: no such file or directory, mkdir '/proc/os-23858-home'OS_HOME=SCRATCH/a/b/c os start✓ Server is ready; home made (data,dist, state file)✓ Server is ready; the same five entries in the homeOS_HOME=/proc/os-23858-dh OS_BOOT_EMPTY=1 os servemkdir("/proc/os-23858-dh/dist")… in 25 s✗ ENOENT: no such file or directory, mkdir '/proc/os-23858-dh'OS_HOME=/proc/os-23858-ar OS_ARTIFACT_URL=http://127.0.0.1:PORT/objectstack.json os serve✗ Cannot boot from OS_ARTIFACT_URL.…could not write it to /proc/os-23858-ar/artifacts/staged-….json: ENOENT: …, mkdir '/proc/os-23858-ar'os create plugin foo --dir /proc/os-23858-cr/fooLocation:; 889,246 in 20 sENOENT: no such file or directory, mkdir '/proc/os-23858-cr'os create plugin foo --dir SCRATCH/create-ctl/a/b/fooos init os-23858-init, cwd/procError: ENOENT: …, mkdir '/proc/os-23858-init',Code: ENOENTOS_HOME=/proc/os-23858-sv OS_ARTIFACT_PATH=… OS_DATABASE_URL=file:… os serve(site 3, held off)Server is ready: 2,019,800 failingmkdir("/proc/os-23858-sv")in 45 sPins
Each
/proccase runs in a child bounded with SIGKILL, so a regression fails at the bound instead of hanging the suite. The/proccases run where/proc/selfexists (Linux, which CI is).packages/cli/test/uncreatable-directory-doors.integration.test.ts(integrationtier: it spawns the builtbin/run.js). Four concurrent children, each in its own process group, killed whole at 60 s:os startunderOS_HOME=/proc/MISSING: exit 1, and the output names the path andENOENT;os startunder a creatableOS_HOMEthree levels deep makesHOME_DIR/data.--port 70000is start's own invalid-port refusal, after the home step and before anyservechild, so no leg boots a server;os create plugin --dir /proc/MISSING/foo: exit 1, naming the path andENOENT;os create --dirthree levels deep exits 0 and writes the project.packages/cli/src/utils/make-directory.test.ts(unittier):node --import tsxchild (45 s) runs the helper on/proc/MISSING/…pluswriteAuthConfigandwriteCloudConfigunderHOME=/proc/MISSING. Each isENOENT, naming the level;ENOTDIRthrough a file,EEXISTfor a file holding the name, a dangling symlink refused;undefined.packages/runtime/src/make-directory.test.ts(localproject):node --import tsxchild (60 s) runs everyOS_HOMEsubject: the helper;createDefaultHostConfig({ requireArtifact: false }),setPackageDisabled, andresolveArtifactReferencewith an injectedfetchImpl→ENOENT, with the resolver'sOS_ARTIFACT_UNREACHABLEcarryingENOENTand the path;Negative control (ablation)
The ablation ran on the committed implementation, head
d188c036d. The three pinned files and both helpers are byte-identical on this PR's head. Both helpers were mutated at once throughscripts/ablation-replace.mjs, nested, inside one run under the verify lock:const missing: string[] = [];(x1 → x0) becamereturn mkdirSync(dir, { recursive: true }) ?? undefined;followed byconst missing: Array<string> = [];(x0 → x1). That return is the recursive call the walk replaced.7fc615b9673a→abd314b12e8b.496e6dab39ee→5f636bc5557a.dist/.pnpm --filter @objectstack/cli build: exit 0.node scripts/ablation-dist-preflight.mjs @objectstack/cli 'recursive: true }) ?? undefined': exit 0, marker present inpackages/cli/dist/utils/make-directory.js. The door pin runsdist/; the other two run source through tsx.'start-proc' had not settled after 60000 ms and was killed, and the same for'create-proc'. Both CONTROLs stayed green.the /proc cases had not settled after 45047 ms (child killed by SIGKILL), file FAIL in 49 s.the OS_HOME directory preps had not settled after 60087 ms (child killed by SIGKILL), file FAIL in 62 s.git diff HEADempty. After a CLI rebuild,ablation-dist-preflight.mjs … --absentfound the marker absent from all 648 built files and the whole tree clean. The three files re-ran green: 4/4, 6/6, 8/8.Tests and gates
Every exit code below was captured before any pipe.
origin/main, ond188c036d:pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2: exit 0 (61 tasks, 11 cached).pnpm --filter @objectstack/runtime run typecheck: exit 0.pnpm --filter @objectstack/cli run typecheck: exit 0.check:test-typecheckis OK for both, with no new test-layer debt.pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local(the wholelocalproject): exit 0. 403 files and 6,130 tests passed, 19 skipped.pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit(the wholeunittier): exit 1. 302 of 304 files passed; 3 tests failed in 2 files, and both pinned what this PR replaces (see What changed):test/remote-api-utils.test.tsasserted a mockedfs.promises.mkdir(…, { recursive: true });test/validate-build-gate-parity.test.tsfound the unclassifiedmakeDirectorySync.a2ef81b41. Re-run there, the two files plussrc/utils/make-directory.test.tsgive 3 files, 49 tests passed, exit 0; CLI typecheck exit 0.--project integration, file wall 6.37 s); runtime 8/8 (file wall 3.82 s).pnpm exec eslint --no-inline-config --max-warnings=0over the 20 changed.ts/.mjsfiles: exit 0. The repo-wide run is CI'sLint & Repo Gates.node scripts/check-cli-test-child-env.mjs --self-test: exit 0, 152 cases. The audit: exit 0, all 10 built-CLI spawns keep their child out of development/test.a2ef81b41:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 87 commands. All 87 ran: 87 exit 0, 0 exit 3, 0 red.--ranreconciliation:87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED, exit 0.check:dual-build-cjs-loadsandcheck:i18n-coveragewere measured too: the closure build made their prerequisites present.packages/cli'sintegrationtier, apart from the new door pin. No other integration-tier file and no spawn entry is touched.test:repoproject.Acceptance notes
driver-sql'smakeDatabaseDirectory(async);makeSqliteDatabaseDirectory(async,standalone-stack.ts) and this PR'smakeDirectorySync;prepareDevDatabaseDirectory(async,dev.ts) and this PR'smakeDirectorySync.standalone-stack.tsanddev.ts, both fenced off this card, so it is left to a later card in this lane.EEXISTstrictness is new, and this PR does not apply it to the existing copies. The fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 copies treat anyEEXISTon a created level as made, including a dangling symlink, which the recursive form refuses withENOENT. This PR's copies accept it only when a directory stands there. That case is never a hang, so the older copies are not in this card's class. It is noted here, not filed.EROFSwhere Node answeredENOENT,EEXISTfor a dangling symlink. A path through a file can now namestatinstead ofmkdir. No input that succeeded before is refused (Clause-②: no).os startlatency. The refusal arrives after the CLI's own start-up, about 3 to 4 s wall on this box. The walk itself takes well under a millisecond.plugin/build.ts,auth-config.ts,cloud-config.ts). It makes a handful of syscalls on a one-shot CLI path, and one copy per package is what the claim asked for.Generated by Claude Code