From af9d6bc8b8ed6536e7b07a9f503660aab4f3b127 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 12 Aug 2026 21:50:37 +0200 Subject: [PATCH] fix(cli): create the Maple home before first-run startup touches its siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a machine with no `~/.maple`, `maple start` fails with a bare `ENOENT … mkdir '~/.maple/data.maple-maintenance-lock'`: startup reconciles checkpoint recovery under the maintenance lock before it creates any directory, and the lock — like the PID file, the `--background` log, the restore/reset transactions and the migration journal — is a *sibling* of the data dir, so it lands in a parent that does not exist yet. `acquireMaintenance` has created that parent since v0.0.15, which is why the crash only reproduces on older builds. Make the precondition explicit instead of leaving it to whichever helper happens to run first: create the data dir's parent as `start`'s first filesystem step, and lock the invariant with two regression tests (lock acquisition and recovery reconciliation, both against a data dir whose parent is absent). Both fail with the reported ENOENT when the parent creation is removed. The data dir itself is still created where it was, after recovery reconciliation, so restore/reset reconciliation keeps seeing the tree exactly as it left it. --- apps/cli/src/commands/server.ts | 8 ++++++++ apps/cli/test/archive-pins.test.ts | 17 +++++++++++++++++ apps/cli/test/checkpoints.test.ts | 15 +++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/apps/cli/src/commands/server.ts b/apps/cli/src/commands/server.ts index 3384d7092..17e6301f1 100644 --- a/apps/cli/src/commands/server.ts +++ b/apps/cli/src/commands/server.ts @@ -348,6 +348,14 @@ export const start = Command.make("start", { ) const pidPath = pidFilePath(dataDir) + // First run: `~/.maple` does not exist yet, and everything startup touches + // before the data dir itself — the PID file, the `--background` log, the + // restore/reset transactions, the migration journal, the maintenance lock — + // is a *sibling* of the data dir, i.e. lives in that missing parent. Create + // it up front so first-run ordering is a stated precondition rather than a + // side effect of whichever helper happens to run first. + yield* fs.makeDirectory(dirname(dataDir), { recursive: true }) + // Already-running guard. const existingPid = yield* readPid(fs, pidPath) if (Option.isSome(existingPid) && isProcessAlive(existingPid.value)) { diff --git a/apps/cli/test/archive-pins.test.ts b/apps/cli/test/archive-pins.test.ts index 639ae9eb8..b0e4c742b 100644 --- a/apps/cli/test/archive-pins.test.ts +++ b/apps/cli/test/archive-pins.test.ts @@ -241,6 +241,23 @@ describe("maintenance lock", () => { }) }) + // First run on a clean machine: `~/.maple` does not exist, so the lock — a + // *sibling* of `~/.maple/data` — has no parent to be created in. `maple start` + // reconciles checkpoint recovery under this lock before it creates any + // directory, so an ENOENT here is a hard startup failure, not a warning. + it("acquires the lock when the data dir's parent does not exist yet", async () => { + const root = mkdtempSync(join(tmpdir(), "maple-first-run-lock-test-")) + const dataDir = join(root, ".maple", "data") + try { + ok(!existsSync(dirname(dataDir)), "precondition: the maple home is absent") + const result = await withMaintenanceLock(dataDir, newCheckpointId(), async () => "done") + strictEqual(result, "done") + ok(!existsSync(`${dataDir}.maple-maintenance-lock`), "maintenance lock released") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it("releases the lock even when the task throws", async () => { await withDataDir(async (dataDir) => { await rejects( diff --git a/apps/cli/test/checkpoints.test.ts b/apps/cli/test/checkpoints.test.ts index 720403aa1..d09b6385f 100644 --- a/apps/cli/test/checkpoints.test.ts +++ b/apps/cli/test/checkpoints.test.ts @@ -805,6 +805,21 @@ describe("live restore transaction reconciliation", () => { }) }) + // `maple start` reconciles recovery before it creates any directory, so on a + // clean machine this runs with `~/.maple` itself absent — and every path it + // touches (transaction journal, quarantine, maintenance lock) is a sibling of + // the data dir, living in that missing parent. + it("is a no-op on first run, when the data dir's parent does not exist yet", async () => { + const root = mkdtempSync(join(tmpdir(), "maple-first-run-recovery-test-")) + const dataDir = join(root, ".maple", "data") + try { + ok(!existsSync(dirname(dataDir)), "precondition: the maple home is absent") + await Effect.runPromise(reconcileCheckpointRecovery(dataDir)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it("preserves an interrupted pre-ready restore and leaves the old live store selected", async () => { await withDataDir(async (dataDir) => { const operationId = newCheckpointOperationId()