fix(javascript): preserve recovery acquisition and cancellation failures

This commit is contained in:
morluto committed 2026-10-24 03:11:07 +08:00
1 parent cb689fae96
commit e00b2eda0b
11 files changed
+191 -33

No files matched your search

+5
View File
@@ -85,6 +85,11 @@ publication. REA writes verified bytes to an exclusively owned output tree and
rolls it back on failure. It also checks that the original source and engine
bytes remain unchanged before committing. The existing output is never overwritten.
A denied selected-input read reports `access_denied` with the observed OS code
and path. A source or engine identity change reports `artifact_changed`;
neither failure is a malformed recovery request. Cancellation retains the
caller's reason through admission, command launch and completion.
Multi-input relative filenames must be representable unchanged as artifact paths:
NFC Unicode spelling and no literal backslashes. Unsupported layouts fail before
invoking the engine rather than silently renaming inputs referenced by source maps.
@@ -123,11 +123,14 @@ describe("JavaScript recovery workflow", () => {
const invalid = await service.recover({});
if (invalid.ok) throw new Error("expected malformed input");
expect(invalid.error._tag).toBe("AnalysisInputError");
const cancelled = await service.recover(input, {
signal: AbortSignal.abort(),
});
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
for (const reason of [null, false, { request: "cancel recovery" }]) {
const cancelled = await service.recover(input, {
signal: AbortSignal.abort(reason),
});
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
expect(cancelled.error.cause).toBe(reason);
}
});
it("rejects additional_paths repeating path or each other", async () => {
const service = new JavaScriptRecoveryService({
@@ -34,7 +34,9 @@ export class JavaScriptRecoveryService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
const parsed = javascriptRecoveryInputSchema.safeParse(input);
if (!parsed.success)
return err(
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it, vi } from "vitest";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
@@ -113,6 +114,28 @@ it("keeps a failed admission close owned when cancellation is also requested", a
expect(observed.fileCleanupFailure.cleanupCause).toBe(failure.cleanupCause);
});
it.each(["EACCES", "EPERM"] as const)(
"preserves %s while acquiring a selected recovery input",
async (code) => {
const root = await createTestTempDirectory("rea-recovery-access-");
const selected = join(root, "selected.js");
const snapshots = join(root, "snapshots");
await mkdir(snapshots);
const denied = filesystemError(code);
vi.mocked(openRegularFile).mockRejectedValueOnce(denied);
const failure = await snapshotRecoveryInputs([selected], snapshots).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisAccessDeniedError);
expect(failure).toMatchObject({
operation: "recover_javascript_sources",
path: selected,
systemCode: code,
cause: denied,
});
},
);
it("reports one file selected under two letter cases as an input error", async (context) => {
const root = await createTestTempDirectory("rea-recovery-case-");
const lower = join(root, "bundle.js");
+14 -1
View File
@@ -13,6 +13,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { TextDecoder } from "node:util";
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
@@ -89,8 +91,9 @@ const scanRecoveryFile = async (
before.size !== bytes ||
[after, current].some((state) => !sameRegularFileState(before, state))
)
throw new AnalysisOutputError(
throw new AnalysisArtifactChangedError(
OPERATION,
path,
`File identity changed while reading: ${path}`,
);
return { sha256: hash.digest("hex"), bytes };
@@ -106,6 +109,14 @@ const scanRecoveryFile = async (
throw new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
cause,
});
if (cause instanceof NonRegularFileReadError)
throw new AnalysisOutputError(
OPERATION,
@@ -169,6 +180,8 @@ const snapshotRecoveryInput = async (
} catch (cause: unknown) {
if (
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisAccessDeniedError ||
cause instanceof AnalysisArtifactChangedError ||
cause instanceof ArtifactFileCleanupFailure ||
cause instanceof ArtifactFileCancelled
)
@@ -92,6 +92,31 @@ it("preserves caller cancellation before acquiring an expired execution budget",
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
});
it.each([null, false, { request: "cancel launch" }])(
"preserves the opaque reason when cancellation interrupts launcher acquisition (%j)",
async (reason) => {
const controller = new AbortController();
await expect(
runWakaruCommand({
command: "/selected/wakaru",
limiter: "/usr/bin/prlimit",
arguments: ["--version"],
cwd: "/tmp/unused",
environment: {},
deadline: performance.now() + 5_000,
signal: controller.signal,
launcher: async () => {
controller.abort(reason);
throw new Error("launcher interrupted");
},
}),
).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
},
);
const commandFixture = (
emit: (producer: ReturnType<typeof createProducer>) => void,
) => {
+11 -4
View File
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
@@ -104,8 +105,10 @@ export const resolveWakaruCommand = async (
return { command, limiter, ...fingerprint };
} catch (cause: unknown) {
if (
cause instanceof ArtifactReaderFailure &&
(cause.cleanup !== undefined || cause.reason === "cancelled")
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisArtifactChangedError ||
(cause instanceof ArtifactReaderFailure &&
(cause.cleanup !== undefined || cause.reason === "cancelled"))
)
throw cause;
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
@@ -154,7 +157,9 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
env: { TMPDIR: context.cwd, RAYON_NUM_THREADS: "1", NO_COLOR: "1" },
}).catch((cause: unknown) => {
if (context.signal?.aborted === true)
throw new AnalysisCancelledError(OPERATION);
throw new AnalysisCancelledError(OPERATION, {
cause: context.signal.reason,
});
throw new ProviderAdapterError("wakaru", OPERATION, {
cause,
diagnostics: {
@@ -226,7 +231,9 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
);
if (failure !== undefined) throw failure;
if (context.signal?.aborted === true)
throw new AnalysisCancelledError(OPERATION);
throw new AnalysisCancelledError(OPERATION, {
cause: context.signal.reason,
});
return {
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
+26 -13
View File
@@ -6,8 +6,8 @@ import {
import type { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import {
AnalysisArtifactChangedError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { JavaScriptRecoveryInput } from "../../domain/javascript/javascriptRecovery.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
@@ -155,24 +155,37 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
resources,
);
if (current.sha256 !== selected.sha256 || current.bytes !== selected.bytes)
throw new AnalysisOutputError(
throw new AnalysisArtifactChangedError(
OPERATION,
selected.path,
`Original input changed during recovery: ${selected.path}`,
{
diagnostics: {
expected_sha256: selected.sha256,
observed_sha256: current.sha256,
expected_bytes: selected.bytes,
observed_bytes: current.bytes,
},
},
);
}
if (
(
await fingerprintRecoveryFile(
engine.command,
RECOVERY_LIMITS.outputBytes,
options?.signal,
resources,
)
).sha256 !== engine.sha256
)
throw new AnalysisOutputError(
const currentEngine = await fingerprintRecoveryFile(
engine.command,
RECOVERY_LIMITS.outputBytes,
options?.signal,
resources,
);
if (currentEngine.sha256 !== engine.sha256)
throw new AnalysisArtifactChangedError(
OPERATION,
engine.command,
"Configured engine bytes changed during recovery",
{
diagnostics: {
expected_sha256: engine.sha256,
observed_sha256: currentEngine.sha256,
},
},
);
return createAnalysisExecution(
result,
+4 -1
View File
@@ -16,6 +16,7 @@ import {
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../../artifacts/ArtifactFileOperation.js";
import {
javascriptRecoveryResultSchema,
type JavaScriptRecoveryInput,
@@ -166,7 +167,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
failed.cause instanceof ArtifactReaderFailure &&
failed.cause.reason === "cancelled" &&
failed.cause.cleanup === undefined
? new AnalysisCancelledError(OPERATION, { cause: failed.cause })
? new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(failed.cause),
})
: failed.cause;
const residuals: string[] = [];
const cleanupFailures: { resource: string; reason: string }[] = [];
@@ -80,10 +80,21 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
});
if (result.ok) throw new Error("expected boundary failure");
const error = projectAnalysisError(result.error);
expect(
["execution_failure", "unreadable_output"],
JSON.stringify(error),
).toContain(error.code);
if (mode === "source-change" || mode === "engine-change") {
expect(error.code).toBe("artifact_changed");
expect(error.details).toMatchObject({
operation: "recover_javascript_sources",
path:
mode === "source-change"
? fixture.path
: join(fixture.root, "fixture-wakaru"),
});
} else {
expect(
["execution_failure", "unreadable_output"],
JSON.stringify(error),
).toContain(error.code);
}
expect(error.details).toBeDefined();
await expect(access(fixture.output)).rejects.toMatchObject({
code: "ENOENT",
@@ -495,9 +506,10 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
output_directory: fixture.output,
});
if (result.ok) throw new Error("expected per-source validation failure");
expect(projectAnalysisError(result.error).code).toMatch(
/execution_failure|unreadable_output/u,
);
const code = projectAnalysisError(result.error).code;
if (mode === "secondary-change" || mode === "redistribute")
expect(code).toBe("artifact_changed");
else expect(code).toMatch(/execution_failure|unreadable_output/u);
const reason =
mode === "secondary-foreign"
? "does not bind the selected input snapshot"
@@ -1,5 +1,5 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
@@ -7,6 +7,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
assertRecoveryCleanup,
recoveryFixture,
@@ -83,3 +84,54 @@ it
await assertRecoveryCleanup(fixture.launches);
},
);
it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
"reports denied selected-input access before launching recovery",
async (context) => {
const fixture = await recoveryFixture();
onTestFinished(() => chmod(fixture.path, 0o600));
await chmod(fixture.path, 0);
const denial = await readFile(fixture.path).then(
() => undefined,
(cause: unknown) => cause,
);
if (denial === undefined) context.skip();
expect(denial).toMatchObject({ code: "EACCES" });
const session = createTestBinarySession(() => {
throw new Error("no binary provider should start");
});
const server = createServer(
{ kind: "session", session },
{
javascriptRecovery: fixture.provider,
},
);
const client = new Client({ name: "recovery-denied", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: "recover_javascript_sources",
arguments: { path: fixture.path, output_directory: fixture.output },
});
expect(parseMcpToolError(result)).toMatchObject({
error: {
code: "access_denied",
category: "unavailable",
details: {
operation: "recover_javascript_sources",
path: fixture.path,
system_code: "EACCES",
boundary: "filesystem-read",
},
},
});
expect(fixture.launches).toEqual([]);
},
);