diff --git a/docs/testing.md b/docs/testing.md index b6dbf2ac7d..d17d479f99 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -225,6 +225,13 @@ cover cancellation during final cleanup. Use explicit abort reasons and assert their identity at admission and after provider completion, alongside retained observations and cleanup state. +Electron fuse regressions cover actual descriptor close denial and retry through +the built CLI, same-connection MCP recovery, mixed descriptor outcomes, and +server shutdown. Controlled seams cover cancellation during final close and +after reader finalization, plus active-provider shutdown during preflight. +They preserve real filesystem cleanup where possible; mocked launch boundaries +do not establish real Electron teardown or naturally occurring OS failures. + For atomic writes, exercise cancellation on both sides of the documented commit point and preserve any publication receipt. A late interruption does not prove that no output was published. diff --git a/src/application/electron/ElectronFusePort.ts b/src/application/electron/ElectronFusePort.ts index 06354f2c5d..16b0eb9c10 100644 --- a/src/application/electron/ElectronFusePort.ts +++ b/src/application/electron/ElectronFusePort.ts @@ -12,6 +12,8 @@ import type { Result } from "../../domain/result.js"; /** Replaceable read-only Electron fuse reader with no target-execution authority. */ export interface ElectronFusePort { readonly identity: ProviderIdentity; + /** Release owned read resources; legacy read-only ports may own none. */ + close?(): Promise; inspect( input: InspectElectronFusesInput, options?: ExecutionOptions, diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index 769a6ab90d..9bbd125fb1 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -8,12 +8,12 @@ import { AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; import { electronFuseReportSchema, inspectElectronFusesInputSchema, } from "../../domain/electron/electronFuses.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { createElectronFuseEvidence } from "../../domain/electron/electronFuseEvidence.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -24,13 +24,20 @@ const OPERATION = "inspect_electron_fuses"; export class ElectronFuseService { constructor(readonly provider: ElectronFusePort) {} + /** Await and retry the reader's exact retained descriptor owners. */ + async close(): Promise { + await this.provider.close?.(); + } + /** Read fuse wires for one explicitly selected path without launching it. */ async inspect( rawInput: unknown, options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectElectronFusesInputSchema.safeParse(rawInput); if (!input.success) return err( @@ -61,56 +68,13 @@ export class ElectronFuseService { "Electron fuse reader returned a malformed report or changed the selected artifact identity.", ), ); - const value = report.data; - const architecture = subjectArchitecture(value); return completeProviderEvidence( - createEvidence( - { - path: value.artifact.resolved_path, - sha256: value.artifact.sha256, - format: value.artifact.format, - ...(architecture === undefined ? {} : { architecture }), - }, + createElectronFuseEvidence( + input.data, + report.data, this.provider.identity, - { - operation: OPERATION, - parameters: input.data, - result: value, - confidence: "observed", - limitations: value.limitations, - locations: locationsFor(value), - }, ), options?.signal, ); } } - -const subjectArchitecture = ( - report: ReturnType, -) => { - if (report.artifact.format === "mach-o-universal") return undefined; - if (report.slices.length !== 1) return undefined; - const name = report.slices[0]?.architecture; - return BINARY_ARCHITECTURES.find((supported) => supported === name); -}; - -const locationsFor = ( - report: ReturnType, -) => [ - { kind: "artifact-path" as const, path: report.artifact.selected_path }, - ...(report.artifact.resolved_path === report.artifact.selected_path - ? [] - : [ - { - kind: "artifact-path" as const, - path: report.artifact.resolved_path, - }, - ]), - ...report.slices.flatMap((slice) => - slice.sentinels.map((sentinel) => ({ - kind: "file-offset" as const, - offset: sentinel.sentinel_offset, - })), - ), -]; diff --git a/src/application/javascript/ElectronActiveObservationPort.ts b/src/application/javascript/ElectronActiveObservationPort.ts index c323842f38..b6262fb144 100644 --- a/src/application/javascript/ElectronActiveObservationPort.ts +++ b/src/application/javascript/ElectronActiveObservationPort.ts @@ -16,6 +16,8 @@ export type CanonicalElectronActiveObservationInput = /** Provider-neutral boundary for owned Electron runtime experiments. */ export interface ElectronActiveObservationPort { identity(): ProviderIdentity; + /** Join admitted captures and retry retained preflight cleanup. */ + close?(): Promise; capture( input: CanonicalElectronActiveObservationInput, options?: ExecutionOptions, diff --git a/src/browser/PlaywrightElectronActiveFuses.ts b/src/browser/PlaywrightElectronActiveFuses.ts index a6eaf5ab92..3bf67773e5 100644 --- a/src/browser/PlaywrightElectronActiveFuses.ts +++ b/src/browser/PlaywrightElectronActiveFuses.ts @@ -1,3 +1,7 @@ +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { electronFuseFailure } from "../electron/ElectronFuseFailures.js"; import { basename, dirname } from "node:path"; import { AnalysisUnsupportedTargetError } from "../domain/analysisErrorCore.js"; @@ -9,6 +13,7 @@ const NODE_CLI_INSPECT_ARGUMENTS_FUSE_INDEX = 3; export interface InspectArgumentsPreflightOptions { readonly signal?: AbortSignal; + readonly resources?: ArtifactResourceScope; readonly readFuseImage?: typeof readElectronFuseImage; readonly resolveFuseTarget?: typeof resolveElectronFuseTarget; } @@ -40,7 +45,7 @@ export const assertNodeCliInspectArgumentsSupported = async ( executablePath: string, options?: InspectArgumentsPreflightOptions, ): Promise => { - options?.signal?.throwIfAborted(); + assertArtifactReadActive(options?.signal); const reader = options?.readFuseImage ?? readElectronFuseImage; const resolver = options?.resolveFuseTarget ?? resolveElectronFuseTarget; try { @@ -56,10 +61,12 @@ export const assertNodeCliInspectArgumentsSupported = async ( } } - const image = await reader( - targetPath, - options?.signal !== undefined ? { signal: options.signal } : undefined, - ); + const image = await reader(targetPath, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + ...(options?.resources === undefined + ? {} + : { resources: options.resources }), + }); if (image.slices.length === 0) return; const hostArchitecture = process.arch === "x64" ? "x86_64" : process.arch; @@ -90,7 +97,12 @@ export const assertNodeCliInspectArgumentsSupported = async ( } } catch (cause: unknown) { if (cause instanceof AnalysisUnsupportedTargetError) throw cause; - options?.signal?.throwIfAborted(); + if ( + cause instanceof ArtifactReaderFailure && + (cause.cleanup !== undefined || cause.reason === "cancelled") + ) + throw electronFuseFailure(cause, executablePath, OPERATION); + assertArtifactReadActive(options?.signal); // Missing sentinels, format failures, or unreadable fuses leave inspection capability unknown; proceed with launch. } }; diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 956ec312af..b535203539 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -1,3 +1,5 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { electronFuseFailure } from "../electron/ElectronFuseFailures.js"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; @@ -60,6 +62,19 @@ type ElectronPaths = { /** Launch an owned Electron application through the official Playwright API. */ export class PlaywrightElectronActiveProvider implements ElectronActiveObservationPort { + readonly #resources = new ArtifactResourceScope(); + readonly #shutdown = new AbortController(); + + /** Stop admitting work, cancel and join captures, then retry exact fuse owners. */ + async close(): Promise { + this.#shutdown.abort(new Error("Electron active provider is closing")); + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw electronFuseFailure(cause, "fuse-preflight", OPERATION); + } + } + constructor( private readonly environment: Readonly>, // Load Playwright only when a capture launches; MCP startup stays off its module graph. @@ -75,6 +90,23 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati async capture( input: CanonicalElectronActiveObservationInput, options: ExecutionOptions = {}, + ): Promise> { + const signal = AbortSignal.any([ + this.#shutdown.signal, + ...(options.signal === undefined ? [] : [options.signal]), + ]); + try { + return await this.#resources.run(() => + this.#capture(input, { ...options, signal }), + ); + } catch (cause: unknown) { + return err(electronFuseFailure(cause, input.executable_path, OPERATION)); + } + } + + async #capture( + input: CanonicalElectronActiveObservationInput, + options: ExecutionOptions, ): Promise> { let application: ElectronApplication | undefined; let ownership: OwnedProcessGroup | undefined; @@ -97,14 +129,28 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati application: input.application_path, root: input.application_root, }; - await runWithExecutionLimits( - assertNodeCliInspectArgumentsSupported(paths.executable, { + const preflight = assertNodeCliInspectArgumentsSupported( + input.executable_path, + { + resources: this.#resources, ...(options.signal === undefined ? {} : { signal: options.signal }), readFuseImage: this.readFuseImage, - }), - options.signal, - startupDeadline, - ); + }, + ).catch((cause: unknown) => { + throw electronFuseFailure(cause, input.executable_path, OPERATION); + }); + try { + await runWithExecutionLimits( + preflight, + options.signal, + startupDeadline, + ); + } catch (interruption: unknown) { + // A startup interruption cannot abandon an admitted descriptor's final + // close. Its physical/cleanup failure takes precedence over the race. + await preflight; + throw interruption; + } const captureBaseline = await systemProcessOwnershipHost.captureBaseline?.(options.signal); if (options.signal?.aborted) @@ -116,9 +162,10 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati args: ["-r", hookPath, paths.application, ...input.args], timeout: Math.max(1, startupDeadline - performance.now()), }; - application = await containLaunchRejections(() => - this.launch(launchOptions), - ); + application = await containLaunchRejections(() => { + options.signal?.throwIfAborted(); + return this.launch(launchOptions); + }); const leaderPid = application.process().pid; if ( typeof leaderPid !== "number" || @@ -170,7 +217,9 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ], }; } - if (options.signal?.aborted === true) + if (cause instanceof AnalysisError && cause.cleanupIncomplete) + outcome = err(cause); + else if (options.signal?.aborted === true) outcome = err( new BrowserObservationError(OPERATION, "cancelled", { ...cancellationCause(cause, options.signal), diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index df737736e8..e6fe651182 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -41,7 +41,6 @@ const registerElectronFuseCommand = ( cli: ReturnType, logger: Logger, ): void => { - const service = createElectronFuseService(); cli.command(CLI_COMMANDS.inspectElectronFuses, { description: "Inspect packaged Electron fuse bytes without launching the application", @@ -55,11 +54,19 @@ const registerElectronFuseCommand = ( run: ({ args }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectElectronFuses, async () => { - const result = await service.inspect( - { path: absoluteElectronFusePath(args.path) }, - { signal }, + const service = createElectronFuseService(); + return finishAnalysisCommand( + () => + service.inspect( + { path: absoluteElectronFusePath(args.path) }, + { signal }, + ), + service, + service.provider.identity.id, + "inspect_electron_fuses", + undefined, + signal, ); - return result.ok ? result.value : projectAnalysisError(result.error); }), ), }); @@ -117,12 +124,22 @@ const registerElectronActiveCommand = ( await import("../application/javascript/ElectronActiveObservationService.js"); const { createElectronScenarioProvider } = await import("../composition/electronScenario.js"); - const result = await captureElectronScenario( - createElectronScenarioProvider(environment), - parsed.data, - { signal }, + const provider = createElectronScenarioProvider(environment); + const result = await finishAnalysisCommand( + () => captureElectronScenario(provider, parsed.data, { signal }), + { + close: async () => { + await provider.close?.(); + }, + }, + provider.identity().id, + "capture_electron_scenario", + undefined, + signal, ); - return result.ok ? result.value : cliError(result.error); + return "code" in result + ? { error: "Electron analysis failed", ...result } + : result; }, ), ), diff --git a/src/domain/electron/electronFuseEvidence.ts b/src/domain/electron/electronFuseEvidence.ts new file mode 100644 index 0000000000..bbb8899d2d --- /dev/null +++ b/src/domain/electron/electronFuseEvidence.ts @@ -0,0 +1,57 @@ +import { BINARY_ARCHITECTURES } from "../binaryTargetTypes.js"; +import { createEvidence, type Evidence } from "../evidence.js"; +import type { + ElectronFuseReport, + InspectElectronFusesInput, +} from "./electronFuses.js"; + +/** Preserve the same validated fuse facts on success and incomplete finalization. */ +export const createElectronFuseEvidence = ( + input: InspectElectronFusesInput, + value: ElectronFuseReport, + provider: Evidence["provider"], +): Evidence => { + const architecture = subjectArchitecture(value); + return createEvidence( + { + path: value.artifact.resolved_path, + sha256: value.artifact.sha256, + format: value.artifact.format, + ...(architecture === undefined ? {} : { architecture }), + }, + provider, + { + operation: "inspect_electron_fuses", + parameters: input, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: locationsFor(value), + }, + ); +}; + +const subjectArchitecture = (report: ElectronFuseReport) => { + if (report.artifact.format === "mach-o-universal") return undefined; + if (report.slices.length !== 1) return undefined; + const name = report.slices[0]?.architecture; + return BINARY_ARCHITECTURES.find((supported) => supported === name); +}; + +const locationsFor = (report: ElectronFuseReport) => [ + { kind: "artifact-path" as const, path: report.artifact.selected_path }, + ...(report.artifact.resolved_path === report.artifact.selected_path + ? [] + : [ + { + kind: "artifact-path" as const, + path: report.artifact.resolved_path, + }, + ]), + ...report.slices.flatMap((slice) => + slice.sentinels.map((sentinel) => ({ + kind: "file-offset" as const, + offset: sentinel.sentinel_offset, + })), + ), +]; diff --git a/src/electron/ElectronFuseCleanup.test.ts b/src/electron/ElectronFuseCleanup.test.ts new file mode 100644 index 0000000000..efb5399cc0 --- /dev/null +++ b/src/electron/ElectronFuseCleanup.test.ts @@ -0,0 +1,604 @@ +import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { readElectronFuseImage } from "./ElectronFuseReader.js"; +import fs, { fstatSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import * as fileSystem from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +import { thinFuseImage } from "../../tests/fixtures/electron/fuseImage.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createHash } from "node:crypto"; +import { ElectronFuseService } from "../application/electron/ElectronFuseService.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ElectronFuseProvider } from "./ElectronFuseProvider.js"; +import { PlaywrightElectronActiveProvider } from "../browser/PlaywrightElectronActiveProvider.js"; +import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { assertNodeCliInspectArgumentsSupported } from "../browser/PlaywrightElectronActiveFuses.js"; + +afterEach(() => { + vi.restoreAllMocks(); + syncBuiltinESMExports(); +}); + +it("retains the exact rejected fuse descriptor until its owner can close it", async () => { + const root = await createTestTempDirectory("rea-fuse-close-owner-"); + const path = join(root, "application"); + await fileSystem.writeFile(path, thinFuseImage([0x31]).bytes); + const originalOpen = fileSystem.open; + const releases: (() => Promise)[] = []; + let denyClose = true; + const descriptors: number[] = []; + vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => { + const file = await originalOpen(...args); + if (String(args[0]) !== path) return file; + descriptors.push(file.fd); + const close = file.close.bind(file); + releases.push(close); + vi.spyOn(file, "close").mockImplementation(async () => { + if (denyClose) + throw Object.assign(new Error("close denied"), { code: "EACCES" }); + await close(); + }); + return file; + }); + syncBuiltinESMExports(); + const provider = new ElectronFuseProvider(); + try { + const result = await provider.inspect({ path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected incomplete descriptor cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([path]); + expect(fstatSync(descriptors[0]!)).toBeDefined(); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(descriptors).toHaveLength(1); + expect(result.error.cleanupIncomplete).toBe(true); + denyClose = false; + await provider.close(); + expect(() => fstatSync(descriptors[0]!)).toThrow(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + denyClose = false; + for (const close of releases) await close(); + } +}); + +const faultyFuse = async (faults: { + read?: () => Promise; + close?: () => Promise; +}) => { + const root = await createTestTempDirectory("rea-fuse-finalization-"); + const path = join(root, "application"); + const bytes = thinFuseImage([0x31]).bytes; + await fileSystem.writeFile(path, bytes); + const originalOpen = fileSystem.open; + const releases: (() => Promise)[] = []; + vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => { + const file = await originalOpen(...args); + if (String(args[0]) !== path) return file; + const close = file.close.bind(file); + releases.push(close); + if (faults.read !== undefined) + vi.spyOn(file, "read").mockImplementation(faults.read); + vi.spyOn(file, "close").mockImplementation(async () => { + await faults.close?.(); + await close(); + }); + return file; + }); + syncBuiltinESMExports(); + return { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + opened: () => releases.length, + release: async () => { + for (const close of releases) await close(); + }, + }; +}; + +it.each([false, true])( + "preserves a physical read failure when cancellation arrives afterward (same reason=%s)", + async (sameReason) => { + const controller = new AbortController(); + const physical = Object.assign(new Error("physical read failed"), { + code: "EIO", + }); + const fixture = await faultyFuse({ + read: async () => { + controller.abort(sameReason ? physical : { caller: "late" }); + throw physical; + }, + }); + const provider = new ElectronFuseProvider(); + try { + const result = await provider.inspect( + { path: fixture.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected physical read failure"); + expect(result.error.cause).toBe(physical); + expect(projectAnalysisError(result.error).code).not.toBe("cancelled"); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + await provider.close(); + await fixture.release(); + } + }, +); + +it("keeps the physical failure alongside a retryable failed close", async () => { + const physical = Object.assign(new Error("physical read failed"), { + code: "EIO", + }); + let denied = true; + const fixture = await faultyFuse({ + read: async () => { + throw physical; + }, + close: async () => { + if (denied) throw new Error("close denied"); + }, + }); + const provider = new ElectronFuseProvider(); + try { + const result = await provider.inspect({ path: fixture.path }); + if (result.ok) throw new Error("Expected physical read and close failures"); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (!(result.error.cause instanceof AggregateError)) + throw new Error("Expected both failures"); + expect(result.error.cause.errors[0].cause).toBe(physical); + expect(result.error.cleanupIncomplete).toBe(true); + denied = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cause.errors[0].cause).toBe(physical); + } finally { + denied = false; + await fixture.release(); + } +}); + +it.each([false, true])( + "retains completed fuse Evidence through final close (cancelled=%s)", + async (cancelled) => { + const controller = new AbortController(); + const reason = { caller: "during final close" }; + let denied = true; + const fixture = await faultyFuse({ + close: async () => { + if (cancelled) controller.abort(reason); + if (denied) throw new Error("close denied"); + }, + }); + const provider = new ElectronFuseProvider(); + const service = new ElectronFuseService(provider); + try { + const result = await service.inspect( + { path: fixture.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected final close failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + artifact: { resolved_path: fixture.path, sha256: fixture.sha256 }, + }, + operation: "inspect_electron_fuses", + }); + denied = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + if (cancelled) { + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + if (!(result.error.cause instanceof AggregateError)) + throw new Error("Expected retained cancellation"); + expect(result.error.cause.errors[0].cause).toBe(reason); + } + } finally { + denied = false; + await fixture.release(); + } + }, +); + +it("does not allow active Electron preflight to swallow an owned close failure", async () => { + let denied = true; + const fixture = await faultyFuse({ + close: async () => { + if (denied) throw new Error("close denied"); + }, + }); + const resources = new ArtifactResourceScope(); + try { + await expect( + assertNodeCliInspectArgumentsSupported(fixture.path, { resources }), + ).rejects.toMatchObject({ cleanupIncomplete: true }); + await expect(resources.close()).rejects.toBeDefined(); + denied = false; + await resources.close(); + } finally { + denied = false; + await fixture.release(); + } +}); + +it("lets the shared fuse service retry its provider's failed descriptor close", async () => { + let denied = true; + const fixture = await faultyFuse({ + close: async () => { + if (denied) throw new Error("close denied"); + }, + }); + const service = new ElectronFuseService(new ElectronFuseProvider()); + try { + const result = await service.inspect({ path: fixture.path }); + if (result.ok) throw new Error("Expected close failure"); + await expect(service.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + denied = false; + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + denied = false; + await fixture.release(); + } +}); + +it("retains the exact caller reason and completed Evidence when final close succeeds after cancellation", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel during successful close" }; + const fixture = await faultyFuse({ + close: async () => controller.abort(reason), + }); + const provider = new ElectronFuseProvider(); + try { + const result = await new ElectronFuseService(provider).inspect( + { path: fixture.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected final cancellation"); + expect(result.error.cause).toBe(reason); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { artifact: { sha256: fixture.sha256 } }, + }); + } finally { + await provider.close(); + await fixture.release(); + } +}); + +it("does not launch Electron while its real preflight descriptor remains owned", async () => { + let denied = true; + const fixture = await faultyFuse({ + close: async () => { + if (denied) throw new Error("close denied"); + }, + }); + let launches = 0; + const provider = new PlaywrightElectronActiveProvider({}, async () => { + launches += 1; + throw new Error("Launch must not be reached"); + }); + try { + const result = await provider.capture({ + ...electronActiveObservationInputSchema.parse({ + executable_path: fixture.path, + application_path: fixture.path, + }), + application_root: dirname(fixture.path), + }); + expect(launches).toBe(0); + if (result.ok) throw new Error("Expected owned preflight cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([fixture.path]); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + denied = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + denied = false; + await fixture.release(); + } +}); + +it("keeps the shared workflow's exact pre-abort reason without opening a file", async () => { + const controller = new AbortController(); + const reason = { caller: "before inspection" }; + controller.abort(reason); + const fixture = await faultyFuse({}); + const provider = new ElectronFuseProvider(); + try { + const result = await new ElectronFuseService(provider).inspect( + { path: fixture.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected admission cancellation"); + expect(result.error.cause).toBe(reason); + expect(fixture.opened()).toBe(0); + } finally { + await provider.close(); + await fixture.release(); + } +}); + +it("awaits an admitted preflight's final close before projecting cancellation", async () => { + const controller = new AbortController(); + const physical = Object.assign(new Error("preflight read failed"), { + code: "EIO", + }); + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + let markClose!: () => void; + const closeEntered = new Promise((resolve) => { + markClose = resolve; + }); + let denied = true; + const fixture = await faultyFuse({ + read: async () => { + controller.abort(physical); + throw physical; + }, + close: async () => { + markClose(); + await closeGate; + if (denied) throw new Error("preflight close denied"); + }, + }); + let launches = 0; + const provider = new PlaywrightElectronActiveProvider({}, async () => { + launches += 1; + throw new Error("Unexpected Electron launch"); + }); + const input = { + ...electronActiveObservationInputSchema.parse({ + executable_path: fixture.path, + application_path: fixture.path, + }), + application_root: dirname(fixture.path), + }; + let settled = false; + const running = provider + .capture(input, { signal: controller.signal }) + .then((result) => { + settled = true; + return result; + }); + try { + await closeEntered; + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + releaseClose(); + const result = await running; + if (result.ok) throw new Error("Expected preflight failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + if (!(result.error.cause instanceof AggregateError)) + throw new Error("Expected both producer outcomes"); + expect(result.error.cause.errors[0].cause).toBe(physical); + const retry = await provider.capture(input); + if (retry.ok) throw new Error("Expected retained owner to block launch"); + expect(retry.error.cleanupIncomplete).toBe(true); + expect(fixture.opened()).toBe(1); + expect(launches).toBe(0); + denied = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(retry.error.cleanupIncomplete).toBe(false); + } finally { + releaseClose(); + denied = false; + await running; + await provider.close(); + await fixture.release(); + } +}); + +it.each(["admission", "close"] as const)( + "keeps active preflight's exact caller reason at %s", + async (phase) => { + const controller = new AbortController(); + const reason = { caller: phase }; + const fixture = await faultyFuse({ + close: async () => controller.abort(reason), + }); + if (phase === "admission") controller.abort(reason); + let launches = 0; + const provider = new PlaywrightElectronActiveProvider({}, async () => { + launches += 1; + throw new Error("Unexpected launch"); + }); + try { + const result = await provider.capture( + { + ...electronActiveObservationInputSchema.parse({ + executable_path: fixture.path, + application_path: fixture.path, + }), + application_root: dirname(fixture.path), + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.cause).toBe(reason); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(launches).toBe(0); + expect(fixture.opened()).toBe(phase === "admission" ? 0 : 1); + } finally { + await provider.close(); + await fixture.release(); + } + }, +); + +it("preserves a preflight caller cancelled while its preceding descriptor is being recovered", async () => { + const controller = new AbortController(); + const reason = { caller: "during retained-owner retry" }; + let recover = false; + const fixture = await faultyFuse({ + close: async () => { + if (!recover) throw new Error("close denied"); + controller.abort(reason); + }, + }); + let launches = 0; + const provider = new PlaywrightElectronActiveProvider({}, async () => { + launches += 1; + throw new Error("Unexpected launch"); + }); + const input = { + ...electronActiveObservationInputSchema.parse({ + executable_path: fixture.path, + application_path: fixture.path, + }), + application_root: dirname(fixture.path), + }; + try { + const first = await provider.capture(input); + if (first.ok) throw new Error("Expected retained descriptor"); + expect(first.error.cleanupIncomplete).toBe(true); + recover = true; + const result = await provider.capture(input, { signal: controller.signal }); + if (result.ok) throw new Error("Expected queued admission cancellation"); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(first.error.cleanupIncomplete).toBe(false); + expect(fixture.opened()).toBe(1); + expect(launches).toBe(0); + } finally { + recover = true; + await provider.close(); + await fixture.release(); + } +}); + +it("joins shutdown during preflight and refuses any later launch or capture", async () => { + const root = await createTestTempDirectory("rea-fuse-preflight-shutdown-"); + const path = join(root, "selected"); + await fileSystem.writeFile(path, thinFuseImage([0x31]).bytes); + const entered = createDeferred(); + const release = createDeferred(); + const launch = vi.fn(async () => { + throw new Error("Launch must not occur after shutdown"); + }); + const read = vi.fn( + async (...args: Parameters) => { + entered.resolve(); + await release.promise; + return readElectronFuseImage(...args); + }, + ); + const provider = new PlaywrightElectronActiveProvider({}, launch, read); + const input = { + ...electronActiveObservationInputSchema.parse({ + executable_path: path, + application_path: path, + }), + application_root: root, + }; + const running = provider.capture(input); + await entered.promise; + let closed = false; + const closing = provider.close().then(() => { + closed = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(closed).toBe(false); + release.resolve(); + expect((await running).ok).toBe(false); + await closing; + expect(launch).not.toHaveBeenCalled(); + expect((await provider.capture(input)).ok).toBe(false); + expect(read).toHaveBeenCalledOnce(); + } finally { + release.resolve(); + await running; + await closing; + } +}); + +it("releases successful descriptor peers while retaining the failed owner's exact retry", async () => { + const root = await createTestTempDirectory("rea-fuse-mixed-descriptors-"); + const paths = [join(root, "first"), join(root, "second")]; + await Promise.all( + paths.map((path) => + fileSystem.writeFile(path, thinFuseImage([0x31]).bytes), + ), + ); + const originalOpen = fileSystem.open; + const controls = new Map< + string, + { allowed: boolean; descriptor: number; release: () => Promise } + >(); + vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => { + const handle = await originalOpen(...args); + const path = String(args[0]); + if (!paths.includes(path)) return handle; + const release = handle.close.bind(handle); + const control = { allowed: false, descriptor: handle.fd, release }; + controls.set(path, control); + vi.spyOn(handle, "close").mockImplementation(async () => { + if (!control.allowed) throw new Error("authored peer close denial"); + await release(); + }); + return handle; + }); + syncBuiltinESMExports(); + const provider = new ElectronFuseProvider(); + try { + const results = await Promise.all( + paths.map((path) => provider.inspect({ path })), + ); + expect(controls.size).toBe(2); + const first = controls.get(paths[0]!)!; + const second = controls.get(paths[1]!)!; + first.allowed = true; + let aggregate: unknown; + try { + await provider.close(); + } catch (cause) { + aggregate = cause; + } + expect(aggregate).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: [paths[1]], + }); + expect(() => fstatSync(first.descriptor)).toThrow(); + expect(fstatSync(second.descriptor)).toBeDefined(); + expect(results[0]).toMatchObject({ + error: { cleanupIncomplete: false, cleanupResources: [] }, + }); + expect(results[1]).toMatchObject({ + error: { cleanupIncomplete: true, cleanupResources: [paths[1]] }, + }); + second.allowed = true; + await provider.close(); + expect(aggregate).toMatchObject({ + cleanupIncomplete: false, + cleanupResources: [], + }); + expect(results[1]).toMatchObject({ + error: { cleanupIncomplete: false, cleanupResources: [] }, + }); + expect(() => fstatSync(second.descriptor)).toThrow(); + } finally { + for (const control of controls.values()) { + control.allowed = true; + await control.release(); + } + await provider.close(); + } +}); diff --git a/src/electron/ElectronFuseCompletion.test.ts b/src/electron/ElectronFuseCompletion.test.ts new file mode 100644 index 0000000000..676c67bf72 --- /dev/null +++ b/src/electron/ElectronFuseCompletion.test.ts @@ -0,0 +1,184 @@ +import { join } from "node:path"; +import { mkdir, open, writeFile } from "node:fs/promises"; +import { expect, it, vi } from "vitest"; +import { ElectronFuseProvider } from "./ElectronFuseProvider.js"; +import { ElectronFuseService } from "../application/electron/ElectronFuseService.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { + thinFuseImage, + OBSIDIAN_FUSE_BYTES, +} from "../../tests/fixtures/electron/fuseImage.js"; + +const faults = vi.hoisted(() => ({ + path: "", + readFailure: undefined as Error | undefined, + onClose: undefined as (() => void) | undefined, + afterReader: undefined as (() => void) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + if (String(args[0]) === faults.path) { + const close = handle.close.bind(handle); + if (faults.readFailure !== undefined) { + const failure = faults.readFailure; + vi.spyOn(handle, "read").mockImplementation(async () => { + throw failure; + }); + } + handle.close = async () => { + await close(); + faults.onClose?.(); + }; + } + return handle; + }, + }; +}); + +it("preserves a physical read failure when abort arrives during successful final close", async () => { + const root = await createTestTempDirectory("rea-fuse-completion-failure-"); + const path = join(root, "selected"); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + const controller = new AbortController(); + const physical = Object.assign(new Error("authored physical read denial"), { + code: "EACCES", + }); + faults.path = path; + faults.readFailure = physical; + faults.onClose = () => controller.abort("late final close abort"); + try { + const result = await new ElectronFuseProvider().inspect( + { path }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisAccessDeniedError", + cause: physical, + cleanupIncomplete: false, + }, + }); + } finally { + faults.path = ""; + faults.readFailure = undefined; + faults.onClose = undefined; + } +}); + +it("retains completed Evidence and resolved bundle identity after successful-close cancellation", async () => { + const root = await createTestTempDirectory("rea-fuse-completion-evidence-"); + const bundle = join(root, "Authored.app"); + const path = join( + bundle, + "Contents", + "Frameworks", + "Electron Framework.framework", + "Electron Framework", + ); + await mkdir( + join(bundle, "Contents", "Frameworks", "Electron Framework.framework"), + { recursive: true }, + ); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + const controller = new AbortController(); + const reason = { caller: "abort after actual close" }; + faults.path = path; + faults.onClose = () => controller.abort(reason); + try { + const result = await new ElectronFuseService( + new ElectronFuseProvider(), + ).inspect({ path: bundle }, { signal: controller.signal }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + partialObservation: { + subject: { local_path: path, architecture: "arm64" }, + parameters: { path: bundle }, + normalized_result: { + artifact: { selected_path: bundle, resolved_path: path }, + slices: [{ architecture: "arm64", sentinel_count: 1 }], + }, + }, + }, + }); + } finally { + faults.path = ""; + faults.onClose = undefined; + } +}); + +it.each([null, { caller: "already aborted" }, undefined])( + "preserves early service abort reason %j without invoking its provider", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const inspect = vi.fn(); + const service = new ElectronFuseService({ + identity: { id: "not-invoked", name: "not invoked", version: "1" }, + inspect, + }); + const result = await service.inspect( + { path: "/not-opened" }, + { signal: controller.signal }, + ); + expect(inspect).not.toHaveBeenCalled(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(controller.signal.reason); + } + }, +); + +vi.mock("./ElectronFuseReader.js", async (importOriginal) => { + const reader = + await importOriginal(); + return { + ...reader, + readElectronFuseImage: async ( + ...args: Parameters + ) => { + const image = await reader.readElectronFuseImage(...args); + faults.afterReader?.(); + return image; + }, + }; +}); + +it.each([null, { caller: "reader handoff" }, undefined])( + "retains completed Evidence after reader finalization and before provider continuation (%j)", + async (reason) => { + const root = await createTestTempDirectory("rea-fuse-reader-handoff-"); + const path = join(root, "selected"); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + const provider = new ElectronFuseProvider(); + const service = new ElectronFuseService(provider); + const ordinary = await service.inspect({ path }); + if (!ordinary.ok) throw ordinary.error; + const controller = new AbortController(); + faults.afterReader = () => controller.abort(reason); + try { + const result = await service.inspect( + { path }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Expected cancellation at provider completion"); + expect(result.error.cause).toBe(controller.signal.reason); + expect(result.error.partialObservation).toEqual(ordinary.value); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + faults.afterReader = undefined; + await provider.close(); + } + }, +); diff --git a/src/electron/ElectronFuseFailures.ts b/src/electron/ElectronFuseFailures.ts new file mode 100644 index 0000000000..e7658c3a03 --- /dev/null +++ b/src/electron/ElectronFuseFailures.ts @@ -0,0 +1,157 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + ArtifactFileCleanupFailure, + artifactFileCancellationCause, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ZodError } from "zod"; +import { + RegularFileChangedError, + NonRegularFileReadError, +} from "../filesystem/RegularFile.js"; +import { ElectronFusePathFailure } from "./ElectronFusePath.js"; +import { + ElectronFuseFormatFailure, + ElectronFuseResourceFailure, +} from "./ElectronFuseReader.js"; + +/** Bundled fuse-wire reader. It does not flip fuses or launch the application. */ +export const ELECTRON_FUSE_PROVIDER_IDENTITY = { + id: "electron-fuses", + name: "REA Electron fuse reader", + version: "1", +} as const; + +const OPERATION = "inspect_electron_fuses"; + +/** Translate the shared fuse reader without replacing its producing outcome or cleanup owner. */ +export const electronFuseFailure = ( + cause: unknown, + path: string, + operation = OPERATION, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { + const cleanup = new ProviderCleanupError( + ELECTRON_FUSE_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation, cause }, + ); + bindArtifactFileCleanup(cause, cleanup); + if (cause instanceof ArtifactFileCleanupFailure) { + const outcome = cause.fileCleanupFailure.outcome; + const primary = + outcome.kind === "failed" + ? electronFuseFailure(outcome.cause, path, operation) + : cause.fileCleanupFailure.completionCancellation === undefined + ? undefined + : new AnalysisCancelledError(operation, { + cause: cause.fileCleanupFailure.completionCancellation.reason, + }); + if (primary !== undefined) + return analysisErrorWithCleanupFailure( + primary, + cleanup, + operation, + ).bindCleanupDependencies([primary, cleanup]); + } + return cleanup; + } + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + const cancellation = regularFileCancellation(cause); + if (cancellation !== undefined) + return new AnalysisCancelledError(operation, { + cause: cancellation.reason, + }); + if (cause instanceof ElectronFusePathFailure) + return new AnalysisInputError(operation, { cause }, [ + { path: ["path"], reason: cause.reason, message: cause.message }, + ]); + if (cause instanceof ElectronFuseResourceFailure) + return new AnalysisResourceConstraintError( + operation, + cause.boundary === "file-size" ? "file-size" : "capacity", + cause.message, + cause.boundary === "file-size" + ? { maximum_input_bytes: 1024 * 1024 * 1024 } + : { maximum_sentinels_per_slice: 32 }, + { cause }, + ); + if (cause instanceof ElectronFuseFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError(operation, path, cause.message, { + cause, + }) + : new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${path}: ${cause.message}`, + }, + ]); + if (cause instanceof RegularFileChangedError) + return new AnalysisArtifactChangedError(operation, path, cause.message, { + cause, + }); + if (cause instanceof NonRegularFileReadError) + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if (cause instanceof ZodError) + return new AnalysisOutputError( + operation, + "Electron fuse reader produced a report outside its schema.", + { cause }, + ); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError( + ELECTRON_FUSE_PROVIDER_IDENTITY.id, + operation, + { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }, + ); +}; diff --git a/src/electron/ElectronFusePath.ts b/src/electron/ElectronFusePath.ts index 0836c961cb..95d56f4b36 100644 --- a/src/electron/ElectronFusePath.ts +++ b/src/electron/ElectronFusePath.ts @@ -1,3 +1,4 @@ +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; import type { BigIntStats } from "node:fs"; import { lstat, readlink } from "node:fs/promises"; import { basename, dirname, isAbsolute, sep } from "node:path"; @@ -46,7 +47,7 @@ export const resolveElectronFuseTarget = async ( selectedPath: string, signal?: AbortSignal, ): Promise => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const metadata = await lstat(selectedPath, { bigint: true }); let file: ResolvedElectronFuseFile; if (metadata.isSymbolicLink()) @@ -129,7 +130,7 @@ const resolveDirectorySuffix = async ( const pending: string[] = []; const nodes: ElectronFusePathNode[] = []; while (true) { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const name = basename(path); // lstat("link/") follows a link; inspect the named component itself. if (name !== "") while (path.endsWith(sep)) path = path.slice(0, -1); @@ -187,7 +188,7 @@ const resolveInside = async ( let hops = 0; let last: "file" | "directory" | undefined; while (pending.length > 0) { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const segment = pending.shift() ?? ""; if (segment === ".") continue; if (segment === "..") { diff --git a/src/electron/ElectronFuseProvider.ts b/src/electron/ElectronFuseProvider.ts index f1a906dd7b..6094065241 100644 --- a/src/electron/ElectronFuseProvider.ts +++ b/src/electron/ElectronFuseProvider.ts @@ -1,299 +1,132 @@ -import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { ElectronFusePort } from "../application/electron/ElectronFusePort.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { createElectronFuseEvidence } from "../domain/electron/electronFuseEvidence.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { - AnalysisAccessDeniedError, - AnalysisArtifactChangedError, - AnalysisCancelledError, - AnalysisInputError, - AnalysisOutputError, - AnalysisResourceConstraintError, - AnalysisUnsupportedTargetError, -} from "../domain/analysisErrorCore.js"; + assertArtifactReadActive, + ArtifactFileCancelled, + ArtifactFileCleanupFailure, +} from "../artifacts/ArtifactFileOperation.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { describeElectronFuseFindings, electronFuseReportSchema, type ElectronFuseReport, type InspectElectronFusesInput, } from "../domain/electron/electronFuses.js"; -import { ProviderAdapterError } from "../domain/providerAdapterError.js"; -import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; -import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { err, ok, type Result } from "../domain/result.js"; -import { ZodError } from "zod"; -import { RegularFileChangedError } from "../filesystem/RegularFile.js"; -import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; -import { - ElectronFusePathFailure, - resolveElectronFuseTarget, -} from "./ElectronFusePath.js"; +import { resolveElectronFuseTarget } from "./ElectronFusePath.js"; import { electronFuseReadLimitation, - ElectronFuseFormatFailure, - ElectronFuseResourceFailure, readElectronFuseImage, type ElectronFuseImage, } from "./ElectronFuseReader.js"; +import { + ELECTRON_FUSE_PROVIDER_IDENTITY, + electronFuseFailure, +} from "./ElectronFuseFailures.js"; -/** Bundled fuse-wire reader. It does not flip fuses or launch the application. */ -export const ELECTRON_FUSE_PROVIDER_IDENTITY = { - id: "electron-fuses", - name: "REA Electron fuse reader", - version: "1", -} as const; +export { ELECTRON_FUSE_PROVIDER_IDENTITY }; const OPERATION = "inspect_electron_fuses"; /** Read packaged Electron fuse wires from one selected local file or app bundle. */ export class ElectronFuseProvider implements ElectronFusePort { readonly identity = ELECTRON_FUSE_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + /** Retry the same failed descriptor owners, awaiting active reads first. */ + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw electronFuseFailure(cause, "artifact-resources"); + } + } async inspect( input: InspectElectronFusesInput, options?: ExecutionOptions, ): Promise> { try { - options?.signal?.throwIfAborted(); + return await this.#resources.run(() => this.#inspectFile(input, options)); + } catch (cause: unknown) { + return err(electronFuseFailure(cause, input.path)); + } + } + + async #inspectFile( + input: InspectElectronFusesInput, + options?: ExecutionOptions, + ): Promise> { + let resolvedPath = input.path; + let completed: ElectronFuseReport | undefined; + try { + assertArtifactReadActive(options?.signal); const located = await resolveElectronFuseTarget( input.path, options?.signal, ); - options?.signal?.throwIfAborted(); + assertArtifactReadActive(options?.signal); + resolvedPath = located.file.path; const image = await readElectronFuseImage(located.file.path, { + resources: this.#resources, selection: located.file, ...(options?.signal === undefined ? {} : { signal: options.signal }), }); - const findings = describeElectronFuseFindings(image.slices, image.format); - const parsed = electronFuseReportSchema.safeParse({ - artifact: { - selected_path: located.selectedPath, - resolved_path: located.file.path, - sha256: image.sha256, - bytes: image.bytes, - format: image.format, - }, - slices: image.slices, - runtime_implications: findings.runtime_implications, - limitations: [...findings.limitations, electronFuseReadLimitation()], - }); - if (!parsed.success) - throw new AnalysisOutputError( - OPERATION, - "Electron fuse reader produced a report outside its schema.", - { cause: parsed.error }, - ); - options?.signal?.throwIfAborted(); - return ok(parsed.data); + completed = reportFor(image, located.selectedPath, resolvedPath); + assertArtifactReadActive(options?.signal); + return ok(completed); } catch (cause: unknown) { - return err(failureFor(cause, input.path, options?.signal)); + const failure = electronFuseFailure(cause, input.path); + // Only this reader's completed physical outcome supplies partial fuse facts. + const outcome = + cause instanceof ArtifactFileCleanupFailure + ? (cause as ArtifactFileCleanupFailure) + .fileCleanupFailure.outcome + : cause instanceof ArtifactFileCancelled + ? (cause as ArtifactFileCancelled) + .fileCancellation.outcome + : undefined; + const report = + completed ?? + (outcome?.kind === "completed" + ? reportFor(outcome.value, input.path, resolvedPath) + : undefined); + if (report !== undefined) { + failure.retainPartialObservation( + createElectronFuseEvidence(input, report, this.identity), + ); + } + return err(failure); } } } -/** - * Completed fuse evidence kept when the final descriptor close cannot be - * confirmed. Marked explicitly partial: cleanup is still incomplete and the - * retained observation must not be mistaken for a finished report. - */ -const fuseImagePartialEvidence = ( - path: string, +const reportFor = ( image: ElectronFuseImage, -): Evidence => - createEvidence( - { path, sha256: image.sha256, format: image.format }, - ELECTRON_FUSE_PROVIDER_IDENTITY, - { - operation: OPERATION, - parameters: { path }, - result: jsonValueSchema.parse({ - artifact: { - sha256: image.sha256, - bytes: image.bytes, - format: image.format, - }, - slices: image.slices, - }), - confidence: "observed", - limitations: [ - "Partial observation: the file was read successfully, but the final descriptor close could not be confirmed, so cleanup remains incomplete.", - ], + selectedPath: string, + resolvedPath: string, +): ElectronFuseReport => { + const findings = describeElectronFuseFindings(image.slices, image.format); + const parsed = electronFuseReportSchema.safeParse({ + artifact: { + selected_path: selectedPath, + resolved_path: resolvedPath, + sha256: image.sha256, + bytes: image.bytes, + format: image.format, }, - ); - -const cleanupErrorFor = ( - cause: RegularFileCleanupFailure, -): ProviderCleanupError => - new ProviderCleanupError( - ELECTRON_FUSE_PROVIDER_IDENTITY.id, - [cause.path], - { - reason: - cause.cleanupCause instanceof Error - ? cause.cleanupCause.message - : String(cause.cleanupCause), - retention: cause.retention.kind, - }, - { operation: OPERATION, cause }, - ); - -/** - * A rejected descriptor close keeps its exact owner and must never replace - * the read outcome or be classified as a read permission failure. Report the - * primary error with its own classification and incomplete cleanup as a - * separate, truthful error. - * - * In this provider the only producer of these failures is - * {@link readElectronFuseImage}, so a completed outcome always carries an - * {@link ElectronFuseImage}. - */ -const cleanupIncompleteFor = ( - cause: RegularFileCleanupFailure, - path: string, - signal?: AbortSignal, -): AnalysisError => { - const cleanup = cleanupErrorFor(cause); - // Cancellation must not hide the failed cleanup: compose it as the primary - // outcome while keeping the incomplete-cleanup report and descriptor owner. - const cancellation = regularFileCancellation(cause); - if (cancellation !== undefined) - return analysisErrorWithCleanupFailure( - new AnalysisCancelledError(OPERATION, { - cause: cancellation.reason, - ...(cause.outcome.kind === "completed" - ? { - partialObservation: fuseImagePartialEvidence( - path, - cause.outcome.value, - ), - } - : {}), - }), - cleanup, - OPERATION, - ); - if (cause.outcome.kind === "failed") - return analysisErrorWithCleanupFailure( - failureFor( - cause.outcome.cause, - path, - // A late abort during close must not replace an earlier read failure. - cause.outcome.cause === signal?.reason ? signal : undefined, - ), - cleanup, - OPERATION, - ); - return analysisErrorWithCleanupFailure( - new ProviderAdapterError(ELECTRON_FUSE_PROVIDER_IDENTITY.id, OPERATION, { - cause, - partialObservation: fuseImagePartialEvidence(path, cause.outcome.value), - diagnostics: { - path, - reason: - "The file was read, but the descriptor close could not be confirmed, so only a partial observation is reported and cleanup stays incomplete.", - }, - }), - cleanup, - OPERATION, - ); -}; - -const failureFor = ( - cause: unknown, - path: string, - signal?: AbortSignal, -): AnalysisError => { - if (cause instanceof AnalysisError) return cause; - // A failed cleanup keeps its owner and incomplete-cleanup report even when - // the caller cancelled: cancelling must not turn it into a bare - // cancellation that silently drops the still-live descriptor. - if (cause instanceof RegularFileCleanupFailure) - return cleanupIncompleteFor( - cause as RegularFileCleanupFailure, - path, - signal, - ); - const cancellation = regularFileCancellation(cause); - if (cancellation !== undefined) - return new AnalysisCancelledError(OPERATION, { - cause: cancellation.reason, - }); - if (signal?.aborted) - return new AnalysisCancelledError(OPERATION, { cause: signal.reason }); - if (cause instanceof ElectronFusePathFailure) - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: cause.reason, message: cause.message }, - ]); - if (cause instanceof ElectronFuseResourceFailure) - return new AnalysisResourceConstraintError( - OPERATION, - cause.boundary === "file-size" ? "file-size" : "capacity", - cause.message, - cause.boundary === "file-size" - ? { maximum_input_bytes: 1024 * 1024 * 1024 } - : { maximum_sentinels_per_slice: 32 }, - { cause }, - ); - if (cause instanceof ElectronFuseFormatFailure) - return cause.kind === "unsupported" - ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { - cause, - }) - : new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: `Selected artifact ${path}: ${cause.message}`, - }, - ]); - if (cause instanceof RegularFileChangedError) - return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { - cause, - }); - if (cause instanceof NonRegularFileReadError) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); - if (cause instanceof ZodError) - return new AnalysisOutputError( + slices: image.slices, + runtime_implications: findings.runtime_implications, + limitations: [...findings.limitations, electronFuseReadLimitation()], + }); + if (!parsed.success) + throw new AnalysisOutputError( OPERATION, "Electron fuse reader produced a report outside its schema.", - { cause }, + { cause: parsed.error }, ); - if (cause instanceof Error && "code" in cause) { - if (cause.code === "EACCES" || cause.code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { - cause, - }); - if ( - ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( - String(cause.code), - ) - ) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, - }, - ]); - } - return new ProviderAdapterError( - ELECTRON_FUSE_PROVIDER_IDENTITY.id, - OPERATION, - { - cause, - diagnostics: { - path, - reason: cause instanceof Error ? cause.message : String(cause), - }, - }, - ); + return parsed.data; }; diff --git a/src/electron/ElectronFuseReader.ts b/src/electron/ElectronFuseReader.ts index c7b7640917..ff87881ac6 100644 --- a/src/electron/ElectronFuseReader.ts +++ b/src/electron/ElectronFuseReader.ts @@ -20,10 +20,11 @@ import { RegularFileChangedError, sameRegularFileState, } from "../filesystem/RegularFile.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { - withOwnedRegularFile, - type OwnedRegularFileOptions, -} from "../filesystem/OwnedRegularFile.js"; + assertArtifactReadActive, + withArtifactRegularFile, +} from "../artifacts/ArtifactFileOperation.js"; /** Admission and search bounds for one Electron executable. */ export const ELECTRON_FUSE_LIMITS = { @@ -85,17 +86,17 @@ export const readElectronFuseImage = async ( readonly signal?: AbortSignal; readonly chunkBytes?: number; readonly selection?: ResolvedElectronFuseFile; - readonly retainFailedClose?: OwnedRegularFileOptions["retainFailedClose"]; + readonly resources?: ArtifactResourceScope; }, ): Promise => { const signal = options?.signal; - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const selection = options?.selection; const recheckPath = async (candidate: string) => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); try { const metadata = await lstat(candidate, { bigint: true }); - signal?.throwIfAborted(); + assertArtifactReadActive(signal); return metadata; } catch (cause: unknown) { if (selection !== undefined && isMissingPath(cause)) @@ -144,15 +145,10 @@ export const readElectronFuseImage = async ( throw new TypeError( `chunkBytes must be an integer between 1 and ${ELECTRON_FUSE_LIMITS.chunkBytes}.`, ); - return withOwnedRegularFile( + return withArtifactRegularFile( path, - { - symlinks: "reject", - ...(signal === undefined ? {} : { signal }), - ...(options?.retainFailedClose === undefined - ? {} - : { retainFailedClose: options.retainFailedClose }), - }, + options?.resources ?? new ArtifactResourceScope(), + signal, async (owned) => { const opened = await owned.stat({ bigint: true }); if (!sameRegularFileState(before, opened)) @@ -194,7 +190,7 @@ const readerFor = signal: AbortSignal | undefined, ): ReadAt => async (offset, length) => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); if (length <= 0 || offset < 0 || offset >= size) return new Uint8Array(); const bounded = Math.min(length, size - offset); const buffer = Buffer.alloc(bounded); @@ -316,7 +312,7 @@ const scanSlices = async ( const chunk = Buffer.allocUnsafe(chunkBytes); let position = 0; while (position < size) { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const length = Math.min(chunkBytes, size - position); const read = await handle.read(chunk, 0, length, position); if (read.bytesRead !== length) throw new RegularFileChangedError(path); @@ -453,7 +449,7 @@ const readExact = async ( length: number, signal: AbortSignal | undefined, ): Promise => { - signal?.throwIfAborted(); + assertArtifactReadActive(signal); const buffer = Buffer.alloc(length); const read = await handle.read(buffer, 0, length, offset); if (read.bytesRead !== length) throw new RegularFileChangedError(path); diff --git a/src/electron/ElectronFuseReaderCleanup.test.ts b/src/electron/ElectronFuseReaderCleanup.test.ts index 6de350ff2c..40d3145e7d 100644 --- a/src/electron/ElectronFuseReaderCleanup.test.ts +++ b/src/electron/ElectronFuseReaderCleanup.test.ts @@ -1,3 +1,5 @@ +import { ArtifactFileCleanupFailure } from "../artifacts/ArtifactFileOperation.js"; +import { parseEvidence } from "../domain/evidence.js"; import { fstatSync } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import { join } from "node:path"; @@ -123,10 +125,14 @@ describe("electron fuse final-close failures", () => { ); armFaults(path, { readFailure, closeFailure }); - const failure = await readElectronFuseImage(path).catch( + const caught = await readElectronFuseImage(path).catch( (error: unknown) => error, ); + expect(caught).toBeInstanceOf(ArtifactFileCleanupFailure); + if (!(caught instanceof ArtifactFileCleanupFailure)) throw caught; + expect(caught.cleanupLease.released).toBe(false); + const failure = caught.fileCleanupFailure; // The close rejection must not replace the primary read error. expect(failure).toBeInstanceOf(RegularFileCleanupFailure); if (!(failure instanceof RegularFileCleanupFailure)) @@ -254,9 +260,14 @@ describe("electron fuse final-close failures", () => { // observation instead of being discarded with the report. const partial = result.error.partialObservation; expect(partial).toBeDefined(); - const serialized = JSON.stringify(partial); - expect(serialized).toContain("Partial observation"); - expect(serialized).toContain("electron-fuses"); + expect(parseEvidence(partial)).toMatchObject({ + provider: { id: "electron-fuses" }, + subject: { local_path: path }, + normalized_result: { + artifact: { selected_path: path, resolved_path: path }, + slices: [{ sentinel_count: 1 }], + }, + }); }); }); diff --git a/src/main/transport.ts b/src/main/transport.ts index 7e5b7e07b6..ecc1c8e6a1 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -85,6 +85,7 @@ export const startMcpTransport = async ( optionalProviders.browserObservation, optionalProviders.browserScenarioCapture, optionalProviders.electronObservation, + optionalProviders.electronActiveObservation, ].filter((provider) => provider !== undefined), ); let optionalProviderClose: Promise | undefined; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index a471f75d36..9889dc72bd 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -312,6 +312,7 @@ export const createServer = ( const goBinary = options.goBinary ?? createGoBinaryService(); const rustBinary = options.rustBinary ?? createRustBinaryService(); const peResources = createPeResourcesService(); + const electronFuses = options.electronFuses ?? createElectronFuseService(); const webNetworkCapture = options.webNetworkCapture ?? createWebNetworkCaptureService(environment); const javascriptRecovery = @@ -394,6 +395,7 @@ export const createServer = ( goBinary.close(), rustBinary.close(), peResources.close(), + electronFuses.close(), webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), ...(options.optionalObservationProviderOwner === "caller" @@ -402,6 +404,7 @@ export const createServer = ( options.browserObservation?.close?.() ?? Promise.resolve(), options.browserScenarioCapture?.close?.() ?? Promise.resolve(), options.electronObservation?.close?.() ?? Promise.resolve(), + options.electronActiveObservation?.close?.() ?? Promise.resolve(), ]), webRuntime.provider.close?.() ?? Promise.resolve(), artifactResources.close(), @@ -452,6 +455,7 @@ export const createServer = ( goBinary, rustBinary, peResources, + electronFuses, webNetworkCapture, }; registerBinaryAnalysisTools(toolContext); @@ -819,6 +823,7 @@ interface ServerToolContext extends ReturnType { readonly goBinary: GoBinaryService; readonly rustBinary: RustBinaryService; readonly peResources: PeResourcesService; + readonly electronFuses: ElectronFuseService; readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; readonly server: EvidenceMcpServer; @@ -885,6 +890,7 @@ const registerBinaryAnalysisTools = ({ }; const registerObservationTools = ({ + electronFuses, evidenceById, server, options, @@ -912,7 +918,7 @@ const registerObservationTools = ({ evidenceById, electron: options.electronObservation, electronActive: options.electronActiveObservation, - electronFuses: options.electronFuses ?? createElectronFuseService(), + electronFuses, observationLoadFailure: options.optionalProviderLoadFailures?.electronObservation, activeLoadFailure: diff --git a/tests/boundary/cli/electronFuseCleanupCli.test.ts b/tests/boundary/cli/electronFuseCleanupCli.test.ts new file mode 100644 index 0000000000..e03a72fe48 --- /dev/null +++ b/tests/boundary/cli/electronFuseCleanupCli.test.ts @@ -0,0 +1,100 @@ +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + thinFuseImage, + OBSIDIAN_FUSE_BYTES, +} from "../../fixtures/electron/fuseImage.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; + +cliTest.for([ + "normal", + "close-once", + "read-and-close-once", + "deny-until-allowed", +] as const)( + "owns the actual CLI fuse descriptor through %s", + async (mode, { processes }) => { + const root = await createTestTempDirectory("rea-cli-fuse-cleanup-"); + const path = join(root, "selected"); + const tracePath = join(root, "trace.jsonl"); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + const result = await processes.run( + process.execPath, + [ + "--import", + resolve("tests/fixtures/electron/fuseCleanupPreload.mjs"), + resolve("scripts/rea.mjs"), + "inspect-electron-fuses", + path, + "--json", + ], + { + env: { + PATH: process.env.PATH, + HOME: root, + REA_LOG_LEVEL: "silent", + REA_FUSE_PROBE_INPUT: path, + REA_FUSE_PROBE_MODE: mode, + REA_FUSE_PROBE_TRACE: tracePath, + REA_FUSE_PROBE_ALLOW: join(root, "allow"), + }, + timeoutMs: 30_000, + }, + ); + const trace = (await readFile(tracePath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(trace.filter((entry) => entry.kind === "open")).toHaveLength(1); + expect(result.signal, result.stderr).toBeNull(); + if (mode === "normal") { + expect(result.exitCode, result.stderr).toBe(0); + expect( + toolContract("inspect_electron_fuses").outputSchema.parse( + JSON.parse(result.stdout), + ).subject?.architecture, + ).toBe("arm64"); + } else { + expect(result.exitCode, result.stderr).toBe(1); + const failure = analysisErrorProjectionSchema.parse( + JSON.parse(result.stdout), + ); + const details = jsonObjectSchema.parse(failure.details ?? {}); + const persistent = mode === "deny-until-allowed"; + expect(failure.code).toBe( + persistent + ? "cleanup_incomplete" + : mode === "close-once" + ? "execution_failure" + : "access_denied", + ); + if (persistent) { + expect(failure.details).toMatchObject({ + cleanup: "incomplete", + resources: [path], + }); + } else { + expect(details.cleanup).not.toBe("incomplete"); + expect(details.resources).toBeUndefined(); + const closes = trace.filter((entry) => entry.kind === "close"); + expect(closes.map((entry) => entry.denied)).toEqual([true, false]); + expect(closes[0].descriptor).toBe(closes[1].descriptor); + } + if (mode === "close-once") + expect(parseEvidence(details.partial_observation)).toMatchObject({ + subject: { local_path: path, architecture: "arm64" }, + normalized_result: { slices: [{ sentinel_count: 1 }] }, + }); + } + // The probe releases only residue which production did not recover. + expect( + trace.filter((entry) => entry.kind === "probe-final-release"), + ).toHaveLength(mode === "deny-until-allowed" ? 1 : 0); + }, +); diff --git a/tests/boundary/process/browserScenarioProbeLifecycle.test.ts b/tests/boundary/process/browserScenarioProbeLifecycle.test.ts index bc07742389..452ca2a3a7 100644 --- a/tests/boundary/process/browserScenarioProbeLifecycle.test.ts +++ b/tests/boundary/process/browserScenarioProbeLifecycle.test.ts @@ -37,6 +37,21 @@ it("keeps the shared browser scenario provider open when the SDK discards its di }, close: scenarioClose, }; + const activeClose = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Fuse preflight cleanup failed")) + .mockResolvedValue(undefined); + const electronActiveObservation = { + identity: () => ({ + id: "active-electron", + name: "Active Electron", + version: "1", + }), + capture: async () => { + throw new Error("Unexpected Electron capture"); + }, + close: activeClose, + }; const factories: Parameters[0][] = []; const runtime: RuntimeDependencies = { env: {}, @@ -57,6 +72,7 @@ it("keeps the shared browser scenario provider open when the SDK discards its di browserObservation, browserScenarioCapture, electronObservation, + electronActiveObservation, }), }); if (!transport.ok) throw new Error("Transport failed"); @@ -70,17 +86,23 @@ it("keeps the shared browser scenario provider open when the SDK discards its di expect(browserClose).not.toHaveBeenCalled(); expect(scenarioClose).not.toHaveBeenCalled(); expect(electronClose).not.toHaveBeenCalled(); + expect(activeClose).not.toHaveBeenCalled(); await expect(transport.closeOptionalProviders()).rejects.toMatchObject({ - errors: [expect.objectContaining({ message: "Scenario cleanup failed" })], + errors: [ + expect.objectContaining({ message: "Scenario cleanup failed" }), + expect.objectContaining({ message: "Fuse preflight cleanup failed" }), + ], }); expect(browserClose).toHaveBeenCalledTimes(1); expect(scenarioClose).toHaveBeenCalledTimes(1); + expect(activeClose).toHaveBeenCalledTimes(1); expect(electronClose).toHaveBeenCalledTimes(1); await transport.closeOptionalProviders(); await transport.closeOptionalProviders(); expect(browserClose).toHaveBeenCalledTimes(1); expect(scenarioClose).toHaveBeenCalledTimes(2); + expect(activeClose).toHaveBeenCalledTimes(2); expect(electronClose).toHaveBeenCalledTimes(1); } finally { await transport.closeAndroid(); diff --git a/tests/fixtures/electron/fuseCleanupPreload.mjs b/tests/fixtures/electron/fuseCleanupPreload.mjs new file mode 100644 index 0000000000..630a1ae6d8 --- /dev/null +++ b/tests/fixtures/electron/fuseCleanupPreload.mjs @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +const selected = process.env.REA_FUSE_PROBE_INPUT; +const trace = process.env.REA_FUSE_PROBE_TRACE; +const mode = process.env.REA_FUSE_PROBE_MODE; +const allow = process.env.REA_FUSE_PROBE_ALLOW; +const record = (value) => + fs.appendFileSync(trace, JSON.stringify(value) + "\n"); +const open = fs.promises.open.bind(fs.promises); +const held = new Map(); +let attempts = 0; +fs.promises.open = async (path, ...args) => { + const handle = await open(path, ...args); + if (String(path) !== selected) return handle; + const descriptor = handle.fd; + const close = handle.close.bind(handle); + held.set(handle, { descriptor }); + record({ kind: "open", path: selected, descriptor }); + if (mode === "read-and-close-once") + handle.read = async () => { + throw Object.assign(new Error("authored physical read denial"), { + code: "EACCES", + }); + }; + handle.close = async () => { + const denied = + (mode.endsWith("once") && attempts++ === 0) || + (mode === "deny-until-allowed" && !fs.existsSync(allow)); + if (denied) { + record({ kind: "close", descriptor, denied: true }); + throw Object.assign(new Error("authored descriptor close denial"), { + code: "EACCES", + }); + } + await close(); + held.delete(handle); + record({ kind: "close", descriptor, denied: false }); + }; + return handle; +}; +syncBuiltinESMExports(); +process.on("exit", () => { + for (const [handle, { descriptor }] of held) { + if (handle.fd !== descriptor) continue; + fs.fstatSync(descriptor); + record({ kind: "probe-final-release", descriptor }); + fs.closeSync(descriptor); + } +}); diff --git a/tests/process-global/electronFuseCleanup.test.ts b/tests/process-global/electronFuseCleanup.test.ts new file mode 100644 index 0000000000..7c1447a214 --- /dev/null +++ b/tests/process-global/electronFuseCleanup.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; +import { join } from "node:path"; +import { afterEach, expect, vi } from "vitest"; + +import { ElectronFuseProvider } from "../../src/electron/ElectronFuseProvider.js"; +import { ElectronFuseService } from "../../src/application/electron/ElectronFuseService.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { thinFuseImage } from "../fixtures/electron/fuseImage.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../support/mcp/mcpFixture.js"; + +afterEach(() => { + vi.restoreAllMocks(); + syncBuiltinESMExports(); +}); + +mcpTest( + "retries the same fuse descriptor before admitting another MCP read and during server close", + async ({ mcp }) => { + const root = await createTestTempDirectory("rea-fuse-mcp-close-"); + const path = join(root, "application"); + await writeFile(path, thinFuseImage([0x31]).bytes); + const originalOpen = fs.promises.open; + const descriptors: number[] = []; + const releases: (() => Promise)[] = []; + let denied = true; + vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => { + if (String(args[0]) === path && descriptors.length > 0) + expect(() => fs.fstatSync(descriptors.at(-1)!)).toThrow(); + const file = await originalOpen(...args); + if (String(args[0]) !== path) return file; + descriptors.push(file.fd); + const close = file.close.bind(file); + releases.push(close); + vi.spyOn(file, "close").mockImplementation(async () => { + if (denied) + throw Object.assign(new Error("close denied"), { code: "EACCES" }); + await close(); + }); + return file; + }); + syncBuiltinESMExports(); + const session = createTestBinarySession(() => { + throw new Error("Unexpected deep provider"); + }); + const service = new ElectronFuseService(new ElectronFuseProvider()); + const server = createServer( + { kind: "session", session }, + { electronFuses: service }, + ); + try { + const client = await mcp.connect(server); + const inspect = () => + client.callTool({ + name: "inspect_electron_fuses", + arguments: { path }, + }); + const first = parseMcpToolError(await inspect()); + expect(first.error).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete", resources: [path] }, + }); + expect(fs.fstatSync(descriptors[0]!)).toBeDefined(); + const retry = parseMcpToolError(await inspect()); + expect(retry.error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: [path] }, + }); + expect(descriptors).toHaveLength(1); + denied = false; + const recovered = await inspect(); + expect(recovered.isError).not.toBe(true); + expect(descriptors).toHaveLength(2); + expect(() => fs.fstatSync(descriptors[1]!)).toThrow(); + denied = true; + parseMcpToolError(await inspect()); + expect(descriptors).toHaveLength(3); + await expect(server.close()).rejects.toBeInstanceOf(AggregateError); + expect(fs.fstatSync(descriptors[2]!)).toBeDefined(); + denied = false; + await server.close(); + expect(() => fs.fstatSync(descriptors[2]!)).toThrow(); + } finally { + denied = false; + for (const close of releases) await close(); + await server.close(); + await session.close(); + } + }, +);