From f9de41371f7536e2e22ce6e43c9314fa2f238a30 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:11 +0800 Subject: [PATCH] fix(electron): own fuse cleanup through adapters and shutdown Adapt the prepared descriptor lifecycle repair from xu91102/rea dbb7db16. Bind exact file cleanup leases, retain typed physical failures and cancellation reasons, and join CLI/MCP finalization and active preflight. Share canonical Evidence construction and retain completed reports across reader handoff cancellation. Scope active capture admission through shutdown and gate launch after close. Preserve successful descriptor peers and retry only unresolved owners. Separate process/transport retry ownership remains tracked in #4805. Validation: 168 focused checks passed, one Windows-only skip; built CLI and actual stdio MCP controlled descriptor recovery; build, static and docs checks. No natural OS fault or real Electron launch/teardown claim. Ordinary full replies and Evidence IDs remain byte identical across 30 small CLI tasks. Fixes #4658 Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- docs/testing.md | 7 + src/application/electron/ElectronFusePort.ts | 2 + .../electron/ElectronFuseService.ts | 62 +- .../ElectronActiveObservationPort.ts | 2 + src/browser/PlaywrightElectronActiveFuses.ts | 24 +- .../PlaywrightElectronActiveProvider.ts | 69 +- src/cli/electronCommands.ts | 37 +- src/domain/electron/electronFuseEvidence.ts | 57 ++ src/electron/ElectronFuseCleanup.test.ts | 604 ++++++++++++++++++ src/electron/ElectronFuseCompletion.test.ts | 184 ++++++ src/electron/ElectronFuseFailures.ts | 157 +++++ src/electron/ElectronFusePath.ts | 7 +- src/electron/ElectronFuseProvider.ts | 337 +++------- src/electron/ElectronFuseReader.ts | 32 +- .../ElectronFuseReaderCleanup.test.ts | 19 +- src/main/transport.ts | 1 + src/server/createServer.ts | 8 +- .../cli/electronFuseCleanupCli.test.ts | 100 +++ .../browserScenarioProbeLifecycle.test.ts | 24 +- .../fixtures/electron/fuseCleanupPreload.mjs | 49 ++ .../electronFuseCleanup.test.ts | 94 +++ 21 files changed, 1522 insertions(+), 354 deletions(-) create mode 100644 src/domain/electron/electronFuseEvidence.ts create mode 100644 src/electron/ElectronFuseCleanup.test.ts create mode 100644 src/electron/ElectronFuseCompletion.test.ts create mode 100644 src/electron/ElectronFuseFailures.ts create mode 100644 tests/boundary/cli/electronFuseCleanupCli.test.ts create mode 100644 tests/fixtures/electron/fuseCleanupPreload.mjs create mode 100644 tests/process-global/electronFuseCleanup.test.ts 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(); + } + }, +);