diff --git a/docs/development/testing.md b/docs/development/testing.md index 9ec1f45918..acce98b679 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -180,6 +180,16 @@ cause when a directly checked caller `AbortSignal` is converted to an follow indirect cancellation helpers, or prove that an explicit cause equals the caller's reason. Keep focused boundary and lifecycle tests for those facts. +Build a cancellation with `cancelledBy` from `src/domain/analysisErrorCore.ts`. +Its reason argument is required: an aborted `AbortSignal`, an earlier +`AnalysisCancelledError`, a `carriedReason(...)` recovered from an inner +failure, or an `OwnerCancellation` when REA itself closed the work's owner. The +architecture guard rejects a direct `new AnalysisCancelledError(` outside that +module. Files that still construct one directly are listed with their counts in +`scripts/verify/architecture-guard-baselines.json`; a file may not exceed its +count, and after removing copies run +`node scripts/verify/architecture-guards.mjs --shrink-baselines` to lower it. + `rea/no-evidence-json-preadmission` rejects direct generic JSON parses in `createEvidence` payload expressions; it does not analyze intermediate values. `rea/no-positional-tool-contract` rejects catalog indices used to select diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index b12e726ef2..dfc4d58175 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -1,4 +1,210 @@ { + "analysis-cancelled-constructor": { + "src/adb/AdbCommand.ts": 2, + "src/adb/AdbConfiguration.ts": 2, + "src/adb/AdbInputSnapshot.ts": 1, + "src/adb/AdbProvider.ts": 12, + "src/android/AndroidMappingFile.ts": 1, + "src/android/JadxConfiguration.ts": 2, + "src/android/JadxFailure.ts": 3, + "src/android/JadxJavaProbe.ts": 1, + "src/android/JadxProvider.ts": 5, + "src/android/JadxSession.ts": 1, + "src/apkSignature/ApkSignatureAdmission.ts": 2, + "src/apkSignature/apkSignatureFailures.ts": 1, + "src/apktool/ApktoolConfiguration.ts": 3, + "src/apktool/ApktoolProvider.ts": 7, + "src/apktool/ApktoolStringTables.ts": 1, + "src/application/AnalysisContextQueries.ts": 6, + "src/application/AnalysisDocument.ts": 3, + "src/application/AnalysisInput.ts": 1, + "src/application/BinaryTargetResolver.ts": 8, + "src/application/BrowserObservationService.ts": 6, + "src/application/BrowserScenarioCaptureService.ts": 1, + "src/application/CallPathTracing.ts": 4, + "src/application/DirectAnalysis.ts": 2, + "src/application/Doctor.ts": 4, + "src/application/EnhancedLiteralTracing.ts": 1, + "src/application/EnhancedTools.ts": 2, + "src/application/EvidenceBundleCommands.ts": 3, + "src/application/EvidenceBundleFiles.ts": 3, + "src/application/EvidenceInputResolver.ts": 2, + "src/application/JsonFiles.ts": 8, + "src/application/McpClientRegistration.ts": 2, + "src/application/McpRegistrationCatalog.ts": 2, + "src/application/McpRegistrationGit.ts": 2, + "src/application/ProviderOperationExecution.ts": 2, + "src/application/ReferenceSourceImportTypes.ts": 2, + "src/application/Setup.ts": 1, + "src/application/SetupCancellation.ts": 1, + "src/application/WebModuleTraceService.ts": 1, + "src/application/WebNetworkCaptureService.ts": 1, + "src/application/WebScriptExportService.ts": 6, + "src/application/WebSourceLocationService.ts": 1, + "src/application/XrefsAddressResolution.ts": 2, + "src/application/analysisView/AnalysisViewService.ts": 1, + "src/application/analysisView/evidenceFileSource.ts": 2, + "src/application/android/AndroidAnalysisService.ts": 1, + "src/application/apiContract/ApiContractRead.ts": 1, + "src/application/apiContract/ApiContractTraversal.ts": 1, + "src/application/binary/AnalysisProviderEvaluation.ts": 2, + "src/application/binary/AnalysisProviderRegistry.ts": 12, + "src/application/binary/AnalysisSnapshotFiles.ts": 1, + "src/application/binary/BinarySession.ts": 36, + "src/application/binary/LazyAnalysisProvider.ts": 1, + "src/application/binaryDiagnostics/ByteEntropyService.ts": 3, + "src/application/decompileDirectory/decompileAll.ts": 1, + "src/application/desktop/DesktopApplicationCapture.ts": 2, + "src/application/desktop/DesktopApplicationState.ts": 6, + "src/application/desktop/DesktopBuildCapture.ts": 2, + "src/application/desktop/DesktopBuildCaptureFailure.ts": 1, + "src/application/desktop/DesktopBuildComputers.ts": 2, + "src/application/desktop/DesktopBuildExport.ts": 1, + "src/application/desktop/DesktopBuildOwnedWork.ts": 1, + "src/application/desktop/DesktopBuildSecrets.ts": 3, + "src/application/desktop/DesktopBuildSolves.ts": 2, + "src/application/desktop/DesktopBuildSteps.ts": 1, + "src/application/desktop/DesktopBuildTurns.ts": 1, + "src/application/desktop/DesktopBuildWitnessBundle.ts": 1, + "src/application/desktop/DesktopCaptureRefusals.ts": 4, + "src/application/desktop/DesktopExportFiles.ts": 1, + "src/application/desktop/DesktopExportInstall.ts": 1, + "src/application/desktop/DesktopOutputTree.ts": 1, + "src/application/desktop/DesktopRemoteTaskReaders.ts": 1, + "src/application/desktop/DesktopTaskDraft.ts": 1, + "src/application/desktop/DesktopTaskReaderCleanup.ts": 1, + "src/application/environment/environmentBuild.ts": 1, + "src/application/firmware/FirmwareAnalysisService.ts": 1, + "src/application/frida/FridaInstrumentationService.ts": 7, + "src/application/gpu/GpuCodeObjectsService.ts": 2, + "src/application/il2cpp/Il2cppMetadataService.ts": 1, + "src/application/javascript/ElectronActiveObservationService.ts": 2, + "src/application/javascript/ElectronObservationService.ts": 2, + "src/application/javascript/JavaScriptArtifactAnalysis.ts": 3, + "src/application/javascript/JavaScriptRuntimeObservationService.ts": 2, + "src/application/javascript/SourceToBundleReference.ts": 3, + "src/application/javascript/StaticObservedEndpointComparisonService.ts": 2, + "src/application/model/ModelArtifactService.ts": 2, + "src/application/native/NativeFunctionMatchingService.ts": 1, + "src/application/native/NativeValueTrace.ts": 2, + "src/application/ntfs/NtfsJournalService.ts": 1, + "src/application/pcap/PcapAnalysisService.ts": 1, + "src/application/process/ProcessCli.ts": 5, + "src/application/process/ProcessScenarioPreparation.ts": 2, + "src/application/releaseExposure/ReleaseExposureService.ts": 3, + "src/application/reverse/ReverseEngineeringService.ts": 12, + "src/application/wasm/WasmInterfaceComparisonService.ts": 2, + "src/artifacts/ReleaseExposureReader.ts": 9, + "src/artifacts/StableArtifactFailure.ts": 1, + "src/browser/CdpBrowserProvider.ts": 4, + "src/browser/CdpCaptureValues.ts": 2, + "src/browser/CdpConnection.ts": 4, + "src/browser/CdpElectronInspection.ts": 1, + "src/browser/CdpElectronProvider.ts": 3, + "src/browser/CdpElectronScripts.ts": 1, + "src/browser/CdpElectronWorkers.ts": 1, + "src/browser/CdpEndpoint.ts": 4, + "src/browser/CdpObservationSession.ts": 1, + "src/browser/CdpScriptSourceCapture.ts": 1, + "src/browser/CdpTargetSessionOwner.ts": 4, + "src/browser/ElectronFileScope.ts": 2, + "src/browser/IsolatedWebBundleAnalyzer.ts": 1, + "src/browser/PlaywrightBrowserScenarioProvider.ts": 2, + "src/browser/PlaywrightElectronActiveActions.ts": 1, + "src/browser/PlaywrightElectronActiveProvider.ts": 1, + "src/browser/PlaywrightExecutionBoundary.ts": 2, + "src/browser/WebBundleAnalysisWorker.ts": 8, + "src/browser/WebSourceMapFetcher.ts": 9, + "src/browser/assets/PublishWebScripts.ts": 3, + "src/browser/assets/WebArtifactReads.ts": 2, + "src/browser/execution/CdpExecutionCapture.ts": 1, + "src/browser/execution/CdpExecutionWindow.ts": 1, + "src/browser/execution/CdpWebRuntimeProvider.ts": 2, + "src/browser/history/CaptureFailures.ts": 3, + "src/browser/history/HistoricalCaptureDecoder.ts": 1, + "src/browser/history/HistoricalCaptureReply.ts": 1, + "src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts": 2, + "src/browser/history/playwrightTrace/TraceFailure.ts": 1, + "src/browser/modules/NativeModuleResolver.ts": 3, + "src/captures/TsharkCaptureProvider.ts": 2, + "src/cli/environmentCommands.ts": 2, + "src/cli/finishAnalysisCommand.ts": 3, + "src/cliJsonInput.ts": 1, + "src/desktop/BuildComputerLongRuns.ts": 5, + "src/desktop/DesktopBuildDoctorFacts.ts": 5, + "src/desktop/DesktopBuildOwnedCommand.ts": 3, + "src/desktop/ScreenJudgeProcess.ts": 3, + "src/doctorRuntime.ts": 3, + "src/domain/apple/dylibResolution.ts": 1, + "src/domain/explicitSensitiveFailure.ts": 1, + "src/dotnet/ManagedSnapshot.ts": 5, + "src/dotnet/ManagedStaticProvider.ts": 2, + "src/electron/ElectronFuseFailures.ts": 3, + "src/evm/EvmoleFailures.ts": 2, + "src/evm/EvmoleInterfaceProvider.ts": 6, + "src/firmware/FirmwareFailures.ts": 1, + "src/firmware/FirmwareFiles.ts": 1, + "src/firmware/FirmwareProvider.ts": 1, + "src/flutter/FlutterBuildProvider.ts": 8, + "src/frida/FridaInstrumentationManager.ts": 2, + "src/gdb/GdbSessionManager.ts": 14, + "src/gpu/GpuCodeObjectsProvider.ts": 1, + "src/hopper/HopperAnalysisProfile.ts": 1, + "src/hopper/HopperCallerReconciliation.ts": 2, + "src/hopper/HopperFileOffset.ts": 1, + "src/hopper/HopperMachOImage.ts": 10, + "src/hopper/HopperRegexSearch.ts": 3, + "src/il2cpp/Il2cppGhidraSeedWriter.ts": 1, + "src/il2cpp/Il2cppMetadataProvider.ts": 1, + "src/inspector/JavaScriptRuntimeScope.ts": 1, + "src/inspector/V8InspectorProvider.ts": 2, + "src/javascript/analysis/JavaScriptAnalysisWorker.ts": 5, + "src/javascript/recovery/RecoveryFiles.ts": 3, + "src/javascript/recovery/WakaruCommand.ts": 2, + "src/javascript/recovery/WakaruProvider.ts": 2, + "src/javascript/sourceMaps/SourceMapCommand.ts": 4, + "src/javascript/sourceMaps/SourceMapDecoder.ts": 3, + "src/model/ModelArtifactProvider.ts": 1, + "src/native/AppleDispatchMetadata.ts": 3, + "src/native/LldbCallTracer.ts": 4, + "src/native/NativeCallObservation.ts": 2, + "src/native/NativeCallTarget.ts": 2, + "src/native/NativeCommandAnalysisError.ts": 1, + "src/native/NativeMacOSProvider.ts": 8, + "src/native/NativePlistSource.ts": 2, + "src/native/NativeUiInterruptionControl.ts": 1, + "src/native/NativeUiObservation.ts": 1, + "src/native/SignatureTargetBinding.ts": 5, + "src/native/pe/PeBinaryFailure.ts": 1, + "src/native/pwndbg/PwndbgCoreContext.ts": 3, + "src/native/pwntools/PwntoolsDecoder.ts": 8, + "src/native/pwntools/PwntoolsFailures.ts": 2, + "src/native/pwntools/PwntoolsRecordedCrashProvider.ts": 1, + "src/pcap/PcapCommand.ts": 3, + "src/pcap/PcapNativeBound.ts": 1, + "src/pcap/PcapOutput.ts": 1, + "src/pcap/PcapProvider.ts": 4, + "src/pcap/PcapSnapshot.ts": 3, + "src/process/ArtifactAnalysisResources.ts": 10, + "src/process/RegexMatcher.ts": 2, + "src/process/capture/ProcessCaptureError.ts": 2, + "src/process/ownedDebuggerSession.ts": 1, + "src/providers/AnalysisClientCreation.ts": 5, + "src/providers/AnalysisClientFailure.ts": 1, + "src/rizin/RizinDebugSessionManager.ts": 1, + "src/rust/RustBinaryProvider.ts": 1, + "src/server/McpToolRequest.ts": 1, + "src/server/analysisAdmission.ts": 1, + "src/server/registerApplicationTools/helpers.ts": 1, + "src/server/registerSessionRecordTools.ts": 1, + "src/server/runDerivedOperation.ts": 2, + "src/signals/SigrokRecordingProvider.ts": 2, + "src/sqlite/SqliteDatabaseFailures.ts": 4, + "src/sqlite/SqliteDatabaseProvider.ts": 4, + "src/sqlite/SqliteDatabaseWorkerExecution.ts": 2, + "src/wasm/WabtArtifactProvider.ts": 2, + "src/wasm/WabtFailures.ts": 2 + }, "parser-json-parse": { "src/android/JadxProtocol.ts": 2, "src/android/JadxWorkspaceRoot.ts": 1, diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index d6176f59dc..5ff65735e0 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { readFile, readdir, writeFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { compareCountedBaseline } from "./counted-baseline.mjs"; // Architecture guards for the strict-identity / best-effort-inference split. // - Composite dedup keys must not NUL-join variable-length or free-form @@ -174,45 +175,48 @@ const baselines = JSON.parse(await readFile(baselinePath, "utf8")); const shrinkBaselines = process.argv.includes("--shrink-baselines"); let baselinesShrunk = false; const checkCounted = (label, key, selected, pattern, owners) => { - const baseline = baselines[key] ?? {}; const counts = new Map(); for (const file of selected) { if (owners.includes(file.path)) continue; const count = file.content.match(pattern)?.length ?? 0; if (count > 0) counts.set(file.path, count); } - const grown = []; - const stale = []; - for (const [path, count] of counts) { - const allowed = baseline[path] ?? 0; - if (count > allowed) - grown.push(` ${path}: ${count} (baseline ${allowed})`); - } - for (const [path, allowed] of Object.entries(baseline)) { - const count = counts.get(path) ?? 0; - if (count < allowed) stale.push({ path, count, allowed }); - } - if (grown.length > 0) - failures.push(`${label}; new copies are not allowed:\n${grown.join("\n")}`); - if (stale.length === 0) return; - if (shrinkBaselines) { - for (const { path, count } of stale) - if (count === 0) delete baseline[path]; - else baseline[path] = count; - baselines[key] = baseline; - baselinesShrunk = true; - return; - } - failures.push( - `${label}; copies were removed, so lower the baseline with \`node scripts/verify/architecture-guards.mjs --shrink-baselines\`:\n${stale + const compared = compareCountedBaseline( + counts, + baselines[key] ?? {}, + shrinkBaselines, + ); + const lines = (differences) => + differences .map( ({ path, count, allowed }) => ` ${path}: ${count} (baseline ${allowed})`, ) - .join("\n")}`, - ); + .join("\n"); + if (compared.grown.length > 0) + failures.push( + `${label}; new copies are not allowed:\n${lines(compared.grown)}`, + ); + if (compared.baseline !== (baselines[key] ?? {})) { + baselines[key] = compared.baseline; + baselinesShrunk = true; + } + if (compared.stale.length > 0) + failures.push( + `${label}; copies were removed, so lower the baseline with \`node scripts/verify/architecture-guards.mjs --shrink-baselines\`:\n${lines(compared.stale)}`, + ); }; +// Cancellation reasons are required by `cancelledBy` in analysisErrorCore; a +// direct construction can omit or replace the caller's reason. +checkCounted( + "direct AnalysisCancelledError construction (use cancelledBy from src/domain/analysisErrorCore)", + "analysis-cancelled-constructor", + files, + /new AnalysisCancelledError\(/gu, + ["src/domain/analysisErrorCore.ts"], +); + // Byte-format parsers read untrusted input. Bounds, truncation and failure // kind belong to BinaryReader (src/native/metadata/BinaryContainer.ts) and // the ParseFailure algebra (src/domain/parseFailure.ts), not to each parser. diff --git a/scripts/verify/counted-baseline.d.mts b/scripts/verify/counted-baseline.d.mts new file mode 100644 index 0000000000..c44a7a97af --- /dev/null +++ b/scripts/verify/counted-baseline.d.mts @@ -0,0 +1,17 @@ +/** One file whose observed count differs from its recorded baseline. */ +export interface CountedBaselineDifference { + readonly path: string; + readonly count: number; + readonly allowed: number; +} + +/** Compare per-file counts with a baseline that may only shrink. */ +export function compareCountedBaseline( + counts: ReadonlyMap, + baseline: Readonly>, + shrink: boolean, +): { + readonly grown: CountedBaselineDifference[]; + readonly stale: CountedBaselineDifference[]; + readonly baseline: Record; +}; diff --git a/scripts/verify/counted-baseline.mjs b/scripts/verify/counted-baseline.mjs new file mode 100644 index 0000000000..9098e97e66 --- /dev/null +++ b/scripts/verify/counted-baseline.mjs @@ -0,0 +1,24 @@ +/** + * Compare observed per-file counts with a counted baseline. A file may not + * exceed its recorded count. A count below the baseline is stale; with + * `shrink`, the returned baseline lowers it (and drops zero entries) but never + * raises any count. + */ +export const compareCountedBaseline = (counts, baseline, shrink) => { + const grown = []; + const stale = []; + for (const [path, count] of counts) { + const allowed = baseline[path] ?? 0; + if (count > allowed) grown.push({ path, count, allowed }); + } + for (const [path, allowed] of Object.entries(baseline)) { + const count = counts.get(path) ?? 0; + if (count < allowed) stale.push({ path, count, allowed }); + } + if (!shrink || stale.length === 0) return { grown, stale, baseline }; + const lowered = { ...baseline }; + for (const { path, count } of stale) + if (count === 0) delete lowered[path]; + else lowered[path] = count; + return { grown, stale: [], baseline: lowered }; +}; diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index a28aefe2ce..e9bbb5458c 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -64,6 +64,8 @@ import { AnalysisResourceConstraintError, AnalysisTimeoutError, AnalysisUnsupportedTargetError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; @@ -1273,7 +1275,7 @@ export class AdbProvider { options?: ExecutionOptions, ): Promise> { if (this.#closing) - return err(new AnalysisCancelledError(request.operation)); + return err(cancelledBy(request.operation, OwnerCancellation.ownerClosed)); const release = this.#beginOperation(); try { try { diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 4a30f30a2e..8c8a8eb162 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -46,6 +46,8 @@ import { AnalysisResourceConstraintError, AnalysisTimeoutError, AnalysisUnsupportedTargetError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; @@ -300,7 +302,7 @@ export class ApktoolProvider { inspectAvailability(signal?: AbortSignal): Promise { if (this.#closing) return Promise.reject( - new AnalysisCancelledError("inspect_apktool_client"), + cancelledBy("inspect_apktool_client", OwnerCancellation.ownerClosed), ); const operationSignal = signal === undefined @@ -376,7 +378,7 @@ export class ApktoolProvider { ): Promise> { if (this.#closing) return Promise.resolve( - err(new AnalysisCancelledError(request.operation)), + err(cancelledBy(request.operation, OwnerCancellation.ownerClosed)), ); const signal = options?.signal === undefined diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index bb37a02b7e..9526b7dd81 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -13,8 +13,9 @@ import { import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { InvestigationStatistics } from "../../domain/investigationLifecycle.js"; import { - AnalysisCancelledError, AnalysisCapabilityUnavailableError, + cancelledBy, + OwnerCancellation, } from "../../domain/analysisErrorCore.js"; import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js"; import type { @@ -120,7 +121,7 @@ export abstract class BinarySessionRecords { evidence: Evidence, ): Result<"added" | "duplicate", AnalysisError> { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("record_evidence")); + return err(cancelledBy("record_evidence", OwnerCancellation.ownerClosed)); const recorded = this.#records.recordEvidence(evidence); if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged(); return recorded; @@ -200,7 +201,9 @@ export abstract class BinarySessionRecords { bundle: unknown, ): Result { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("import_evidence_bundle")); + return err( + cancelledBy("import_evidence_bundle", OwnerCancellation.ownerClosed), + ); const imported = this.#records.mergeEvidenceBundle(bundle); if (!imported.ok) return imported; this.#snapshot.refreshEvidenceMetadata(imported.value.replacements); @@ -266,7 +269,9 @@ export abstract class BinarySessionRecords { snapshot: AnalysisSnapshot, ): Result { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("import_analysis_snapshot")); + return err( + cancelledBy("import_analysis_snapshot", OwnerCancellation.ownerClosed), + ); const active = this.activeAnalysisBinding(); if (active !== undefined && this.#snapshotInvalidated) return err( @@ -465,7 +470,7 @@ export abstract class BinarySessionRecords { input: RecordUnknownInput, ): Result { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("record_unknown")); + return err(cancelledBy("record_unknown", OwnerCancellation.ownerClosed)); const target = this.activeAnalysisBinding()?.target; const recorded = this.#records.recordUnknown(input, target); if (recorded.ok) this.#emitSnapshotChanged(); @@ -477,7 +482,12 @@ export abstract class BinarySessionRecords { input: RecordUnknownInput, ): Result { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("record_evidence_with_unknown")); + return err( + cancelledBy( + "record_evidence_with_unknown", + OwnerCancellation.ownerClosed, + ), + ); const recorded = this.#records.recordEvidenceWithUnknown(evidence, input); if (recorded.ok) this.#emitSnapshotChanged(); return recorded; @@ -487,7 +497,7 @@ export abstract class BinarySessionRecords { input: UpdateUnknownInput, ): Result { if (this.#investigationDisposed) - return err(new AnalysisCancelledError("update_unknown")); + return err(cancelledBy("update_unknown", OwnerCancellation.ownerClosed)); const target = this.activeAnalysisBinding()?.target; const updated = this.#records.updateUnknown(input, target); if (updated.ok) this.#emitSnapshotChanged(); diff --git a/src/browser/IsolatedWebBundleAnalyzer.test.ts b/src/browser/IsolatedWebBundleAnalyzer.test.ts index bfc2624cb3..df2eef95aa 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.test.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.test.ts @@ -7,6 +7,7 @@ import { AnalysisOutputError, AnalysisResourceConstraintError, AnalysisTimeoutError, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import type { AnalyzeWebBundleInput, @@ -267,6 +268,26 @@ it("carries the exact caller cancellation reason and completed prefix facts", as }); }); +it("refuses work after close with REA's own cancellation reason, not an absent one", async () => { + const analyzer = new IsolatedWebBundleAnalyzer(() => ({ + analyze: async (script) => ok(analyzeCapturedWebScript(script)), + close: async () => {}, + })); + await analyzer.close(); + const captured = inspection(["fetch('/one');"]); + + const reserved = await analyzer.reserve(); + const analyzed = await analyzer.analyze(captured, noSourceMaps, input); + + for (const result of [reserved, analyzed]) { + if (result.ok) throw new Error("Closed analyzer accepted work"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(OwnerCancellation.ownerClosed); + } + if (analyzed.ok) throw new Error("Closed analyzer accepted work"); + expect(analyzed.error.partialObservation).toBe(captured); +}); + it("retains all completed facts when cancellation arrives during worker close", async () => { const captured = inspection(["fetch('/one');", "fetch('/two');"]); const closeStarted = deferred(); diff --git a/src/browser/IsolatedWebBundleAnalyzer.ts b/src/browser/IsolatedWebBundleAnalyzer.ts index d5473a8bc0..315a847d14 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.ts @@ -17,6 +17,8 @@ import { AnalysisTimeoutError, AnalysisResourceConstraintError, AnalysisOutputError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; @@ -91,7 +93,9 @@ export class IsolatedWebBundleAnalyzer { /** Reserve capacity before capture, so concurrent captures cannot retain source inventories. */ async reserve(): Promise> { if (this.#closed) - return err(new AnalysisCancelledError("analyze_web_bundle")); + return err( + cancelledBy("analyze_web_bundle", OwnerCancellation.ownerClosed), + ); if (this.#admission.active() !== undefined) return err( new AnalysisResourceConstraintError( @@ -124,7 +128,9 @@ export class IsolatedWebBundleAnalyzer { } if (this.#closed) { this.#admission.release(this); - return err(new AnalysisCancelledError("analyze_web_bundle")); + return err( + cancelledBy("analyze_web_bundle", OwnerCancellation.ownerClosed), + ); } return ok(undefined); } @@ -142,7 +148,7 @@ export class IsolatedWebBundleAnalyzer { if (this.#closed) return Promise.resolve( err( - new AnalysisCancelledError("analyze_web_bundle", { + cancelledBy("analyze_web_bundle", OwnerCancellation.ownerClosed, { partialObservation: inspection, }), ), diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index d26c714a03..db9f24a4c9 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -13,12 +13,13 @@ import { import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { isUtf8 } from "node:buffer"; import { - AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, reattributedResourceConstraint, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { randomUUID, @@ -178,10 +179,7 @@ const assertBridgeActive = ( signal: AbortSignal | undefined, operation: "cutter_command" | "list_cutter_sessions", ): void => { - if (signal?.aborted) - throw new AnalysisCancelledError(operation, { - cause: signal.reason, - }); + if (signal?.aborted) throw cancelledBy(operation, signal); }; const mapDiscovery = async ( @@ -421,9 +419,10 @@ export class CutterBridgeClient { if (cause instanceof AnalysisResourceConstraintError) throw cause; if (cause instanceof CutterBridgeRequestFailure) { if (cause.cancellation !== undefined) - throw new AnalysisCancelledError("list_cutter_sessions", { - cause: cause.cancellation.reason, - }); + throw cancelledBy( + "list_cutter_sessions", + carriedReason(cause.cancellation.reason), + ); if (cause.limit !== undefined) throw reattributedResourceConstraint( "list_cutter_sessions", @@ -604,9 +603,10 @@ export class CutterBridgeClient { // A transport failure escaping here precedes command admission. if (!(cause instanceof CutterBridgeRequestFailure)) throw cause; if (cause.cancellation !== undefined) - throw new AnalysisCancelledError("cutter_command", { - cause: cause.cancellation.reason, - }); + throw cancelledBy( + "cutter_command", + carriedReason(cause.cancellation.reason), + ); throw new AnalysisCapabilityUnavailableError( "cutter", "cutter_command", @@ -1013,9 +1013,10 @@ export class CutterBridgeClient { const onAbort = (): void => { settle({ ok: false, - cause: new AnalysisCancelledError("list_cutter_sessions", { - cause: signal?.reason, - }), + cause: cancelledBy( + "list_cutter_sessions", + carriedReason(signal?.reason), + ), }); }; signal?.addEventListener("abort", onAbort, { once: true }); @@ -1241,9 +1242,7 @@ const discoveryFailure = ( outcome?.kind === "failed" ? discoveryFailure(outcome.cause, operation) : cancellation !== undefined - ? new AnalysisCancelledError(operation, { - cause: cancellation.reason, - }) + ? cancelledBy(operation, carriedReason(cancellation.reason)) : undefined; if (primary === undefined && outcome?.kind !== "failed") return cleanup; const original = @@ -1257,8 +1256,9 @@ const discoveryFailure = ( ).bindCleanupDependencies([cleanup]); } if (cause.reason === "cancelled") - return new AnalysisCancelledError(operation, { - cause: artifactFileCancellationCause(cause), - }); + return cancelledBy( + operation, + carriedReason(artifactFileCancellationCause(cause)), + ); return cause; }; diff --git a/src/cutter/CutterBridgeService.ts b/src/cutter/CutterBridgeService.ts index bde3c2d74f..23ba115a2e 100644 --- a/src/cutter/CutterBridgeService.ts +++ b/src/cutter/CutterBridgeService.ts @@ -2,10 +2,10 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { - AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; @@ -94,19 +94,11 @@ export class CutterBridgeService { ): Promise> { const { signal } = options; if (signal?.aborted) - return err( - new AnalysisCancelledError("list_cutter_sessions", { - cause: signal.reason, - }), - ); + return err(cancelledBy("list_cutter_sessions", signal)); try { const discovery = await this.client.listSessions(options); if (signal?.aborted) - return err( - new AnalysisCancelledError("list_cutter_sessions", { - cause: signal.reason, - }), - ); + return err(cancelledBy("list_cutter_sessions", signal)); return ok(discovery); } catch (cause) { if (cause instanceof AnalysisError) return err(cause); @@ -156,11 +148,7 @@ export class CutterBridgeService { const parsed = cutterCommandInputSchema.safeParse(rawInput); if (!parsed.success) return err(new AnalysisInputError("cutter_command")); if (options.signal?.aborted) - return err( - new AnalysisCancelledError("cutter_command", { - cause: options.signal.reason, - }), - ); + return err(cancelledBy("cutter_command", options.signal)); const input = parsed.data; const cleanup = await this.retryCleanup(); if (!cleanup.ok) return cleanup; @@ -244,8 +232,7 @@ export class CutterBridgeService { } if (options.signal?.aborted) return err( - new AnalysisCancelledError("cutter_command", { - cause: options.signal.reason, + cancelledBy("cutter_command", options.signal, { partialObservation: evidence, }), ); diff --git a/src/domain/analysisErrorCore.cancellation.test.ts b/src/domain/analysisErrorCore.cancellation.test.ts new file mode 100644 index 0000000000..faad202567 --- /dev/null +++ b/src/domain/analysisErrorCore.cancellation.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; +import { + AnalysisCancelledError, + cancelledBy, + carriedReason, + isAborted, + OwnerCancellation, +} from "./analysisErrorCore.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; + +it("takes the cause from each kind of required cancellation reason", () => { + const callerReason = { caller: "stop" }; + const controller = new AbortController(); + controller.abort(callerReason); + + const fromSignal = cancelledBy("probe", controller.signal); + const fromEarlier = cancelledBy("outer", fromSignal); + const fromCarried = cancelledBy("carried", carriedReason(callerReason)); + const fromOwner = cancelledBy("closed", OwnerCancellation.ownerClosed); + + expect(fromSignal).toBeInstanceOf(AnalysisCancelledError); + expect(fromSignal.operation).toBe("probe"); + expect(fromSignal.cause).toBe(callerReason); + expect(fromEarlier.cause).toBe(callerReason); + expect(fromCarried.cause).toBe(callerReason); + expect(fromOwner.cause).toBe(OwnerCancellation.ownerClosed); + expect(OwnerCancellation.ownerClosed.kind).toBe("owner-closed"); +}); + +it("keeps the cancellation projection unchanged by the reason it carries", () => { + const controller = new AbortController(); + controller.abort(new Error("caller detail")); + const cleanup = { reason: "retained", resources: ["/tmp/owned"] }; + + const fromSignal = cancelledBy("probe", controller.signal, { + userMessage: "Stopped.", + cleanup, + }); + const fromOwner = cancelledBy("probe", OwnerCancellation.ownerClosed, { + userMessage: "Stopped.", + cleanup, + }); + + expect(projectAnalysisError(fromOwner)).toEqual( + projectAnalysisError(fromSignal), + ); + expect(JSON.stringify(projectAnalysisError(fromOwner))).not.toContain( + "OwnerCancellation", + ); +}); + +it("narrows only an aborted signal for cancelledBy", () => { + const controller = new AbortController(); + const optional: AbortSignal | undefined = controller.signal; + expect(isAborted(undefined)).toBe(false); + expect(isAborted(optional)).toBe(false); + controller.abort("caller"); + if (!isAborted(optional)) throw new Error("Expected an aborted signal"); + expect(cancelledBy("probe", optional).cause).toBe("caller"); +}); + +it("reads the reason of a signal that is not this realm's AbortSignal", () => { + const reason = { caller: "foreign" }; + const foreign = { aborted: true, reason } as unknown as AbortSignal; + expect(cancelledBy("probe", foreign).cause).toBe(reason); +}); + +it("does not accept a cause beside the required reason", () => { + const controller = new AbortController(); + controller.abort("caller"); + // @ts-expect-error The reason argument owns the cause. + const error = cancelledBy("probe", controller.signal, { cause: "other" }); + expect(error.cause).toBe("caller"); +}); diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index ff53875629..0800b1a4c2 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -310,7 +310,11 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { } } -/** Caller cancellation won before provider-neutral work completed. */ +/** + * Cancellation, by the caller or by REA closing the work's owner, won before + * provider-neutral work completed. Build it with `cancelledBy`, which requires + * the reason; `cause` holds that reason. + */ export class AnalysisCancelledError extends AnalysisError { readonly _tag = "AnalysisCancelledError"; override readonly userMessage: string | undefined; @@ -326,6 +330,85 @@ export class AnalysisCancelledError extends AnalysisError { } } +/** Why REA ended work on its own authority when no caller abort exists. */ +export type OwnerCancellationKind = + /** The provider, session, or service owning the work was closed or disposed. */ + "owner-closed"; + +/** + * A cancellation REA initiated itself, such as a provider closing. It stands + * in for a caller abort reason, so it is never mistaken for one. + */ +export class OwnerCancellation extends Error { + override readonly name = "OwnerCancellation"; + private constructor(readonly kind: OwnerCancellationKind) { + super("REA closed the owner of this operation before it completed"); + } + + /** + * One shared reason per kind, so repeated owner cancellations compare equal + * the way repeated observations of one caller abort do. + */ + static readonly ownerClosed = new OwnerCancellation("owner-closed"); +} + +/** A caller abort reason recovered from an earlier observation of that abort. */ +export interface CarriedCancellationReason { + readonly carried: unknown; +} + +/** Mark a reason already observed from the caller's abort, such as an inner failure's recorded reason. */ +export const carriedReason = (reason: unknown): CarriedCancellationReason => ({ + carried: reason, +}); + +/** + * Where a cancellation's reason comes from: + * - an aborted signal, whose `reason` is the caller's (or parent operation's) reason; + * - an earlier `AnalysisCancelledError`, whose reason is carried unchanged; + * - a reason carried from another earlier observation; + * - an owner cancellation that REA initiated itself. + */ +export type CancellationReason = + | AbortSignal + | AnalysisCancelledError + | CarriedCancellationReason + | OwnerCancellation; + +/** A signal observed aborted, so its `reason` is the cancellation's reason. */ +export type AbortedSignal = AbortSignal & { readonly aborted: true }; + +/** Whether an optional signal has aborted; narrows it to pass to `cancelledBy`. */ +export const isAborted = ( + signal: AbortSignal | undefined, +): signal is AbortedSignal => signal?.aborted === true; + +/** Options for a cancellation; its cause is always the required reason. */ +export type CancelledOptions = Omit & { + readonly cause?: never; + readonly userMessage?: string; +}; + +// Signals are matched structurally last: callers and tests may pass an +// AbortSignal implementation from another realm or a signal-shaped fake. +const cancellationCause = (reason: CancellationReason): unknown => { + if (reason instanceof AnalysisCancelledError) return reason.cause; + if (reason instanceof OwnerCancellation) return reason; + if ("carried" in reason) return reason.carried; + return reason.reason; +}; + +/** The one constructor for a cancellation: its reason is required. */ +export const cancelledBy = ( + operation: string, + reason: CancellationReason, + options?: CancelledOptions, +): AnalysisCancelledError => + new AnalysisCancelledError(operation, { + ...options, + cause: cancellationCause(reason), + }); + /** Provider-neutral operation exceeded its declared execution deadline. */ export class AnalysisTimeoutError extends AnalysisError { readonly _tag = "AnalysisTimeoutError"; diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 157a13fbec..df9acb0cd4 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -19,11 +19,13 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import { AnalysisCancelledError, AnalysisOutputError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { err, ok } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { SafeOutputTree } from "../artifacts/SafeOutputTree.js"; @@ -116,17 +118,18 @@ const waitForPredecessor = ( predecessor: Promise, operation: string, signal?: AbortSignal, -): Promise => - new Promise((resolve, reject) => { - const abort = () => - reject(new AnalysisCancelledError(operation, { cause: signal?.reason })); - signal?.addEventListener("abort", abort, { once: true }); - if (signal?.aborted === true) abort(); +): Promise> => { + if (signal === undefined) return predecessor.then(() => ok(undefined)); + return new Promise((resolve) => { + const abort = () => resolve(err(cancelledBy(operation, signal))); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); void predecessor.then(() => { - signal?.removeEventListener("abort", abort); - resolve(); + signal.removeEventListener("abort", abort); + resolve(ok(undefined)); }); }); +}; const limitations = [ "Signature matches and reported sizes do not establish partition validity, encryption or runtime mappings. Binwalk may infer unknown lengths before emitting its report.", "Only Linux is supported by this integration. One worker, inherited per-process address-space/file-size/CPU limits and a deadline constrain execution; staging byte and entry budgets are polled and may transiently overshoot. Limits are not aggregate process-memory or filesystem quotas.", @@ -166,26 +169,28 @@ export class FirmwareProvider implements FirmwareAnalysisPort { request: FirmwareRequest, options?: ExecutionOptions, ): Promise { - if (this.#closed) return err(new AnalysisCancelledError(request.operation)); + if (this.#closed) + return err(cancelledBy(request.operation, OwnerCancellation.ownerClosed)); const predecessor = this.#tail; let release = () => {}; this.#tail = new Promise((resolve) => { release = resolve; }); // Cancel the caller promptly without letting a later request bypass its predecessor. - try { - await waitForPredecessor(predecessor, request.operation, options?.signal); - } catch (cause: unknown) { + const waited = await waitForPredecessor( + predecessor, + request.operation, + options?.signal, + ); + if (!waited.ok) { void predecessor.then(release); - return err( - cause instanceof AnalysisError - ? cause - : new AnalysisCancelledError(request.operation), - ); + return err(waited.error); } try { if (this.#closed) - return err(new AnalysisCancelledError(request.operation)); + return err( + cancelledBy(request.operation, OwnerCancellation.ownerClosed), + ); const cleanup = await this.#retryCleanup(); if (cleanup.executionBlocked && cleanup.failure !== undefined) return err(cleanup.failure); diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 7e7655c1e2..664fd26269 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -12,6 +12,8 @@ import { AnalysisCancelledError, AnalysisInputError, AnalysisTimeoutError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { GDB_SESSION_TOOLS, @@ -695,7 +697,7 @@ export class GdbSessionManager { return err( signal?.aborted ? cancelledWithCallerReason("start_gdb_session", signal) - : new AnalysisCancelledError("start_gdb_session"), + : cancelledBy("start_gdb_session", OwnerCancellation.ownerClosed), ); if (!persistentDebuggerHostSupported(this.#platform)) return err( @@ -1497,7 +1499,9 @@ export class GdbSessionManager { > > { if (this.#closing) - return err(new AnalysisCancelledError("close_gdb_session")); + return err( + cancelledBy("close_gdb_session", OwnerCancellation.ownerClosed), + ); const session = this.#sessions.get(sessionId); if (session === undefined) return err(gdbCloseUnavailable()); if (session.exited) return this.#finishSessionCleanup(session); diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 71f1c94a18..2122d2bf90 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -9,8 +9,8 @@ import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { - AnalysisCancelledError, AnalysisUnsupportedTargetError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -154,9 +154,7 @@ export const resolveGhidraAnalysisProfile = async ( disabledAnalyzers?: readonly string[], ): Promise> => { if (signal?.aborted === true) - return Promise.resolve( - err(new AnalysisCancelledError("open_binary", { cause: signal.reason })), - ); + return Promise.resolve(err(cancelledBy("open_binary", signal))); if (target.kind !== "executable") return Promise.resolve(ok({ profile: null })); if (installation.status === "unavailable") diff --git a/src/ghidra/GhidraFileArtifacts.ts b/src/ghidra/GhidraFileArtifacts.ts index 2bfbb6abb9..3e24535837 100644 --- a/src/ghidra/GhidraFileArtifacts.ts +++ b/src/ghidra/GhidraFileArtifacts.ts @@ -17,10 +17,11 @@ import { } from "../filesystem/readStableFileBytes.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCancelledError, AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisResourceConstraintError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -137,11 +138,11 @@ export const ghidraFileFailure = ( } const error = cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" - ? new AnalysisCancelledError(operation, { - cause: artifactFileCancellationCause(cause), - cleanup, - diagnostics, - }) + ? cancelledBy( + operation, + carriedReason(artifactFileCancellationCause(cause)), + { cleanup, diagnostics }, + ) : new ProviderAdapterError("ghidra", operation, { cause, cleanup, diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 474e0a612d..52b07f4f28 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -1,5 +1,8 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + cancelledBy, +} from "../domain/analysisErrorCore.js"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { probeGhidraJava, @@ -344,9 +347,7 @@ export const inspectGhidraInstallation = async ( host: GhidraInstallationHost = systemGhidraInstallationHost(), ): Promise => { if (options.signal?.aborted) - throw new AnalysisCancelledError("ghidra:installation", { - cause: options.signal.reason, - }); + throw cancelledBy("ghidra:installation", options.signal); const platform = options.platform ?? process.platform; const architecture = options.architecture ?? process.arch; const coordinates = await installationCoordinates( @@ -415,8 +416,7 @@ export const inspectGhidraInstallation = async ( }, }; if (options.signal?.aborted) - throw new AnalysisCancelledError("ghidra:installation", { - cause: options.signal.reason, + throw cancelledBy("ghidra:installation", options.signal, { diagnostics: ghidraInstallationDiagnostics( availableInstallation(coordinates, observation, options.javaHome), ), diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts index 80d58109ba..d34d378717 100644 --- a/src/ghidra/GhidraJavaProbe.ts +++ b/src/ghidra/GhidraJavaProbe.ts @@ -9,7 +9,7 @@ import { import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { randomUUID } from "node:crypto"; import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { cancelledBy, carriedReason } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; @@ -235,17 +235,20 @@ const observeGhidraJava = async ( ) { primary = inspectionCancellation ?? - new AnalysisCancelledError("ghidra:java", { - cause: commandFailure.cancellation.reason, - ...(output === undefined ? {} : { capturedOutput: output }), - ...(interpreted?.ok === true - ? { - diagnostics: { - java_observation: jsonValueSchema.parse(interpreted.value), - }, - } - : {}), - }); + cancelledBy( + "ghidra:java", + carriedReason(commandFailure.cancellation.reason), + { + ...(output === undefined ? {} : { capturedOutput: output }), + ...(interpreted?.ok === true + ? { + diagnostics: { + java_observation: jsonValueSchema.parse(interpreted.value), + }, + } + : {}), + }, + ); } else if (inspectionCancellation !== undefined) { primary = inspectionCancellation; } else if ( diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ef3420d26e..e0bef7f390 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -33,7 +33,12 @@ import { primaryAnalysisError, } from "../domain/analysisErrorBase.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + isAborted, + cancelledBy, + carriedReason, +} from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; @@ -283,11 +288,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { }, ): Promise> { if (isAborted(options.signal)) - return err( - new AnalysisCancelledError("open_binary", { - cause: options.signal?.reason, - }), - ); + return err(cancelledBy("open_binary", options.signal)); const paths = ghidraRuntimePaths(session.runtimeRoot); const platform = this.options.platform ?? process.platform; const existingProject = this.options.existingProject; @@ -422,11 +423,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ); } if (isAborted(options.signal)) - return err( - new AnalysisCancelledError("open_binary", { - cause: options.signal?.reason, - }), - ); + return err(cancelledBy("open_binary", options.signal)); const headlessArguments = ghidraHeadlessArguments({ platform, ...(psp === undefined ? {} : { psp: true as const }), @@ -593,9 +590,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, ); if (isAborted(options.signal)) { - throw new AnalysisCancelledError("open_binary", { - cause: options.signal?.reason, - }); + throw cancelledBy("open_binary", options.signal); } return ok(partialLaunch); } catch (cause: unknown) { @@ -605,9 +600,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ? cause : cause instanceof GhidraLaunchSupportError && cause.cancellation !== undefined - ? new AnalysisCancelledError("open_binary", { - cause: cause.cancellation.reason, - }) + ? cancelledBy( + "open_binary", + carriedReason(cause.cancellation.reason), + ) : undefined); const capturedOutput = ghidraLaunchCommandOutput(cause); const primary = @@ -1199,5 +1195,3 @@ export const ghidraHeadlessJavaOptions = ( // argv items instead of the shell-split analyzeHeadless option list. return { JDK_JAVA_OPTIONS: "", GHIDRA_HEADLESS_JAVA_OPTIONS: "" }; }; - -const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/ghidra/GhidraLoadImageAttest.ts b/src/ghidra/GhidraLoadImageAttest.ts index d7e494e629..77c104c62a 100644 --- a/src/ghidra/GhidraLoadImageAttest.ts +++ b/src/ghidra/GhidraLoadImageAttest.ts @@ -1,6 +1,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import type { AnalysisOperation } from "../application/AnalysisProvider.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { cancelledBy } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { @@ -52,8 +52,7 @@ export const attestGhidraNativeLoadImage = async ( if (!result.ok) return result; return input.signal?.aborted ? err( - new AnalysisCancelledError(input.operation, { - cause: input.signal.reason, + cancelledBy(input.operation, input.signal, { partialObservation: { kind: "native-load-image", stage: "completed", @@ -134,8 +133,7 @@ const compareLoadImage = async ( } if (signal?.aborted) return err( - new AnalysisCancelledError(operation, { - cause: signal.reason, + cancelledBy(operation, signal, { partialObservation: { kind: "native-load-image", stage: "measured", diff --git a/src/ghidra/GhidraNativeAotSnapshotOwner.ts b/src/ghidra/GhidraNativeAotSnapshotOwner.ts index 6226351c6e..3b62e0a360 100644 --- a/src/ghidra/GhidraNativeAotSnapshotOwner.ts +++ b/src/ghidra/GhidraNativeAotSnapshotOwner.ts @@ -6,7 +6,8 @@ import type { import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { AnalysisArtifactChangedError, - AnalysisCancelledError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { @@ -139,9 +140,7 @@ export class GhidraNativeAotSnapshotOwner { ), ); if (waited.status === "cancelled") - return err( - this.#cancelled(operation, callerSignal?.reason, observations), - ); + return err(this.#cancelled(operation, waited.reason, observations)); if (callerSignal?.aborted) return err( waited.value.ok @@ -163,9 +162,7 @@ export class GhidraNativeAotSnapshotOwner { const attempt = this.#attempt ?? this.#start(normalized); const waited = await attempt.wait(callerSignal); if (waited.status === "cancelled") - return err( - this.#cancelled(operation, callerSignal?.reason, observations), - ); + return err(this.#cancelled(operation, waited.reason, observations)); const settled = waited.value; // A completed physical failure or retained cleanup outranks a later abort. if (!settled.ok) { @@ -362,8 +359,7 @@ export class GhidraNativeAotSnapshotOwner { observations: JsonValue, partialObservation?: AnalysisPartialObservation, ): AnalysisError { - return new AnalysisCancelledError(operation, { - cause: reason, + return cancelledBy(operation, carriedReason(reason), { diagnostics: { measured_observation: observations }, partialObservation, }); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 63c6e8783e..1e2289787d 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -68,7 +68,11 @@ import { type GhidraInstallationHost, type GhidraInstallationInspection, } from "./GhidraInstallation.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + cancelledBy, + carriedReason, +} from "../domain/analysisErrorCore.js"; import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js"; import { admitGhidraLanguageOverride, @@ -200,8 +204,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { : undefined; const availability = this.#availability(installation, readiness); if (availability.status === "available" && signal.aborted) - throw new AnalysisCancelledError("binary_session", { - cause: signal.reason, + throw cancelledBy("binary_session", signal, { diagnostics: availability.diagnostics, }); return { @@ -391,8 +394,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { ); if (result.ok && signal.aborted) return err( - new AnalysisCancelledError("open_binary", { - cause: signal.reason, + cancelledBy("open_binary", signal, { diagnostics: { completed_profile_digest: result.value.profile?.digest ?? null, }, @@ -496,11 +498,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { ); if (!extensions.ok) return extensions; if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError("open_binary", { - cause: options.signal.reason, - }), - ); + return err(cancelledBy("open_binary", options.signal)); const seeds = await resolveGhidraAnalysisSeeds( this.config.ghidraSeedFile, this.#inspectionResources, @@ -622,10 +620,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { callerSignal === undefined ? this.#lifetime.signal : AbortSignal.any([callerSignal, this.#lifetime.signal]); - if (signal.aborted) - throw new AnalysisCancelledError("ghidra:inspection", { - cause: signal.reason, - }); + if (signal.aborted) throw cancelledBy("ghidra:inspection", signal); const work = Promise.resolve().then(() => operation(signal)); this.#inspectionWork.add(work); try { @@ -640,10 +635,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { operation: string, scope?: ProviderInspectionScope, ): Promise { - if (callerSignal?.aborted) - throw new AnalysisCancelledError(operation, { - cause: callerSignal.reason, - }); + if (callerSignal?.aborted) throw cancelledBy(operation, callerSignal); if (scope?.active) { const observed = this.#observations.get(scope); if (observed?.lifetime === this.#lifetime.signal) @@ -679,9 +671,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { } const waited = await attempt.wait(callerSignal); if (waited.status === "cancelled") - throw new AnalysisCancelledError(operation, { - cause: callerSignal?.reason, - }); + throw cancelledBy(operation, carriedReason(waited.reason)); if ( draining && !callerSignal?.aborted && @@ -701,12 +691,13 @@ export class GhidraProvider implements AnalysisProviderCandidate { waited.value.value.status === "available" && (callerSignal?.aborted || attempt.signal.aborted) ) - throw new AnalysisCancelledError(operation, { - cause: callerSignal?.aborted - ? callerSignal.reason - : attempt.signal.reason, - diagnostics: ghidraInstallationDiagnostics(waited.value.value), - }); + throw cancelledBy( + operation, + carriedReason( + callerSignal?.aborted ? callerSignal.reason : attempt.signal.reason, + ), + { diagnostics: ghidraInstallationDiagnostics(waited.value.value) }, + ); if (scope?.active && !callerSignal?.aborted && !attempt.signal.aborted) this.#observations.set(scope, { lifetime: this.#lifetime.signal, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index ce8a95ba65..001779d457 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -75,6 +75,8 @@ import { AnalysisResourceConstraintError, AnalysisTimeoutError, reattributedResourceConstraint, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; @@ -613,8 +615,7 @@ export const createGhidraProviderClient = (input: { : []; if (input.signal.aborted) return err( - new AnalysisCancelledError("create_analysis_client", { - cause: input.signal.reason, + cancelledBy("create_analysis_client", input.signal, { diagnostics: ghidraInstallationDiagnostics(installation), }), ); @@ -1525,14 +1526,18 @@ const projectPrimarySessionError = ( if ( failure.kind === "cancelled" || (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") - ) - return new AnalysisCancelledError(operation, { - ...context, - cause: + ) { + const { cause: _failure, ...cancellationContext } = context; + return cancelledBy( + operation, + carriedReason( failure.cancellation === undefined ? failure : failure.cancellation.reason, - }); + ), + cancellationContext, + ); + } if (failure.kind === "remote" && failure.remoteCode === "decompile_failed") return new ProviderAdapterError("ghidra", operation, { ...context, diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index 2c3257777e..ca3b471c31 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -5,8 +5,9 @@ import { isAbsolute, join } from "node:path"; import { z } from "zod"; import { sameRegularFileState } from "../filesystem/RegularFile.js"; import { - AnalysisCancelledError, AnalysisCapabilityUnavailableError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -248,10 +249,11 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => { : { code: admission.code, ...admission.detail }; const error = cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" - ? new AnalysisCancelledError("resolve_analysis_profile", { - cause: artifactFileCancellationCause(cause), - cleanup, - }) + ? cancelledBy( + "resolve_analysis_profile", + carriedReason(artifactFileCancellationCause(cause)), + { cleanup }, + ) : new AnalysisCapabilityUnavailableError( "ghidra", "resolve_analysis_profile", diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index c7aaa74cc0..4a0fb4f8bd 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -6,9 +6,9 @@ import { z } from "zod"; import type { AppConfig } from "../../config/types.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import { - AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisUnsupportedTargetError, + cancelledBy, } from "../../domain/analysisErrorCore.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; @@ -104,10 +104,7 @@ export const resolveGhidraExtensions = async ( .run(async () => { const extensions: GhidraExtension[] = []; for (const adapter of adapters) { - if (signal?.aborted) - return err( - new AnalysisCancelledError("open_binary", { cause: signal.reason }), - ); + if (signal?.aborted) return err(cancelledBy("open_binary", signal)); const configured = adapter.configuredPath(config); if (configured === undefined) continue; const refusal = adapter.unsupportedReason(target, platform); @@ -133,10 +130,7 @@ export const resolveGhidraExtensions = async ( ); } } - if (signal?.aborted) - return err( - new AnalysisCancelledError("open_binary", { cause: signal.reason }), - ); + if (signal?.aborted) return err(cancelledBy("open_binary", signal)); return ok(extensions); }) .catch((cause: unknown) => diff --git a/src/ida/IdaBusyHealth.ts b/src/ida/IdaBusyHealth.ts index 55ffc58958..daf69097d6 100644 --- a/src/ida/IdaBusyHealth.ts +++ b/src/ida/IdaBusyHealth.ts @@ -1,7 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { - AnalysisCancelledError, AnalysisTimeoutError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { idaRegistrationTimeoutGuidance } from "./IdaConfiguration.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -64,10 +64,7 @@ export const readSettledHealth = async ( const remaining = (): number => timeoutMs - (clock.now() - started); let lastBusy: LastBusyHealth | undefined; for (;;) { - if (signal?.aborted) - throw new AnalysisCancelledError("server_health", { - cause: signal.reason, - }); + if (signal?.aborted) throw cancelledBy("server_health", signal); const budgetMs = remaining(); if (budgetMs <= 0) throw lastBusy === undefined @@ -115,15 +112,10 @@ export const readSettledHealth = async ( cause instanceof Error && cause.name === "AbortError" ) - throw new AnalysisCancelledError("server_health", { - cause: signal.reason, - }); + throw cancelledBy("server_health", signal); throw cause; } - if (signal?.aborted) - throw new AnalysisCancelledError("server_health", { - cause: signal.reason, - }); + if (signal?.aborted) throw cancelledBy("server_health", signal); if (remaining() <= 0) throw expired(); } }; diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index b3fe6369a2..bb3a9cf9b4 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -4,7 +4,11 @@ import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + cancelledBy, + carriedReason, +} from "../domain/analysisErrorCore.js"; import type { AnalysisInputIssue } from "../domain/analysisErrorCore.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -229,22 +233,26 @@ export const readIdaConfiguration = ( const cancellation = regularFileCancellation(cause); if (cancellation !== undefined) { if (cause instanceof RegularFileCleanupFailure) { - const error = new AnalysisCancelledError("ida:registration", { - cause: cancellation.reason, - cleanup: { - reason: - cause.cleanupCause instanceof Error - ? cause.cleanupCause.message - : String(cause.cleanupCause), - resources: [path], + const error = cancelledBy( + "ida:registration", + carriedReason(cancellation.reason), + { + cleanup: { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + resources: [path], + }, }, - }); + ); closeLease?.bindCleanupError(error); throw error; } - throw new AnalysisCancelledError("ida:registration", { - cause: cancellation.reason, - }); + throw cancelledBy( + "ida:registration", + carriedReason(cancellation.reason), + ); } if (cause instanceof RegularFileCleanupFailure) { const primary = diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index 989e6125bc..cdff20fe1e 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -47,6 +47,7 @@ import { AnalysisCancelledError, AnalysisProtocolError, AnalysisResourceConstraintError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -465,8 +466,7 @@ export const createIdaMcpConnection = ( return await request(controller.signal); } catch (cause: unknown) { if (cause === cancellation) - throw new AnalysisCancelledError(operation, { - cause: signal.reason, + throw cancelledBy(operation, signal, { capturedOutput: capturedTransportOutput(), }); throw cause; diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 85e05bd0d8..6110ccd371 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -19,8 +19,8 @@ import { } from "../process/RegexMatcher.js"; import { AnalysisInputError, - AnalysisCancelledError, AnalysisProtocolError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { functionDossierSchema, @@ -131,9 +131,7 @@ export class IdaOperationRunner { args: Readonly>, ): Promise { if (this.options.signal?.aborted) - throw new AnalysisCancelledError(tool, { - cause: this.options.signal.reason, - }); + throw cancelledBy(tool, this.options.signal); const scoped = this.database === undefined ? args : { ...args, database: this.database }; const result = await this.connection.call(tool, scoped, { @@ -141,9 +139,7 @@ export class IdaOperationRunner { }); this.raw.push({ tool, arguments: scoped, result }); if (this.options.signal?.aborted) - throw new AnalysisCancelledError(tool, { - cause: this.options.signal.reason, - }); + throw cancelledBy(tool, this.options.signal); return result; } diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index e12688bf13..4e523a444e 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -12,7 +12,7 @@ import { type BinaryTarget, } from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { cancelledBy } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisClient, @@ -368,11 +368,7 @@ export class IdaProvider implements AnalysisProviderCandidate { options?: AnalysisProfileResolutionOptions, ) { if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError("ida:profile", { - cause: options.signal.reason, - }), - ); + return err(cancelledBy("ida:profile", options.signal)); const config = await this.#configuration(options); if (!config.ok) return config; const profile = createAnalysisProfile(IDA_PROVIDER_IDENTITY, { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 9e1a53206d..237e3399c4 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -15,6 +15,9 @@ import { AnalysisInputError, AnalysisTimeoutError, AnalysisResourceConstraintError, + cancelledBy, + carriedReason, + isAborted, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -97,9 +100,6 @@ const operationSet: ReadonlySet = new Set(IDA_OPERATIONS); const isIdaOperation = (operation: string): operation is IdaOperation => operationSet.has(operation); -const wasCancelled = (options: ExecutionOptions | undefined): boolean => - options?.signal?.aborted === true; - interface IdaOperationFailureContext { runner: IdaOperationRunner | undefined; metadataBefore: JsonValue | undefined; @@ -261,12 +261,8 @@ export class IdaSessionClient implements AnalysisClient { return Promise.resolve(undefined); const call = this.#tail.then(() => { if (!this.#ready || this.#closed || this.#closing) return undefined; - if (wasCancelled(options)) - return err( - new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }), - ); + if (isAborted(options?.signal)) + return err(cancelledBy(operation, options?.signal)); return this.#execute(operation, parameters, options, true); }); this.#tail = call.then( @@ -275,11 +271,7 @@ export class IdaSessionClient implements AnalysisClient { ); const result = await waitForAbortable(call, options?.signal); return result === ABORTED - ? err( - new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }), - ) + ? err(cancelledBy(operation, carriedReason(options?.signal?.reason))) : result; } @@ -295,12 +287,8 @@ export class IdaSessionClient implements AnalysisClient { "IDA session is closing or closed; reopen the target.", ), ); - if (wasCancelled(options)) - return err( - new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }), - ); + if (isAborted(options?.signal)) + return err(cancelledBy(operation, options?.signal)); if (operation !== "health" && !isIdaOperation(operation)) return err( new AnalysisCapabilityUnavailableError( @@ -359,10 +347,8 @@ export class IdaSessionClient implements AnalysisClient { if (!readyOnly) await this.#start(operation); const before = await this.#observe(options); failureContext.metadataBefore = before; - if (wasCancelled(options)) - throw new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }); + if (isAborted(options?.signal)) + throw cancelledBy(operation, options?.signal); if (operation === "health") return this.#health(before); if (!isIdaOperation(operation)) throw new AnalysisProtocolError( @@ -371,20 +357,16 @@ export class IdaSessionClient implements AnalysisClient { const runner = this.#runner(options); failureContext.runner = runner; const result = await runner.run(operation, parameters); - if (wasCancelled(options)) - throw new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }); + if (isAborted(options?.signal)) + throw cancelledBy(operation, options?.signal); failureContext.postOperationIdentityCheck = "started"; const after = await this.#observe( options, (observed) => (failureContext.metadataAfter = observed), ); failureContext.postOperationIdentityCheck = "verified"; - if (wasCancelled(options)) - throw new AnalysisCancelledError(operation, { - cause: options?.signal?.reason, - }); + if (isAborted(options?.signal)) + throw cancelledBy(operation, options?.signal); return ok( createAnalysisExecution(result, IDA_PROVIDER_IDENTITY, { rawResult: { @@ -483,22 +465,27 @@ export class IdaSessionClient implements AnalysisClient { diagnostics: cause.diagnostics, }); } else if (artifactFailure?.reason === "cancelled") { - failure = new AnalysisCancelledError(operation, { - cause: artifactFileCancellationCause(artifactFailure), - }); + failure = cancelledBy( + operation, + carriedReason(artifactFileCancellationCause(artifactFailure)), + ); } else if (cause instanceof AnalysisCancelledError) { - failure = new AnalysisCancelledError(operation, { - cause: + failure = cancelledBy( + operation, + carriedReason( cause.cause === undefined ? options?.signal?.reason : cause.cause, - capturedOutput: cause.capturedOutput, - diagnostics: { ...cause.diagnostics, ...endpoint }, - partialObservation: cause.partialObservation, - cleanup: - cause.cleanup ?? - (cause.cleanupIncomplete - ? { reason: cause.message, resources: cause.cleanupResources } - : undefined), - }) + ), + { + capturedOutput: cause.capturedOutput, + diagnostics: { ...cause.diagnostics, ...endpoint }, + partialObservation: cause.partialObservation, + cleanup: + cause.cleanup ?? + (cause.cleanupIncomplete + ? { reason: cause.message, resources: cause.cleanupResources } + : undefined), + }, + ) .bindCleanupDependencies([cause]) .bindCleanupResourceOwner(cause); } else if ( diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index 50630fb3d8..ca43c159db 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -8,6 +8,9 @@ import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisCancelledError, AnalysisProtocolError, + cancelledBy, + carriedReason, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import type { JebOperation } from "../domain/jeb/jebAnalysis.js"; import { @@ -121,9 +124,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( : waited.value; if (outcome !== undefined && !outcome.ok) throw outcome.error; if (outcome === undefined || signal?.aborted === true) - throw new AnalysisCancelledError("jeb_mcp_catalog", { - cause: signal?.reason, - }); + throw cancelledBy("jeb_mcp_catalog", carriedReason(signal?.reason)); return outcome.value; }; @@ -184,10 +185,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( }; const connect = async (signal?: AbortSignal): Promise => { - if (signal?.aborted === true) - throw new AnalysisCancelledError("jeb_mcp_catalog", { - cause: signal.reason, - }); + if (signal?.aborted === true) throw cancelledBy("jeb_mcp_catalog", signal); if (closing !== undefined) await closing; if (closed) { if (state !== undefined) await dispose(state); @@ -338,7 +336,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( const owner = state; if (owner === undefined) return; owner.tools = undefined; - owner.signal.abort(); + owner.signal.abort(OwnerCancellation.ownerClosed); if (owner.acquisition !== undefined) await owner.acquisition.promise.catch(() => undefined); if (state === owner) await dispose(owner); diff --git a/src/jeb/JebMcpDeadline.ts b/src/jeb/JebMcpDeadline.ts index 19312ef42b..3ab2de7e26 100644 --- a/src/jeb/JebMcpDeadline.ts +++ b/src/jeb/JebMcpDeadline.ts @@ -1,7 +1,7 @@ import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { - AnalysisCancelledError, AnalysisTimeoutError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; @@ -82,9 +82,7 @@ export class JebMcpDeadline { cause: this.#deadline.signal.reason, ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs, this.source), }) - : new AnalysisCancelledError(operation, { - cause: this.#deadline.signal.reason, - }); + : cancelledBy(operation, this.#deadline.signal); } if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout) return new AnalysisTimeoutError(operation, this.timeoutMs, { diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 745e9e5c4f..a89b7a88bc 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -24,6 +24,7 @@ import { AnalysisCapabilityUnavailableError, AnalysisProtocolError, AnalysisTimeoutError, + cancelledBy, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -251,8 +252,7 @@ export class JebProvider implements JebAnalysisPort { ): Promise> { if (options?.signal?.aborted === true) return err( - new AnalysisCancelledError(request.operation, { - cause: options.signal.reason, + cancelledBy(request.operation, options.signal, { diagnostics: { endpoint: this.#selectedEndpoint }, }), ); @@ -269,8 +269,7 @@ export class JebProvider implements JebAnalysisPort { signal: AbortSignal | undefined, ): Promise> { const cancelled = (operation: JebOperation) => { - if (signal?.aborted === true) - throw new AnalysisCancelledError(operation, { cause: signal.reason }); + if (signal?.aborted === true) throw cancelledBy(operation, signal); }; switch (request.operation) { case "inspect_jeb_client": { @@ -354,8 +353,7 @@ export class JebProvider implements JebAnalysisPort { operation: JebOperation, signal: AbortSignal | undefined, ) { - if (signal?.aborted === true) - throw new AnalysisCancelledError(operation, { cause: signal.reason }); + if (signal?.aborted === true) throw cancelledBy(operation, signal); const payload = await this.#connection.call( "get_client_information", {}, @@ -555,7 +553,6 @@ export class JebProvider implements JebAnalysisPort { cause instanceof AnalysisTimeoutError ) { const options = { - cause: cause.cause, diagnostics: { ...cause.diagnostics, endpoint: this.#selectedEndpoint, @@ -568,6 +565,7 @@ export class JebProvider implements JebAnalysisPort { cause instanceof AnalysisTimeoutError ? new AnalysisTimeoutError(operation, cause.timeoutMs, { ...options, + cause: cause.cause, ...(cause.userMessage === undefined ? {} : { userMessage: cause.userMessage }), @@ -576,7 +574,7 @@ export class JebProvider implements JebAnalysisPort { : { remediationAction: cause.remediationAction }), ...(cause.limit === undefined ? {} : { limit: cause.limit }), }) - : new AnalysisCancelledError(operation, options); + : cancelledBy(operation, cause, options); return failure .bindCleanupDependencies([cause]) .bindCleanupResourceOwner(cause); diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index da1a15a10a..f572e009cf 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -9,10 +9,11 @@ import { import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { - AnalysisCancelledError, AnalysisOutputError, AnalysisResourceConstraintError, AnalysisTimeoutError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -116,8 +117,7 @@ export const kunaFileCleanupError = ( const diagnostics = kunaInstallationFailureDiagnostics(failure); const cancellation = regularFileCancellation(failure); if (cancellation !== undefined) - return new AnalysisCancelledError(operation, { - cause: cancellation.reason, + return cancelledBy(operation, carriedReason(cancellation.reason), { diagnostics, cleanup: { reason: message(failure.cleanupCause), @@ -246,9 +246,7 @@ export const runKunaCommand = async ( cause instanceof AnalysisError ? cause : cancellation !== undefined - ? new AnalysisCancelledError(request.operation, { - cause: cancellation.reason, - }) + ? cancelledBy(request.operation, carriedReason(cancellation.reason)) : new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, { cause, userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`, @@ -488,10 +486,11 @@ const commandFailure = ( }; let primary: AnalysisError; if (cause.reason === "cancelled") - primary = new AnalysisCancelledError(operation, { - ...details, - cause: cause.cancellation?.reason, - }); + primary = cancelledBy( + operation, + carriedReason(cause.cancellation?.reason), + capturedOutput === undefined ? undefined : { capturedOutput }, + ); else if (cause.reason === "timeout") { const { setting, ms } = request.deadline; const analysisSetting = diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index 91a5cae4a8..0b2722b5a0 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -9,7 +9,11 @@ import { appendCallerPath } from "../filesystem/callerPath.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + cancelledBy, + carriedReason, +} from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { @@ -216,8 +220,7 @@ export const createKunaResourceScope = ( const cancellation = kunaInstallationCancellation(cause); if (cancellation === undefined) return undefined; const pending = pendingLeases(); - const error = new AnalysisCancelledError(operation, { - cause: cancellation.reason, + const error = cancelledBy(operation, carriedReason(cancellation.reason), { diagnostics: cancellation.diagnostics, ...(pending.length === 0 ? {} diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 54cc73beb8..4f870ebe24 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -33,8 +33,9 @@ import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCancelledError, AnalysisResourceConstraintError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -574,11 +575,7 @@ export class KunaProjectCache { request: KunaProjectRequest, ): Promise> { if (request.signal?.aborted === true) - return err( - new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { - cause: request.signal.reason, - }), - ); + return err(cancelledBy(KUNA_EXPORT_OPERATION, request.signal)); const key = kunaProjectKey({ targetSha256: request.targetSha256, targetBasename: basename(request.targetPath), @@ -954,9 +951,10 @@ export class KunaProjectCache { if (entry.waiters === 0) entry.controller.abort(); resolve( err( - new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { - cause: request.signal?.reason, - }), + cancelledBy( + KUNA_EXPORT_OPERATION, + carriedReason(request.signal?.reason), + ), ), ); }; diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index ad54031b3a..9d684cb2b6 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -18,7 +18,8 @@ import { basename, join } from "node:path"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisArtifactChangedError, - AnalysisCancelledError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -177,12 +178,14 @@ export const kunaExportFailure = ( const { completion } = kunaCopyFailureFacts(cause); const primary = cause.reason === "cancelled" || completion?.cancellation !== undefined - ? new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { - cause: + ? cancelledBy( + KUNA_EXPORT_OPERATION, + carriedReason( completion?.cancellation === undefined ? artifactFileCancellationCause(cause) : completion.cancellation.reason, - }) + ), + ) : new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { cause, userMessage: `Kuna project cache failed: ${message(cause)}`, @@ -207,12 +210,13 @@ export const kunaExportFailure = ( return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION); const cancellation = regularFileCancellation(cause); if (cancellation !== undefined) - return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { - cause: cancellation.reason, - }); + return cancelledBy( + KUNA_EXPORT_OPERATION, + carriedReason(cancellation.reason), + ); if (cause instanceof AnalysisError) return cause; if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true) - return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause }); + return cancelledBy(KUNA_EXPORT_OPERATION, carriedReason(cause)); return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { cause, userMessage: `Kuna project cache failed: ${message(cause)}`, diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index 74c323be6a..ec03cc8d06 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -15,6 +15,8 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import { AnalysisCancelledError, AnalysisResourceConstraintError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -112,10 +114,7 @@ const readSpan = async ( const callerAborted = (): AbortSignal | undefined => signal?.aborted === true ? signal : undefined; const cancelled = callerAborted(); - if (cancelled !== undefined) - return err( - new AnalysisCancelledError(operation, { cause: cancelled.reason }), - ); + if (cancelled !== undefined) return err(cancelledBy(operation, cancelled)); const opened = await ownership.admitSpan(operation, signal, () => open(path, "r"), ); @@ -134,9 +133,7 @@ const readSpan = async ( // Preserve a fully acquired span for owned finalization, which records // cancellation alongside its text if descriptor cleanup fails. if (bytesRead < length && signal?.aborted) - throw (spanCancellation = new AnalysisCancelledError(operation, { - cause: signal.reason, - })); + throw (spanCancellation = cancelledBy(operation, signal)); }, ); if (buffer.length !== length) @@ -204,9 +201,7 @@ const readSpan = async ( (cause instanceof RegularFileOperationCancelled || cause instanceof RegularFileCancelled) ) - return err( - new AnalysisCancelledError(operation, { cause: aborted.reason }), - ); + return err(cancelledBy(operation, aborted)); throw cause; } }; @@ -267,9 +262,10 @@ const spanCloseFailure = ( ownership.bindSpanCleanup(failure.owner, marker); if (failure.completionCancellation !== undefined) return analysisErrorWithCleanupFailure( - new AnalysisCancelledError(operation, { - cause: failure.completionCancellation.reason, - }), + cancelledBy( + operation, + carriedReason(failure.completionCancellation.reason), + ), marker, operation, ).bindCleanupDependencies([marker]); @@ -453,9 +449,7 @@ export class KunaProjectReader { ); const index = await waitForAbortable(this.#assembly, signal); if (index === ABORTED) - return err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ); + return err(cancelledBy(operation, carriedReason(signal?.reason))); return index.ok ? index : err( diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 01ac21e993..aa66733808 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -9,7 +9,7 @@ import type { ProviderAvailability, ProviderTargetSupport, } from "../application/AnalysisProvider.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { cancelledBy } from "../domain/analysisErrorCore.js"; import { createAnalysisProfile, type AnalysisProfileCommitment, @@ -197,11 +197,7 @@ export class KunaProvider implements AnalysisProviderCandidate { options?: AnalysisProfileResolutionOptions, ) { if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError("kuna:profile", { - cause: options.signal.reason, - }), - ); + return err(cancelledBy("kuna:profile", options.signal)); let installation: Awaited>; const resourceScope = options?.resourceScope; if (resourceScope === undefined) diff --git a/src/kuna/KunaQueryWork.ts b/src/kuna/KunaQueryWork.ts index 96e9586721..31ac3ced81 100644 --- a/src/kuna/KunaQueryWork.ts +++ b/src/kuna/KunaQueryWork.ts @@ -2,6 +2,8 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisResourceConstraintError, + cancelledBy, + carriedReason, } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js"; @@ -54,21 +56,13 @@ export class KunaQueryWork { ): Promise> { const local = await this.#nativeSlots.acquire(request.signal); if (local === undefined) - return err( - new AnalysisCancelledError(request.operation, { - cause: request.signal.reason, - }), - ); + return err(cancelledBy(request.operation, request.signal)); let release: (() => void) | undefined; try { const foreground = await this.lifetime.runSlots.acquire(request.signal); if (foreground === undefined) { local(); - return err( - new AnalysisCancelledError(request.operation, { - cause: request.signal.reason, - }), - ); + return err(cancelledBy(request.operation, request.signal)); } release = () => { foreground(); @@ -133,10 +127,7 @@ export class KunaQueryWork { signal: AbortSignal, ): ReturnType { const release = await this.#regexSlots.acquire(signal); - if (release === undefined) - return err( - new AnalysisCancelledError(input.operation, { cause: signal.reason }), - ); + if (release === undefined) return err(cancelledBy(input.operation, signal)); try { return await matchRegexValues({ ...input, @@ -177,9 +168,7 @@ export class KunaQueryWork { // Final departure retains all producer failures, including a typed // cancellation with captured facts or incomplete cleanup. if (waited.status === "stopped" && !waited.value.ok) return waited.value; - return err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ); + return err(cancelledBy(operation, carriedReason(signal?.reason))); } /** Retire abandoned work before retrying, retaining its physical failures. */ @@ -195,8 +184,6 @@ export class KunaQueryWork { result.error.cleanupIncomplete) ) return result; - return signal?.aborted - ? err(new AnalysisCancelledError(operation, { cause: signal.reason })) - : ok(null); + return signal?.aborted ? err(cancelledBy(operation, signal)) : ok(null); } } diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 56a7487c80..1c2b07805d 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -31,6 +31,10 @@ import { AnalysisInputError, AnalysisResourceConstraintError, AnalysisTimeoutError, + cancelledBy, + carriedReason, + isAborted, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { @@ -217,7 +221,7 @@ const waitFor = async ( ): Promise> => { const result = await waitForAbortable(work, signal); return result === ABORTED - ? err(new AnalysisCancelledError(operation, { cause: signal?.reason })) + ? err(cancelledBy(operation, carriedReason(signal?.reason))) : result; }; @@ -230,9 +234,6 @@ const isUnavailableProject = (cause: unknown): boolean => cause.code === "ESTALE"); /** Read through a call so a check before an await does not narrow later ones. */ -const cancelled = (signal: AbortSignal | undefined): boolean => - signal?.aborted === true; - const message = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); @@ -507,12 +508,8 @@ export class KunaSessionClient implements AnalysisClient { // under #admitSpan, which shares its queue with that descriptor's close. const pendingDescriptors = await this.#pendingDescriptorCleanup(operation); if (pendingDescriptors !== undefined) return err(pendingDescriptors); - if (cancelled(options.signal)) - return err( - new AnalysisCancelledError(operation, { - cause: options.signal?.reason, - }), - ); + if (isAborted(options.signal)) + return err(cancelledBy(operation, options.signal)); if (operation !== "health" && !isKunaOperation(operation)) return err( kunaUnavailable( @@ -599,12 +596,8 @@ export class KunaSessionClient implements AnalysisClient { ); if (!answer.ok) return err(kunaQueryFailureForCaller(answer.error, operation)); - if (cancelled(options.signal)) - return err( - new AnalysisCancelledError(operation, { - cause: options.signal?.reason, - }), - ); + if (isAborted(options.signal)) + return err(cancelledBy(operation, options.signal)); return ok( this.#execution( kunaLimitations(operation, this.#deadline, this.#heapRequest), @@ -613,12 +606,15 @@ export class KunaSessionClient implements AnalysisClient { ), ); } catch (cause: unknown) { - if (cancelled(options.signal)) { + if (isAborted(options.signal)) { const cancellation = regularFileCancellation(cause); return err( - new AnalysisCancelledError(operation, { - cause: cancellation === undefined ? cause : cancellation.reason, - }), + cancelledBy( + operation, + carriedReason( + cancellation === undefined ? cause : cancellation.reason, + ), + ), ); } return err(failure(operation, cause)); @@ -633,7 +629,7 @@ export class KunaSessionClient implements AnalysisClient { if (this.#closed) return ok(null); if (!this.#closing) { this.#closing = true; - this.#session.abort(); + this.#session.abort(OwnerCancellation.ownerClosed); } // Cancellation starts process shutdown; its completion proves whether it // is safe to remove private copies and exposes any retained cleanup owner. @@ -1551,10 +1547,8 @@ export class KunaSessionClient implements AnalysisClient { installation: KunaInstallation, signal: AbortSignal | undefined, ): Promise { - if (cancelled(signal)) - return Promise.resolve( - err(new AnalysisCancelledError(operation, { cause: signal?.reason })), - ); + if (isAborted(signal)) + return Promise.resolve(err(cancelledBy(operation, signal))); const kept = this.#singleFunctionTexts.get(address); if (kept !== undefined) { this.#singleFunctionTexts.delete(address); @@ -1598,11 +1592,7 @@ export class KunaSessionClient implements AnalysisClient { if (!waiting) return; leave(); if (shared.waiters > 0) { - resolve( - err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ), - ); + resolve(err(cancelledBy(operation, carriedReason(signal?.reason)))); return; } // A run whose waiters all left winds down; a new request starts afresh. @@ -1654,15 +1644,10 @@ export class KunaSessionClient implements AnalysisClient { ? await this.#runSlots.acquire(signal) : await this.#admitSingleFunction(signal); if (admitted === EXPORT_READY) return admitted; - if (admitted === undefined) - return err( - new AnalysisCancelledError(operation, { cause: signal.reason }), - ); + if (admitted === undefined) return err(cancelledBy(operation, signal)); if (signal.aborted) { admitted(); - return err( - new AnalysisCancelledError(operation, { cause: signal.reason }), - ); + return err(cancelledBy(operation, signal)); } let deadline: KunaDeadline; try { @@ -1984,9 +1969,7 @@ export class KunaSessionClient implements AnalysisClient { } const waited = await attempt.wait(signal); if (waited.status === "cancelled") - return err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ); + return err(cancelledBy(operation, carriedReason(waited.reason))); if (!waited.value.ok) { // A fresh caller can arrive while the previous final waiter joins stop. // Retry only proven cancellation, never an authored physical failure. @@ -1999,10 +1982,7 @@ export class KunaSessionClient implements AnalysisClient { return this.#ensureInstallation(operation, signal); return err(kunaQueryFailureForCaller(waited.value.error, operation)); } - if (signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: signal.reason }), - ); + if (signal?.aborted) return err(cancelledBy(operation, signal)); return waited.value; } @@ -2255,20 +2235,12 @@ export class KunaSessionClient implements AnalysisClient { return this.#enqueueSpan(async () => { const cancelledBefore = callerAborted(); if (cancelledBefore !== undefined) - return err( - new AnalysisCancelledError(operation, { - cause: cancelledBefore.reason, - }), - ); + return err(cancelledBy(operation, cancelledBefore)); const pending = await this.#pendingDescriptorCleanup(operation); if (pending !== undefined) return err(pending); const cancelledAfter = callerAborted(); if (cancelledAfter !== undefined) - return err( - new AnalysisCancelledError(operation, { - cause: cancelledAfter.reason, - }), - ); + return err(cancelledBy(operation, cancelledAfter)); return ok(await open()); }); } @@ -2431,11 +2403,7 @@ export class KunaSessionClient implements AnalysisClient { const exported = await this.#exportInventory; if (exported !== undefined) return ok(exported); if (this.#session.signal.aborted) - return err( - new AnalysisCancelledError("list_procedures", { - cause: this.#session.signal.reason, - }), - ); + return err(cancelledBy("list_procedures", this.#session.signal)); let deadline: KunaDeadline; try { deadline = kunaFunctionDeadline(installation.configuration); diff --git a/src/kuna/KunaStringQueries.ts b/src/kuna/KunaStringQueries.ts index 116767087a..f1d2dec0a7 100644 --- a/src/kuna/KunaStringQueries.ts +++ b/src/kuna/KunaStringQueries.ts @@ -1,8 +1,9 @@ import type { RegexConfigurationSnapshot } from "../process/RegexMatcher.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCancelledError, AnalysisInputError, + cancelledBy, + isAborted, } from "../domain/analysisErrorCore.js"; import { analysisSearchPredicate, @@ -44,10 +45,7 @@ export class KunaStringQueries { AnalysisError > > { - if (signal?.aborted === true) - return err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ); + if (signal?.aborted === true) return err(cancelledBy(operation, signal)); const address = operation === "list_strings" && parameters.address !== undefined ? normalizeKunaAddress(parameters.address) @@ -67,10 +65,7 @@ export class KunaStringQueries { const inventory = await this.#load(installation, operation, signal); if (!inventory.ok) return err(kunaQueryFailureForCaller(inventory.error, operation)); - if (this.#cancelled(signal)) - return err( - new AnalysisCancelledError(operation, { cause: signal?.reason }), - ); + if (isAborted(signal)) return err(cancelledBy(operation, signal)); const available = inventory.value.items; let items: KunaStringItem[] | undefined; if (address !== undefined) @@ -162,10 +157,6 @@ export class KunaStringQueries { return this.work.wait(this.#pending, signal, operation); } - #cancelled(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true; - } - /** Called only after session-owned work has drained. */ clear(): void { this.#inventory = undefined; diff --git a/src/kuna/KunaTargetCommands.ts b/src/kuna/KunaTargetCommands.ts index c00b1ebe8e..f28b1beead 100644 --- a/src/kuna/KunaTargetCommands.ts +++ b/src/kuna/KunaTargetCommands.ts @@ -14,7 +14,9 @@ import type { Writable } from "node:stream"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisArtifactChangedError, - AnalysisCancelledError, + cancelledBy, + carriedReason, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { KunaDocumentError } from "./KunaDocumentError.js"; @@ -108,11 +110,11 @@ export class KunaTargetCommands { request.releaseRun?.(); return Promise.resolve( err( - new AnalysisCancelledError( + cancelledBy( request.operation, request.signal.aborted - ? { cause: request.signal.reason } - : undefined, + ? request.signal + : OwnerCancellation.ownerClosed, ), ), ); @@ -125,11 +127,11 @@ export class KunaTargetCommands { if (this.#closing || this.lifetime.signal.aborted) return Promise.resolve( err( - new AnalysisCancelledError( + cancelledBy( "kuna:workspace", this.lifetime.signal.aborted - ? { cause: this.lifetime.signal.reason } - : undefined, + ? this.lifetime.signal + : OwnerCancellation.ownerClosed, ), ), ); @@ -149,11 +151,11 @@ export class KunaTargetCommands { if (this.#closing || this.lifetime.signal.aborted) return Promise.resolve( err( - new AnalysisCancelledError( + cancelledBy( operation, this.lifetime.signal.aborted - ? { cause: this.lifetime.signal.reason } - : undefined, + ? this.lifetime.signal + : OwnerCancellation.ownerClosed, ), ), ); @@ -327,7 +329,7 @@ export class KunaTargetCommands { return err(failure); } if (this.#closing && cause instanceof ArtifactReaderFailure) - return err(new AnalysisCancelledError(operation, { cause })); + return err(cancelledBy(operation, carriedReason(cause))); throw cause; } } @@ -425,9 +427,7 @@ export class KunaTargetCommands { : undefined); const primary = cancellation !== undefined - ? new AnalysisCancelledError("kuna:workspace", { - cause: cancellation.reason, - }) + ? cancelledBy("kuna:workspace", carriedReason(cancellation.reason)) : completion === undefined ? new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { cause: primaryCause, @@ -482,9 +482,7 @@ export class KunaTargetCommands { this.lifetime.signal.aborted ? analysisErrorWithCancellation( failure, - new AnalysisCancelledError("kuna:workspace", { - cause: this.lifetime.signal.reason, - }), + cancelledBy("kuna:workspace", this.lifetime.signal), ) : failure, ); diff --git a/src/process/SharedStartupAttempt.test.ts b/src/process/SharedStartupAttempt.test.ts index 05bed1443c..c6deca0482 100644 --- a/src/process/SharedStartupAttempt.test.ts +++ b/src/process/SharedStartupAttempt.test.ts @@ -6,14 +6,19 @@ import { STARTUP_OWNER_CLOSED, } from "./SharedStartupAttempt.js"; -it("keeps shared startup running for an interested peer", async () => { +it("keeps shared startup running for an interested peer and returns the departing caller's reason", async () => { const completed = createDeferred(); const attempt = new SharedStartupAttempt(() => completed.promise); const controller = new AbortController(); const departing = attempt.wait(controller.signal); const staying = attempt.wait(); - controller.abort(); - await expect(departing).resolves.toEqual({ status: "cancelled" }); + const reason = { caller: "departing" }; + controller.abort(reason); + const departed = await departing; + expect(departed).toEqual({ status: "cancelled", reason }); + expect(departed.status === "cancelled" ? departed.reason : undefined).toBe( + reason, + ); expect(attempt.signal.aborted).toBe(false); completed.resolve("ready"); await expect(staying).resolves.toEqual({ @@ -28,9 +33,13 @@ it("retains the final cleanup result when all callers cancel together", async () const first = new AbortController(); const last = new AbortController(); const waits = [attempt.wait(first.signal), attempt.wait(last.signal)]; - first.abort(); + const firstReason = new Error("first caller left"); + first.abort(firstReason); last.abort(); - await expect(waits[0]).resolves.toEqual({ status: "cancelled" }); + await expect(waits[0]).resolves.toEqual({ + status: "cancelled", + reason: firstReason, + }); expect(attempt.signal.aborted).toBe(true); expect(attempt.settled).toBe(false); const failure = { cleanupIncomplete: true }; diff --git a/src/process/SharedStartupAttempt.ts b/src/process/SharedStartupAttempt.ts index 7dde69a262..5c683b8837 100644 --- a/src/process/SharedStartupAttempt.ts +++ b/src/process/SharedStartupAttempt.ts @@ -6,7 +6,7 @@ export const STARTUP_OWNER_CLOSED = Symbol("provider startup owner closed"); /** The final departing caller retains the producer's complete cleanup result. */ export type SharedStartupWait = | { readonly status: "completed"; readonly value: Value } - | { readonly status: "cancelled" } + | { readonly status: "cancelled"; readonly reason: unknown } | { readonly status: "stopped"; readonly value: Value }; type StartupSettlement = @@ -68,7 +68,8 @@ export class SharedStartupAttempt { try { const waited = await waitForAbortable(this.promise, signal); if (waited !== ABORTED) return { status: "completed", value: waited }; - if (this.#waiters > 1) return { status: "cancelled" }; + if (this.#waiters > 1) + return { status: "cancelled", reason: signal?.reason }; if (!this.settled) this.#controller.abort(signal?.reason); return { status: "stopped", value: await this.promise }; } finally { diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts index feeb42835f..f9febcb982 100644 --- a/src/sqlite/SqliteDatabaseProvider.ts +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -15,6 +15,8 @@ import { AnalysisCancelledError, AnalysisOutputError, AnalysisResourceConstraintError, + cancelledBy, + OwnerCancellation, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -93,7 +95,8 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { cause: options.signal.reason, }), ); - if (this.#closed) return err(new AnalysisCancelledError(operation)); + if (this.#closed) + return err(cancelledBy(operation, OwnerCancellation.ownerClosed)); if (this.#active.size >= SQLITE_DATABASE_LIMITS.concurrentInspections) return err( new AnalysisResourceConstraintError( @@ -278,7 +281,8 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { throw new AnalysisCancelledError(operation, { cause: options.signal.reason, }); - if (this.#closed) throw new AnalysisCancelledError(operation); + if (this.#closed) + throw cancelledBy(operation, OwnerCancellation.ownerClosed); root = await this.createRuntime(); phase = "artifact-read"; const snapshot = await captureSqliteDatabaseSnapshot( diff --git a/tests/boundary/filesystem/countedBaseline.test.ts b/tests/boundary/filesystem/countedBaseline.test.ts new file mode 100644 index 0000000000..855027cfd3 --- /dev/null +++ b/tests/boundary/filesystem/countedBaseline.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; + +import { compareCountedBaseline } from "../../../scripts/verify/counted-baseline.mjs"; + +it("rejects a file that gains copies or is missing from the baseline", () => { + const compared = compareCountedBaseline( + new Map([ + ["src/a.ts", 3], + ["src/new.ts", 1], + ]), + { "src/a.ts": 2 }, + false, + ); + expect(compared.grown).toEqual([ + { path: "src/a.ts", count: 3, allowed: 2 }, + { path: "src/new.ts", count: 1, allowed: 0 }, + ]); +}); + +it("reports removed copies as stale until the baseline is lowered", () => { + const baseline = { "src/a.ts": 2, "src/gone.ts": 1 }; + const compared = compareCountedBaseline( + new Map([["src/a.ts", 1]]), + baseline, + false, + ); + expect(compared.grown).toEqual([]); + expect(compared.stale).toEqual([ + { path: "src/a.ts", count: 1, allowed: 2 }, + { path: "src/gone.ts", count: 0, allowed: 1 }, + ]); + expect(compared.baseline).toBe(baseline); +}); + +it("only lowers counts when shrinking, never raising one that grew", () => { + const baseline = { "src/a.ts": 2, "src/b.ts": 1, "src/gone.ts": 4 }; + const compared = compareCountedBaseline( + new Map([ + ["src/a.ts", 1], + ["src/b.ts", 5], + ]), + baseline, + true, + ); + expect(compared.baseline).toEqual({ "src/a.ts": 1, "src/b.ts": 1 }); + expect(compared.grown).toEqual([{ path: "src/b.ts", count: 5, allowed: 1 }]); + expect(compared.stale).toEqual([]); + expect(baseline).toEqual({ "src/a.ts": 2, "src/b.ts": 1, "src/gone.ts": 4 }); +}); diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index 5b86a23121..22949911b3 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -2,6 +2,7 @@ import { expect, it as test, vi } from "vitest"; import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { OwnerCancellation } from "../../../../src/domain/analysisErrorCore.js"; import { ProcessOwnershipInspectionCancelled } from "../../../../src/process/ProcessInspectionCancellation.js"; import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; @@ -216,7 +217,7 @@ it("preserves the lifetime reason when workspace preparation is already cancelle } }); -it("does not invent a caller cause when closed without cancellation", async () => { +it("reports REA's owner close, not an invented caller cause, when closed without cancellation", async () => { const { kuna, target, cacheRoot } = await session("rea-kuna-command-closed-"); const installed = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna, @@ -233,7 +234,7 @@ it("does not invent a caller cause when closed without cancellation", async () = const preparation = await commands.prepare(); if (preparation.ok) throw new Error("Expected closed preparation to cancel"); expect(preparation.error._tag).toBe("AnalysisCancelledError"); - expect(preparation.error.cause).toBeUndefined(); + expect(preparation.error.cause).toBe(OwnerCancellation.ownerClosed); const result = await commands.run({ installation: installed.value, operation: "list_procedures", @@ -245,7 +246,7 @@ it("does not invent a caller cause when closed without cancellation", async () = }); if (result.ok) throw new Error("Expected closed command to cancel"); expect(result.error._tag).toBe("AnalysisCancelledError"); - expect(result.error.cause).toBeUndefined(); + expect(result.error.cause).toBe(OwnerCancellation.ownerClosed); expect(releaseRun).toHaveBeenCalledOnce(); expect(await fakeKunaCalls(kuna)).toEqual(callsBefore); expect(await commands.close()).toEqual({ ok: true, value: null });