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

@@ -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 }[] = [];