Derive firmware completeness from examined coverage, retain linked continuation evidence, and preserve reported extraction limits. Keep Android package attribution and Go image fixtures coherent, and classify SQLite busy failures without discarding descriptor cleanup facts. Reviewed fixes include firmware, ADB, SQLite, and recorded-transport bug reports and their focused PRs. Validation: firmware report source tests passed 21 cases; prior focused artifact/provider boundaries passed. Real Android devices and external firmware tools were not rerun.
160 lines
5.9 KiB
TypeScript
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,
|
|
);
|