rea/tests/boundary/sqlite/sqliteDatabaseBusy.test.ts

160 lines
5.9 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { access } from "node:fs/promises";
import { expect, it, onTestFinished, vi } from "vitest";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js";
import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
const busy = vi.hoisted(() => ({ path: "" }));
// Node opens files with full sharing, so a test process cannot hold one without read sharing
// itself. This injects the error Windows returns for that sharing violation.
vi.mock("node:fs/promises", async (importOriginal) => {
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
return {
...filesystem,
open: async (...args: Parameters<typeof filesystem.open>) => {
if (busy.path !== "" && args[0] === busy.path)
throw Object.assign(
new Error(`EBUSY: resource busy or locked, open '${busy.path}'`),
{ code: "EBUSY", errno: -4082, syscall: "open", path: busy.path },
);
return filesystem.open(...args);
},
};
});
// Private roots go under the test's own workspace, so the cleanup check sees only this
// provider's root and not those of SQLite tests running in parallel.
const createProvider = (parent: string, roots: string[]) =>
new SqliteDatabaseProvider(process.env, undefined, async () => {
const root = await PrivateRuntimeRoot.create({ parent });
roots.push(root.path);
return root;
});
const expectRetryableBusy = (
error: Parameters<typeof projectAnalysisError>[0],
path: string,
) =>
expect(projectAnalysisError(error)).toMatchObject({
code: "capability_unavailable",
category: "unavailable",
retryable: true,
message:
"The selected SQLite database is held open by another process without read sharing. Close that application or inspect a copy, then retry.",
details: {
provider_id: "sqlite",
operation: "inspect_sqlite_database",
reason: `Selected database file set is in use by another process (EBUSY): ${path}`,
},
});
const expectRootsRemoved = async (roots: string[]) => {
expect(roots).toHaveLength(1);
await expect(access(roots[0]!)).rejects.toMatchObject({ code: "ENOENT" });
};
const stopHolder = async (holder: ChildProcess) => {
// A holder that never started has no pid and emits only "error", never "exit".
if (
holder.pid === undefined ||
holder.exitCode !== null ||
holder.signalCode !== null
)
return;
const exited = new Promise<void>((resolve) =>
holder.once("exit", () => resolve()),
);
holder.kill();
await exited;
};
it("reports a selected database held by another process as retryable and unavailable", async () => {
const workspace = await createTestWorkspace("rea-sqlite-busy-");
const database = createSqliteDatabaseFixture(workspace.root);
database.close();
const roots: string[] = [];
const provider = createProvider(workspace.root, roots);
onTestFinished(async () => {
busy.path = "";
await provider.close();
await removeTestWorkspace(workspace.root);
});
busy.path = database.path;
const result = await provider.inspect({ path: database.path });
if (result.ok) throw new Error("Expected a busy database rejection");
expectRetryableBusy(result.error, database.path);
await expectRootsRemoved(roots);
});
it("names the WAL when another process holds that member of the file set", async () => {
const workspace = await createTestWorkspace("rea-sqlite-busy-wal-");
const database = createSqliteDatabaseFixture(workspace.root, true);
const roots: string[] = [];
const provider = createProvider(workspace.root, roots);
onTestFinished(async () => {
busy.path = "";
await provider.close();
database.close();
await removeTestWorkspace(workspace.root);
});
busy.path = database.walPath;
const result = await provider.inspect({ path: database.path });
if (result.ok) throw new Error("Expected a busy WAL rejection");
expectRetryableBusy(result.error, database.walPath);
await expectRootsRemoved(roots);
});
it.runIf(process.platform === "win32")(
"reports a database that another Windows process holds without sharing as retryable",
async () => {
const workspace = await createTestWorkspace("rea-sqlite-busy-native-");
const database = createSqliteDatabaseFixture(workspace.root);
database.close();
const roots: string[] = [];
const provider = createProvider(workspace.root, roots);
const literal = database.path.replaceAll("'", "''");
const holder = spawn(
"powershell.exe",
[
"-NoProfile",
"-Command",
`$f = [System.IO.File]::Open('${literal}', 'Open', 'ReadWrite', 'None'); Write-Output ready; Start-Sleep -Seconds 60; $f.Close()`,
],
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
);
// One ordered cleanup: the holder must release the file before the workspace is removed.
onTestFinished(async () => {
await stopHolder(holder);
await provider.close();
await removeTestWorkspace(workspace.root);
});
await new Promise<void>((resolve, reject) => {
holder.stdout?.once("data", () => resolve());
holder.once("error", (error) =>
reject(new Error("Holder did not start", { cause: error })),
);
holder.once("exit", (code) =>
reject(
new Error(`Holder exited before opening the file (${String(code)})`),
),
);
});
const result = await provider.inspect({ path: database.path });
if (result.ok) throw new Error("Expected a busy database rejection");
expectRetryableBusy(result.error, database.path);
await expectRootsRemoved(roots);
},
20_000,
);