mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
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:
1 parent
8a2eb4ab61
commit
a16f5f4b5d
5 files changed
+650
-37
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user