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()