diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 418a321536..f089347bbd 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -184,6 +184,11 @@ retains the completed Evidence and the resource owner for cleanup retry. MCP records that Evidence before delivering the error, which carries its retained evidence ID for later retrieval or export. +If a worker fails and cleanup remains incomplete, the error retains the original +cancellation, timeout, diagnostic overflow or memory failure. Successful cleanup +retry restores that failure's classification; it does not make the inspection +successful. + Changed input identities, special files, malformed databases, unsupported formats and resource failures are reported as errors with the selected path and meaningful reason. Worker resource refusals expose the caller-selected file in diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index 04f574a8c4..b39a3d2462 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { expect, it as nativeTest, onTestFinished } from "vitest"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { SQLITE_DATABASE_LIMITS } from "../../../src/sqlite/SqliteDatabaseLimits.js"; import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; @@ -1269,3 +1270,78 @@ nativeTest( await expectPrivateRootRemoved(ownedRoot); }, ); + +nativeTest.each([ + ["cancelled", "cancelled"], + ["output-limit", "unreadable_output"], + ["heap-diagnostic", "resource_constraint"], +] as const)( + "preserves the typed %s worker failure through retained cleanup retry", + async (scenario, code) => { + const { database } = await createFixture(); + const controller = new AbortController(); + const reason = { requested: "cancel retained SQLite worker" }; + let cleanupAllowed = false; + let ownedRoot = ""; + let launches = 0; + const provider = new SqliteDatabaseProvider({}, async (spawn) => { + launches += 1; + ownedRoot = spawn.cwd ?? ""; + const script = + scenario === "heap-diagnostic" + ? "process.stderr.write('JavaScript heap out of memory'); process.exitCode = 3" + : scenario === "output-limit" + ? `process.stdout.write('x'.repeat(${String(SQLITE_DATABASE_LIMITS.diagnosticBytes + 1)})); setInterval(() => {}, 1000)` + : "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)"; + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: ["-e", script], + }); + const cleanupOwned = + launched.cleanup ?? + (() => cleanupOwnedProcessGroup(launched.ownership)); + if (scenario === "cancelled") { + await waitForProviderProcessReady(launched.process); + setImmediate(() => controller.abort(reason)); + } + return { + ...launched, + cleanup: async () => { + const cleaned = await cleanupOwned(); + if (!cleanupAllowed) + throw new Error("controlled SQLite worker cleanup refusal"); + return cleaned; + }, + }; + }); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspect( + { path: database.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected retained worker cleanup"); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + const primary = result.error.cleanupPrimary; + if (primary === undefined) throw new Error("Missing typed worker failure"); + expect(projectAnalysisError(primary).code).toBe(code); + if (scenario === "cancelled") + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(launches).toBe(1); + await access(ownedRoot); + cleanupAllowed = true; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect(projectAnalysisError(result.error).code).toBe(code); + if (scenario === "cancelled") + expect(result.error.cleanupPrimary?.cause).toBe(reason); + await expectPrivateRootRemoved(ownedRoot); + }, +);