mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(analysis): preserve failure and cleanup outcomes
This commit is contained in:
1 parent
4767970105
commit
d815367a11
4 files changed
+267
-21
No files matched your search
@@ -71,22 +71,33 @@ export const managedAnalysisCleanupFailure = (
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok)
|
||||
return err(analysisErrorWithCleanupFailure(result.error, error));
|
||||
// A close rejection without a live cleanup state has unknown completion;
|
||||
// an empty dependency set must not turn that rejection into confirmed cleanup.
|
||||
if (!result.ok) {
|
||||
const failure = analysisErrorWithCleanupFailure(result.error, error);
|
||||
return err(
|
||||
error.cleanupIncomplete
|
||||
? failure.bindCleanupDependencies([result.error, error])
|
||||
: failure,
|
||||
);
|
||||
}
|
||||
const cleanup = projectAnalysisError(error);
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
error instanceof ProviderAdapterError ? error.providerId : "rea",
|
||||
"close_binary",
|
||||
{
|
||||
cause: error,
|
||||
remediationAction: cleanup.remediation.action,
|
||||
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
|
||||
diagnostics: {
|
||||
cleanup_error: cleanup,
|
||||
partial_observation: jsonValueSchema.parse(result.value),
|
||||
},
|
||||
const failure = new ProviderAdapterError(
|
||||
error instanceof ProviderAdapterError ? error.providerId : "rea",
|
||||
"close_binary",
|
||||
{
|
||||
cause: error,
|
||||
remediationAction: cleanup.remediation.action,
|
||||
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
|
||||
diagnostics: {
|
||||
cleanup_error: cleanup,
|
||||
partial_observation: jsonValueSchema.parse(result.value),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
return err(
|
||||
error.cleanupIncomplete
|
||||
? failure.bindCleanupDependencies([error])
|
||||
: failure,
|
||||
);
|
||||
};
|
||||
@@ -49,7 +49,12 @@ export class ArtifactFileSource {
|
||||
/** Bind verified bytes to the inventory's expected whole-carrier identity. */
|
||||
bindSha256(expectedSha256: string, signal?: AbortSignal): Promise<void> {
|
||||
return this.#scope.run(() =>
|
||||
this.#bind(expectedSha256, signal, () => this.#verify(signal)),
|
||||
this.#bind(
|
||||
expectedSha256,
|
||||
signal,
|
||||
() => this.#verify(signal),
|
||||
() => this.#verify(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +82,7 @@ export class ArtifactFileSource {
|
||||
expectedSha256: string,
|
||||
signal: AbortSignal | undefined,
|
||||
verify: () => Promise<StableRegularFileDescriptor>,
|
||||
verifyCompletion: () => Promise<StableRegularFileDescriptor> = verify,
|
||||
verifyCompletion: () => Promise<StableRegularFileDescriptor>,
|
||||
): Promise<void> {
|
||||
const { handle } = await verify();
|
||||
const observed =
|
||||
@@ -96,8 +101,9 @@ export class ArtifactFileSource {
|
||||
signal,
|
||||
)
|
||||
).sha256;
|
||||
// A completed digest needs source validation before late cancellation can
|
||||
// decide the outcome; unverified bytes must not establish a contradiction.
|
||||
await verifyCompletion();
|
||||
assertArtifactReadActive(signal);
|
||||
if (observed !== expectedSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -110,6 +116,7 @@ export class ArtifactFileSource {
|
||||
false,
|
||||
),
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
this.#boundSha256 = observed;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { writeFile, stat } from "node:fs/promises";
|
||||
import { writeFile, stat, open } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { GodotPckArtifactReader } from "../../../src/artifacts/GodotPckArtifactReader.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { buildGodotPck, pckU64 } from "../../fixtures/godotPck.js";
|
||||
import {
|
||||
@@ -10,6 +12,83 @@ import {
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
|
||||
it.each(
|
||||
[true, false].flatMap((matchingDigest) =>
|
||||
["stable", "changed", "unavailable", "read failure"].map((sourceState) => ({
|
||||
matchingDigest,
|
||||
sourceState,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"preserves verified carrier binding through final cancellation (matching=$matchingDigest, source=$sourceState)",
|
||||
async ({ matchingDigest, sourceState }) => {
|
||||
const root = await createTestTempDirectory("rea-pck-binding-finalization-");
|
||||
const path = join(root, "game.pck");
|
||||
const bytes = buildGodotPck(4, [
|
||||
{ path: "res://main.gd", data: "extends Node\n" },
|
||||
]);
|
||||
await writeFile(path, bytes);
|
||||
const handle = await open(path);
|
||||
const initial = await handle.stat();
|
||||
const observedSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const expectedSha256 = matchingDigest ? observedSha256 : "0".repeat(64);
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "carrier final source validation" };
|
||||
const physical = Object.assign(
|
||||
new Error("carrier source I/O unavailable"),
|
||||
{ code: "EIO" },
|
||||
);
|
||||
const nativeStat = handle.stat.bind(handle);
|
||||
let validations = 0;
|
||||
vi.spyOn(handle, "stat").mockImplementation(async () => {
|
||||
const observed = await nativeStat();
|
||||
if (++validations === 2) {
|
||||
controller.abort(reason);
|
||||
if (sourceState === "unavailable") throw physical;
|
||||
if (sourceState === "changed") observed.ino += 1;
|
||||
}
|
||||
return observed;
|
||||
});
|
||||
if (sourceState === "read failure")
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async () => {
|
||||
controller.abort(reason);
|
||||
throw physical;
|
||||
});
|
||||
const reader = new GodotPckArtifactReader(path, { handle, initial });
|
||||
try {
|
||||
const failure: unknown = await reader
|
||||
.prepareContainer(expectedSha256, controller.signal)
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(controller.signal.reason).toBe(reason);
|
||||
if (sourceState === "unavailable" || sourceState === "read failure")
|
||||
expect(failure).toBe(physical);
|
||||
else if (sourceState === "changed")
|
||||
expect(failure).toMatchObject({
|
||||
reason: "integrity",
|
||||
details: undefined,
|
||||
message: expect.stringContaining("source identity changed"),
|
||||
});
|
||||
else if (matchingDigest)
|
||||
expect(failure).toMatchObject({ reason: "cancelled", cause: reason });
|
||||
else
|
||||
expect(failure).toMatchObject({
|
||||
reason: "integrity",
|
||||
details: {
|
||||
declaredSha256: expectedSha256,
|
||||
calculatedSha256: observedSha256,
|
||||
},
|
||||
});
|
||||
await reader.close();
|
||||
// The admitted descriptor is borrowed; only its original caller closes it.
|
||||
expect(handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await reader.close();
|
||||
await handle.close();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("binds extraction to the original carrier even if a changed directory names identical payloads", async () => {
|
||||
const root = await createTestTempDirectory("rea-pck-extraction-identity-");
|
||||
const source = join(root, "game.pck");
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
@@ -41,6 +48,7 @@ import {
|
||||
createTestBinarySession,
|
||||
} from "../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
||||
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
"fixture",
|
||||
@@ -727,3 +735,144 @@ describe("managed one-shot cleanup outcomes", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed one-shot cleanup retry ownership", () => {
|
||||
it.each(["success", "timeout", "independent primary"] as const)(
|
||||
"follows exact cleanup completion after a %s outcome",
|
||||
async (mode) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const resource = join(dirname(path), "retained-descriptor");
|
||||
const handle = await open(resource, "w+");
|
||||
const cleanup = new ProviderCleanupError("fixture", [resource], {
|
||||
reason: "controlled first close refusal",
|
||||
});
|
||||
let attempts = 0;
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
if (++attempts === 1) throw new Error("first close refused");
|
||||
await handle.close();
|
||||
}, [resource]);
|
||||
lease.bindCleanupError(cleanup);
|
||||
const primaryResource = join(dirname(path), "primary-descriptor");
|
||||
const primaryHandle =
|
||||
mode === "independent primary"
|
||||
? await open(primaryResource, "w+")
|
||||
: undefined;
|
||||
const primary =
|
||||
mode === "success"
|
||||
? undefined
|
||||
: new AnalysisTimeoutError("inspect_managed_members", 100, {
|
||||
capturedOutput: {
|
||||
stdout: "retained timeout observation",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
},
|
||||
...(primaryHandle === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: "independent primary cleanup",
|
||||
resources: [primaryResource],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const primaryLease =
|
||||
primaryHandle === undefined
|
||||
? undefined
|
||||
: new AnalysisCleanupLease(
|
||||
() => primaryHandle.close(),
|
||||
[primaryResource],
|
||||
);
|
||||
if (primary !== undefined) primaryLease?.bindCleanupError(primary);
|
||||
const session = createTestBinarySession(() => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? observed(null)
|
||||
: primary === undefined
|
||||
? observed({ retained: true })
|
||||
: err(primary),
|
||||
close: async () => {
|
||||
try {
|
||||
await lease.release();
|
||||
return ok(null);
|
||||
} catch {
|
||||
return err(cleanup);
|
||||
}
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await runManagedProviderExecution(
|
||||
{ createManagedBinarySession: () => session },
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
);
|
||||
if (result.ok) throw new Error("Expected retained cleanup failure");
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
expect(handle.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(handle.fd).toBe(-1);
|
||||
expect(cleanup.cleanupIncomplete).toBe(false);
|
||||
if (primaryLease !== undefined) {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual([primaryResource]);
|
||||
expect(primaryHandle?.fd).toBeGreaterThanOrEqual(0);
|
||||
await primaryLease.release();
|
||||
}
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
const projected = projectAnalysisError(result.error);
|
||||
if (primary === undefined)
|
||||
expect(projected).toMatchObject({
|
||||
details: {
|
||||
diagnostics: {
|
||||
partial_observation: { result: { retained: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
else
|
||||
expect(projected).toMatchObject({
|
||||
code: "provider_timeout",
|
||||
details: {
|
||||
captured_output: { stdout: "retained timeout observation" },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await lease.release();
|
||||
await primaryLease?.release();
|
||||
await handle.close();
|
||||
await primaryHandle?.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps rejected cleanup without a live completion owner unknown (primary failure: %s)",
|
||||
async (primaryFailure) => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const unowned = new AnalysisInputError("close_binary");
|
||||
const { dependencies } = factories({
|
||||
...(primaryFailure
|
||||
? {
|
||||
executionError: new AnalysisTimeoutError(
|
||||
"inspect_managed_members",
|
||||
100,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
cleanupError: unowned,
|
||||
});
|
||||
const result = await runManagedProviderExecution(
|
||||
dependencies,
|
||||
path,
|
||||
"inspect_managed_members",
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cleanup rejection");
|
||||
expect(unowned.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user