fix(recovery): preserve bounded Wakaru failure and cleanup facts (#10087)

* fix(javascript): retain Wakaru diagnostics and cleanup ownership

* fix(recovery): preserve launch failure with late cancellation

* fix(recovery): retain cancellation through provider cleanup
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 18:36:09 +08:00
1 parent 8a2eb4ab61
commit a16f5f4b5d
5 files changed
+650 -37

No files matched your search

+126 -3
View File
@@ -1,4 +1,9 @@
import { EventEmitter } from "node:events";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { spawnOwnedProviderProcess } from "../../process/ProviderProcess.js";
import { execFileOutput } from "../../process/ExecFileOutput.js";
import { PassThrough } from "node:stream";
import { expect, it } from "vitest";
import {
@@ -6,6 +11,7 @@ import {
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { primaryAnalysisError } from "../../domain/analysisErrorBase.js";
import {
resolveWakaruCommand,
runWakaruCommand,
@@ -90,9 +96,10 @@ it.each([null, false, { request: "cancel launch" }])(
environment: {},
deadline: performance.now() + 5_000,
signal: controller.signal,
launcher: async () => {
launcher: async (options) => {
controller.abort(reason);
throw new Error("launcher interrupted");
options.signal?.throwIfAborted();
throw new Error("Expected launcher cancellation");
},
}),
).rejects.toMatchObject({
@@ -102,6 +109,67 @@ it.each([null, false, { request: "cancel launch" }])(
},
);
it("preserves cancellation reported by the ownership preparation command", async () => {
const controller = new AbortController();
const reason = { caller: "cancel ownership preparation" };
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await expect(
runWakaruCommand({
command: "/selected/wakaru",
limiter: "/unused/prlimit",
arguments: ["--version"],
cwd: tmpdir(),
environment: {},
deadline: performance.now() + 10_000,
signal: controller.signal,
launcher: async () => {
timer = setTimeout(() => controller.abort(reason), 100);
await execFileOutput(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{
signal: controller.signal,
stopSignal: "SIGTERM",
},
);
throw new Error("Expected preparation cancellation");
},
}),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason });
} finally {
if (timer !== undefined) clearTimeout(timer);
}
});
it("preserves a real launch failure when the caller cancels after it", async () => {
const controller = new AbortController();
const reason = { caller: "cancel after launch failure" };
const missing = join(tmpdir(), `rea-missing-wakaru-${randomUUID()}`);
const failure: unknown = await runWakaruCommand({
command: "/selected/wakaru",
limiter: missing,
arguments: ["--version"],
cwd: tmpdir(),
environment: {},
deadline: performance.now() + 30_000,
signal: controller.signal,
launcher: async (options) => {
try {
return await spawnOwnedProviderProcess(options);
} catch (cause) {
controller.abort(reason);
throw cause;
}
},
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
_tag: "ProviderAdapterError",
cause: { code: "ENOENT" },
secondaryFailures: [{ _tag: "AnalysisCancelledError", cause: reason }],
});
});
const commandFixture = (
emit: (producer: ReturnType<typeof createProducer>) => void,
) => {
@@ -169,12 +237,67 @@ it("checks the diagnostic byte limit against output delivered after exit", async
producer.emit("close", 0, null);
});
});
await expect(runWakaruCommand(context)).rejects.toMatchObject({
const failure = await runWakaruCommand(context).catch(
(cause: unknown) => cause,
);
expect(failure).toMatchObject({
_tag: "AnalysisOutputError",
operation: "recover_javascript_sources",
capturedOutput: {
truncated: true,
exit_code: 0,
},
});
expect(
(failure as { capturedOutput: { stderr: string } }).capturedOutput.stderr
.length,
).toBe(RECOVERY_LIMITS.reportBytes);
});
it.each([false, true])(
"retains output and exact cancellation during final cleanup (cleanup fails: %s)",
async (cleanupFails) => {
const reason = { caller: "cancel during final cleanup" };
const controller = new AbortController();
const { context } = commandFixture((producer) => {
producer.stdout.end("finished output");
producer.stderr.end("finished diagnostic");
producer.emit("exit", 0, null);
producer.emit("close", 0, null);
});
const launcher = context.launcher;
const failure = await runWakaruCommand({
...context,
signal: controller.signal,
launcher: async (options) => ({
...(await launcher(options)),
cleanup: async () => {
controller.abort(reason);
return cleanupFails
? { cleaned: false as const, reason: "owned cleanup refused" }
: { cleaned: true as const, signaled: false };
},
}),
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
cleanupIncomplete: cleanupFails,
capturedOutput: {
stdout: "finished output",
stderr: "finished diagnostic",
exit_code: 0,
},
});
const primary = primaryAnalysisError(
failure as Parameters<typeof primaryAnalysisError>[0],
);
expect(primary).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
expect(primary.cause).toBe(reason);
},
);
it("enforces the shared deadline while exited output streams remain open", async () => {
const { context, producer } = commandFixture((process) => {
process.emit("exit", 0, null);
+50 -10
View File
@@ -13,7 +13,10 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import { prlimitArgumentVector } from "../../process/LinuxResourceLimits.js";
import { ExecFileOutputCancelledError } from "../../process/ExecFileOutput.js";
import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js";
import {
ProviderProcessSupervisor,
@@ -33,6 +36,17 @@ const OPERATION = "recover_javascript_sources";
const WAKARU_REQUIREMENT =
"Provide an absolute REA_WAKARU_COMMAND pointing to a Wakaru ^1.13.0 Linux x64 binary (verified with 1.14.0); no tool is installed by REA. See docs/guides/javascript/javascript-recovery.md#install-wakaru.";
const commandCapturedOutput = (
snapshot: ReturnType<ProviderProcessSupervisor["snapshot"]>,
) => ({
stdout: snapshot.stdout.text,
stderr: snapshot.stderr.text,
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
truncated:
snapshot.diagnosticTruncated === true || snapshot.outputIncomplete === true,
});
/** Wakaru recovery runs only on Linux x64; discovery and execution share this check. */
export const wakaruHostSupported = (
platform: NodeJS.Platform,
@@ -64,7 +78,14 @@ export class WakaruCleanupFailure extends ProviderCleanupError {
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
},
{ operation: OPERATION, cause: previousError },
{
operation: OPERATION,
cause: previousError,
...(previousError instanceof AnalysisError
? { cleanupPrimary: previousError }
: {}),
capturedOutput: commandCapturedOutput(snapshot),
},
);
}
}
@@ -158,11 +179,15 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
...(context.signal === undefined ? {} : { signal: context.signal }),
env: { TMPDIR: context.cwd, RAYON_NUM_THREADS: "1", NO_COLOR: "1" },
}).catch((cause: unknown) => {
if (context.signal?.aborted === true)
throw new AnalysisCancelledError(OPERATION, {
cause: context.signal.reason,
});
throw new ProviderAdapterError("wakaru", OPERATION, {
const signal = context.signal;
if (
signal?.aborted === true &&
(Object.is(cause, signal.reason) ||
(cause instanceof ExecFileOutputCancelledError &&
Object.is(cause.cause, signal.reason)))
)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
const failure = new ProviderAdapterError("wakaru", OPERATION, {
cause,
diagnostics: {
reason: "Failed to launch configured Wakaru through util-linux prlimit",
@@ -170,6 +195,12 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
detail: recoveryFailureMessage(cause),
},
});
throw signal?.aborted === true
? analysisErrorWithCancellation(
failure,
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
)
: failure;
});
let oversized = false;
const supervisor = new ProviderProcessSupervisor(
@@ -180,6 +211,10 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
},
{
maxDiagnosticBytes: {
stdout: RECOVERY_LIMITS.reportBytes,
stderr: RECOVERY_LIMITS.reportBytes,
},
onDiagnostic: (event) => {
if (
event.type === "output" &&
@@ -222,6 +257,15 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
}
const stopped = await supervisor.stop();
const snapshot = supervisor.snapshot();
if (context.signal?.aborted === true) {
const cancelled = new AnalysisCancelledError(OPERATION, {
cause: context.signal.reason,
});
if (failure === undefined || failure instanceof AnalysisError)
failure = analysisErrorWithCancellation(failure, cancelled);
}
if (failure instanceof AnalysisError)
failure.retainCapturedOutput(commandCapturedOutput(snapshot));
if (stopped.status === "incomplete")
throw new WakaruCleanupFailure(
supervisor,
@@ -232,10 +276,6 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
snapshot,
);
if (failure !== undefined) throw failure;
if (context.signal?.aborted === true)
throw new AnalysisCancelledError(OPERATION, {
cause: context.signal.reason,
});
return {
exit_code: snapshot.exitCode ?? null,
signal: snapshot.signal ?? null,
+46 -23
View File
@@ -12,8 +12,10 @@ import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
@@ -75,28 +77,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
input: JavaScriptRecoveryInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (this.#closing)
return Promise.resolve(
err(
new ProviderCleanupError(
"wakaru",
[],
{ reason: "Provider is closing" },
{ operation: OPERATION },
),
),
);
if (this.#closing) return Promise.resolve(err(this.#closedError()));
const operation = this.#tail.then(() =>
this.#closing
? err(
new ProviderCleanupError(
"wakaru",
[],
{ reason: "Provider is closing" },
{ operation: OPERATION },
),
)
: this.#recover(input, options),
this.#closing ? err(this.#closedError()) : this.#recover(input, options),
);
this.#tail = operation.then(
() => undefined,
@@ -105,6 +88,14 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
return operation;
}
#closedError(): AnalysisCapabilityUnavailableError {
return new AnalysisCapabilityUnavailableError(
"wakaru",
OPERATION,
"Provider is closing",
);
}
async #recover(
input: JavaScriptRecoveryInput,
options?: ExecutionOptions,
@@ -172,7 +163,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
? failed.cause
: undefined;
const primary = reportFailure?.failure ?? failed.cause;
const failure =
let failure =
primary instanceof ArtifactReaderFailure &&
primary.reason === "cancelled" &&
primary.cleanup === undefined
@@ -258,6 +249,29 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
if (root !== undefined) residuals.push(root);
root = undefined;
}
// Cleanup can settle after the caller cancels. Preserve the established
// physical outcome and record the exact later cancellation on its primary.
const signal = options?.signal;
if (signal?.aborted === true) {
const analysisFailure =
failure instanceof AnalysisError
? failure
: new ProviderAdapterError("wakaru", OPERATION, {
cause: failure,
diagnostics: { reason: recoveryFailureMessage(failure) },
...(failure instanceof ArtifactReaderFailure &&
failure.partialObservation !== undefined
? { partialObservation: failure.partialObservation }
: {}),
...(reportFailure === undefined
? {}
: { capturedOutput: reportFailure.capturedOutput }),
});
failure = analysisErrorWithCancellation(
analysisFailure,
new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
);
}
if (residuals.length > 0) {
const error = new ProviderCleanupError(
"wakaru",
@@ -275,6 +289,12 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
{
operation: OPERATION,
cause: failure,
...(failure instanceof AnalysisError
? {
cleanupPrimary: failure,
capturedOutput: failure.capturedOutput,
}
: {}),
...(reportFailure === undefined
? {}
: { capturedOutput: reportFailure.capturedOutput }),
@@ -287,7 +307,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
),
},
}
: failure instanceof ArtifactReaderFailure &&
: (failure instanceof AnalysisError ||
failure instanceof ArtifactReaderFailure) &&
failure.partialObservation !== undefined
? { partialObservation: failure.partialObservation }
: {}),
@@ -295,6 +316,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
);
const lease = this.#pendingCleanup?.lease;
if (lease !== undefined) {
if (failure instanceof WakaruCleanupFailure)
lease.bindCleanupError(failure);
lease.bindCleanupError(error);
error.bindCleanupResourceOwner(lease);
}