From fc7f042c8cae607a4d18016788c05dc4711413dd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:49:07 +0800 Subject: [PATCH] refactor(lifecycle): require cleanup for offline providers Require resource-owning provider ports to expose cleanup and migrate test adapters to delegate or explicitly close their own resources. --- src/application/electron/ElectronFusePort.ts | 4 ++-- .../electron/ElectronFuseService.test.ts | 2 ++ src/application/electron/ElectronFuseService.ts | 2 +- src/application/erlang/BeamPort.ts | 2 +- src/application/erlang/BeamService.test.ts | 3 +++ src/application/erlang/BeamService.ts | 2 +- src/application/go/GoBinaryPort.ts | 2 +- src/application/go/GoBinaryService.test.ts | 9 +++++++++ src/application/go/GoBinaryService.ts | 2 +- src/application/il2cpp/Il2cppMetadataPort.ts | 2 +- .../il2cpp/Il2cppMetadataService.test.ts | 14 ++++++++++++-- src/application/il2cpp/Il2cppMetadataService.ts | 2 +- src/application/python/PythonPycPort.ts | 2 +- src/application/python/PythonPycService.test.ts | 5 +++++ src/application/python/PythonPycService.ts | 2 +- src/application/rust/RustBinaryPort.ts | 2 +- src/application/rust/RustBinaryService.test.ts | 10 ++++++++++ src/application/rust/RustBinaryService.ts | 2 +- src/electron/ElectronFuseCompletion.test.ts | 1 + tests/boundary/mcp/goBinaryMcp.test.ts | 1 + .../mcp/lateAbortApplicationEvidenceMcp.test.ts | 7 +++++-- 21 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/application/electron/ElectronFusePort.ts b/src/application/electron/ElectronFusePort.ts index 16b0eb9c10..202a8b28ef 100644 --- a/src/application/electron/ElectronFusePort.ts +++ b/src/application/electron/ElectronFusePort.ts @@ -12,8 +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; + /** Release owned read resources, retrying retained cleanup failures. */ + close(): Promise; inspect( input: InspectElectronFusesInput, options?: ExecutionOptions, diff --git a/src/application/electron/ElectronFuseService.test.ts b/src/application/electron/ElectronFuseService.test.ts index 59eeec14bc..1a501fee62 100644 --- a/src/application/electron/ElectronFuseService.test.ts +++ b/src/application/electron/ElectronFuseService.test.ts @@ -32,6 +32,7 @@ it("retains completed fuse Evidence when the provider completes after abort", as const controller = new AbortController(); const result = await new ElectronFuseService({ identity, + close: async () => {}, inspect: async () => { controller.abort(); return ok(report); @@ -58,6 +59,7 @@ it("preserves malformed fuse output ahead of late cancellation", async () => { const controller = new AbortController(); const invalid = await new ElectronFuseService({ identity, + close: async () => {}, inspect: async () => { controller.abort(); return ok(malformed); diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index dab580db09..7a23c862fb 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -20,7 +20,7 @@ export class ElectronFuseService { /** Await and retry the reader's exact retained descriptor owners. */ async close(): Promise { - await this.provider.close?.(); + await this.provider.close(); } /** Read fuse wires for one explicitly selected path without launching it. */ diff --git a/src/application/erlang/BeamPort.ts b/src/application/erlang/BeamPort.ts index bec5e2667b..aaaafe684e 100644 --- a/src/application/erlang/BeamPort.ts +++ b/src/application/erlang/BeamPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Stable read and BEAM IFF decoding, independent of a disassembler session. */ export interface BeamPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectErlangBeamInput, options?: ExecutionOptions, diff --git a/src/application/erlang/BeamService.test.ts b/src/application/erlang/BeamService.test.ts index 258897f3d8..031f771436 100644 --- a/src/application/erlang/BeamService.test.ts +++ b/src/application/erlang/BeamService.test.ts @@ -31,6 +31,7 @@ const report: ErlangBeam = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new BeamService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -62,6 +63,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected BEAM form", async () => { const result = await new BeamService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -78,6 +80,7 @@ it("discards an observation if the caller cancels while the provider completes", const controller = new AbortController(); const reason = { source: "caller-request", operation: "cancel" }; const service = new BeamService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); diff --git a/src/application/erlang/BeamService.ts b/src/application/erlang/BeamService.ts index bcf5e904b0..610aec6619 100644 --- a/src/application/erlang/BeamService.ts +++ b/src/application/erlang/BeamService.ts @@ -19,7 +19,7 @@ export class BeamService { constructor(readonly provider: BeamPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } async inspect( diff --git a/src/application/go/GoBinaryPort.ts b/src/application/go/GoBinaryPort.ts index 59a66777c6..a6fd60eedc 100644 --- a/src/application/go/GoBinaryPort.ts +++ b/src/application/go/GoBinaryPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline build-information reader with no target-execution authority. */ export interface GoBinaryPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: Omit, options?: ExecutionOptions, diff --git a/src/application/go/GoBinaryService.test.ts b/src/application/go/GoBinaryService.test.ts index ba7eb3deb7..efa65a4087 100644 --- a/src/application/go/GoBinaryService.test.ts +++ b/src/application/go/GoBinaryService.test.ts @@ -28,6 +28,7 @@ const report: GoBinary = { it("rejects invalid paths and extra caller arguments before invoking the reader", async () => { let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -49,6 +50,7 @@ it("rejects invalid paths and extra caller arguments before invoking the reader" it("requires a complete Evidence retention path before starting summary inspection", async () => { let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -67,6 +69,7 @@ it("requires a complete Evidence retention path before starting summary inspecti it("preserves unsupported subject architectures and does not duplicate metadata", async () => { const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }); @@ -113,6 +116,7 @@ it("preserves byte-valued build metadata and validates compiler source lengths", limitations: ["Some embedded strings contain non-UTF-8 bytes."], }; const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(partial)), }).inspect({ path: report.artifact.path }); @@ -128,6 +132,7 @@ it("preserves byte-valued build metadata and validates compiler source lengths", }, }; const invalid = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(mismatched)), }).inspect({ path: report.artifact.path }); @@ -163,6 +168,7 @@ it("refuses reader identity changes and invalid original source ranges", async ( outside, ]) { const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(value)), }).inspect({ path: report.artifact.path }); @@ -176,6 +182,7 @@ it("honors cancellation before reading and after a completed observation", async const reason = { request: "late Go cancellation" }; let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -212,6 +219,7 @@ it("keeps malformed provider output ahead of a late cancellation", async () => { artifact: { ...report.artifact, path: "/other" }, }; const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => { controller.abort(); @@ -240,6 +248,7 @@ it.each([ controller.abort(reason); let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: async () => { calls += 1; diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index 536c7c59a5..6f12cdc6f8 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -29,7 +29,7 @@ export class GoBinaryService { constructor(readonly provider: GoBinaryPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } /** Inspect one explicitly selected file without acquiring a binary session. */ diff --git a/src/application/il2cpp/Il2cppMetadataPort.ts b/src/application/il2cpp/Il2cppMetadataPort.ts index 4de8180594..bee54c43cb 100644 --- a/src/application/il2cpp/Il2cppMetadataPort.ts +++ b/src/application/il2cpp/Il2cppMetadataPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline metadata reader with no target-execution or decryption authority. */ export interface Il2cppMetadataPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: Il2cppMetadataProviderInput, options?: ExecutionOptions, diff --git a/src/application/il2cpp/Il2cppMetadataService.test.ts b/src/application/il2cpp/Il2cppMetadataService.test.ts index 696fcc158f..3102609ffb 100644 --- a/src/application/il2cpp/Il2cppMetadataService.test.ts +++ b/src/application/il2cpp/Il2cppMetadataService.test.ts @@ -23,6 +23,7 @@ const report = createEmptyIl2cppMetadataFixture(); const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) => new Il2cppMetadataService({ + close: async () => {}, identity, inspect: () => { calls.count += 1; @@ -104,6 +105,7 @@ it("reports cancellation before and after reading", async () => { const during = new AbortController(); const service = new Il2cppMetadataService({ + close: async () => {}, identity, inspect: () => { during.abort(); @@ -158,7 +160,11 @@ it("preserves source Evidence and requested output choices when publication fail path: "/output/seeds.tsv", }); const service = new Il2cppMetadataService( - { identity, inspect: async () => ok(linkedReport()) }, + { + close: async () => {}, + identity, + inspect: async () => ok(linkedReport()), + }, seedWriter(async () => err(failure)), ); const result = await service.inspectWithGhidraSeeds(selectedPair, { @@ -192,7 +198,11 @@ it("retains a published seed receipt if cancellation arrives after the atomic wr generic_instances: null, }; const service = new Il2cppMetadataService( - { identity, inspect: async () => ok(linkedReport()) }, + { + close: async () => {}, + identity, + inspect: async () => ok(linkedReport()), + }, seedWriter(async () => { controller.abort(reason); return ok(receipt); diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index be18ef7a2f..0c4c78136b 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -44,7 +44,7 @@ export class Il2cppMetadataService { async close(): Promise { let failure: AnalysisError | undefined; for (const close of [ - () => this.provider.close?.(), + () => this.provider.close(), () => this.seeds?.close(), ]) { try { diff --git a/src/application/python/PythonPycPort.ts b/src/application/python/PythonPycPort.ts index 09a0964559..59b9231eee 100644 --- a/src/application/python/PythonPycPort.ts +++ b/src/application/python/PythonPycPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Stable read and CPython pyc decoding, independent of a disassembler session. */ export interface PythonPycPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectPythonPycInput, options?: ExecutionOptions, diff --git a/src/application/python/PythonPycService.test.ts b/src/application/python/PythonPycService.test.ts index e8bba03e2e..950c4e3deb 100644 --- a/src/application/python/PythonPycService.test.ts +++ b/src/application/python/PythonPycService.test.ts @@ -69,6 +69,7 @@ const report: PythonPyc = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new PythonPycService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -89,6 +90,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected artifact and the header plus marshal ranges", async () => { const result = await new PythonPycService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -110,6 +112,7 @@ it("retains completed pyc Evidence and the opaque caller reason on late cancella const controller = new AbortController(); const reason = new AnalysisInputError("opaque caller metadata"); const service = new PythonPycService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); @@ -135,6 +138,7 @@ it("retains a pre-admission cancellation reason without calling the pyc provider const reason = new Error("caller stopped before pyc admission"); let calls = 0; const service = new PythonPycService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -155,6 +159,7 @@ it("retains a pre-admission cancellation reason without calling the pyc provider it("preserves malformed provider output instead of relabeling it as a late abort", async () => { const controller = new AbortController(); const service = new PythonPycService({ + close: async () => {}, identity, inspect: async () => { controller.abort(new Error("later caller stop")); diff --git a/src/application/python/PythonPycService.ts b/src/application/python/PythonPycService.ts index 3d77369b06..c891efd5f4 100644 --- a/src/application/python/PythonPycService.ts +++ b/src/application/python/PythonPycService.ts @@ -23,7 +23,7 @@ export class PythonPycService { constructor(readonly provider: PythonPycPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } async inspect( diff --git a/src/application/rust/RustBinaryPort.ts b/src/application/rust/RustBinaryPort.ts index 9007f8cc28..18b870756f 100644 --- a/src/application/rust/RustBinaryPort.ts +++ b/src/application/rust/RustBinaryPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline dependency reader with no target-execution authority. */ export interface RustBinaryPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectRustBinaryInput, options?: ExecutionOptions, diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index 28704c6503..f145d9502c 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -51,6 +51,7 @@ const report: RustBinary = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -71,6 +72,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected artifact and the dependency section range", async () => { const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -90,6 +92,7 @@ it("records the selected artifact and the dependency section range", async () => artifact: { ...report.artifact, path: "/artifacts/other" }, }; const invalid = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(mismatched)), }).inspect({ path: report.artifact.path }); @@ -107,6 +110,7 @@ it("rejects a cycle claim the package dependencies do not contain", async () => }, }; const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(invalidReport)), }).inspect({ path: report.artifact.path }); @@ -118,6 +122,7 @@ it("retains completed Rust Evidence on late cancellation and keeps output failur const controller = new AbortController(); const reason = { request: "late Rust cancellation" }; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); @@ -142,6 +147,7 @@ it("retains completed Rust Evidence on late cancellation and keeps output failur artifact: { ...report.artifact, path: "/artifacts/other" }, }; const invalid = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => { invalidController.abort(); @@ -179,6 +185,7 @@ it.each([ }, }; const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(invalidReport)), }).inspect({ path: report.artifact.path }); @@ -204,6 +211,7 @@ it.each([ controller.abort(reason); let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async () => { calls += 1; @@ -226,6 +234,7 @@ it.each([ it("requires retention and rejects contradictory detail before invoking the reader", async () => { let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -250,6 +259,7 @@ it("requires retention and rejects contradictory detail before invoking the read it("retains the unchanged parent and keeps delivery selectors out of provider inputs", async () => { const inputs: unknown[] = []; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async (input) => { inputs.push(input); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 3784f4ada7..015355a313 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -26,7 +26,7 @@ export class RustBinaryService { constructor(readonly provider: RustBinaryPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } /** Inspect one explicitly selected file without acquiring a binary session. */ diff --git a/src/electron/ElectronFuseCompletion.test.ts b/src/electron/ElectronFuseCompletion.test.ts index 676c67bf72..932d8fb4d8 100644 --- a/src/electron/ElectronFuseCompletion.test.ts +++ b/src/electron/ElectronFuseCompletion.test.ts @@ -124,6 +124,7 @@ it.each([null, { caller: "already aborted" }, undefined])( const inspect = vi.fn(); const service = new ElectronFuseService({ identity: { id: "not-invoked", name: "not invoked", version: "1" }, + close: async () => {}, inspect, }); const result = await service.inspect( diff --git a/tests/boundary/mcp/goBinaryMcp.test.ts b/tests/boundary/mcp/goBinaryMcp.test.ts index 76b814ecc0..3c682480f9 100644 --- a/tests/boundary/mcp/goBinaryMcp.test.ts +++ b/tests/boundary/mcp/goBinaryMcp.test.ts @@ -65,6 +65,7 @@ mcpTest( const provider = new GoBinaryProvider(); const session = createTestBinarySession(createCacheProvider([])); const service = new GoBinaryService({ + close: () => provider.close(), identity: provider.identity, inspect: async (input, options) => { const result = await provider.inspect(input, options); diff --git a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts index 1932446510..cd43628d70 100644 --- a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts @@ -1,11 +1,13 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished, vi } from "vitest"; -import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + createTestBinarySession, + createDeferred, +} from "../../fixtures/binarySession.js"; import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js"; import type { GoBinary } from "../../../src/domain/go/goBinary.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; -import { createDeferred } from "../../fixtures/binarySession.js"; import { ok } from "../../../src/domain/result.js"; import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js"; import type { BrowserObservationPort } from "../../../src/application/BrowserObservationPort.js"; @@ -29,6 +31,7 @@ it("retains late-cancelled Go artifact Evidence through MCP and keeps the server throw new Error("unexpected binary admission"); }); const service = new GoBinaryService({ + close: async () => {}, identity: { id: "fixture", name: "fixture", version: "1" }, inspect: async (_input, options) => { started.resolve();