From 3c28f00b40a24aae3c36ac26c79c89a655079b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= <16311043+immakermatty@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:49:30 +0200 Subject: [PATCH] fix(server): a start killed while copying V1 no longer leaves its copy on disk The first V2 start copies state.sqlite into a .v2-import-* directory before it publishes the copy. A start killed during the copy never removed that directory, and nothing else did, so every killed attempt left a database-sized directory in userdata. The directory name now carries the copying process ID. Every start removes .v2-import-* directories whose process no longer runs, and the ones from older builds, which carry no ID. Co-Authored-By: Claude Opus 5.5 --- .../persistence/initializeV2Database.test.ts | 19 +++++++++++++++ .../src/persistence/initializeV2Database.ts | 24 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 09b10dfa571e7..9e787f692f733 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -196,3 +197,21 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", () Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), ); }); + +it.effect("removes copies left by a killed start, but not one that is still running", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-")); + // The child has exited, so nothing runs under its process ID. + const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + const running = `.v2-import-${process.pid}-Kq81Zx`; + return Effect.gen(function* () { + for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) { + NodeFS.mkdirSync(NodePath.join(directory, name)); + NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy"); + } + yield* initializeV2Database(NodePath.join(directory, "statev2.sqlite")); + assert.deepEqual(NodeFS.readdirSync(directory), [running]); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), + ); +}); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 7f63bb9c4c137..6441b1f454dd7 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { isProcessAlive } from "../serverRuntimeState.ts"; + export class V2DatabaseImportError extends Schema.TaggedError()( "V2DatabaseImportError", { sourcePath: Schema.String, destinationPath: Schema.String, cause: Schema.Defect() }, @@ -14,6 +16,25 @@ export class V2DatabaseImportError extends Schema.TaggedError error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const copy = /^\.v2-import-(?:(\d+)-)?/.exec(entry); + if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue; + yield* fs.remove(path.join(directory, entry), { recursive: true }); + } +}); + /** Its copied legacy tables remain the source for lazy transcript import. */ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ( destinationPath: string, @@ -22,12 +43,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* const path = yield* Path.Path; const directory = path.dirname(destinationPath); const sourcePath = path.join(directory, "state.sqlite"); + yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true })); yield* Effect.gen(function* () { if (yield* fs.exists(destinationPath)) return; if (!(yield* fs.exists(sourcePath))) return; const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ directory, - prefix: ".v2-import-", + prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); yield* Effect.tryPromise(async () => {