refactor(providers): finalize apktool and shared artifact resources through runOwned (#10653)

* refactor(providers): finalize apktool and shared artifact resources through runOwned

runOwned moves to the provider layer so provider sites can use it without depending on application workflows. It gains a completed-value projection (partial) for values that are not themselves partial observations, an optional signal, and cleanupLeases that bind a close failure to the exact leases whose confirmed release resolves it.

ArtifactAnalysisResources.finish is removed; TShark and sigrok finalize through runOwned with the shared release as close. Apktool's hand-written release, cancellation and lease-group composition is replaced by runOwned. A failed primary now keeps a cancellation observed during final cleanup as a secondary failure, as the documented precedence requires.

A counted architecture guard refuses new hand-written completed-result abort reclassification outside runOwned.

* refactor(adb): finalize runtime-root work through runOwned (#10660)

* refactor(adb): finalize runtime-root work through runOwned

The ADB runtime-root finalizer re-derived the precedence by hand and kept its own cancellation-with-cleanup combiner. It now runs through runOwned: the work body maps snapshot failures, close releases the root lease and binds its own cleanup error, and partial projects the completed execution. A cancellation whose cleanup fails is now the shared cleanup composite with the cancellation as its primary, as runOwned produces for every other owner.

* refactor(providers): keep completed values as diagnostics in runOwned and migrate four finalizers (#10691)

* refactor(providers): keep completed values as diagnostics in runOwned and migrate signature, Ghidra, bundle and LLDB finalization

* test(providers): run the lifecycle scenarios over every runOwned adopter shape (#10707)

One table of testing.md lifecycle rows runs over partial, diagnostics, publishing and lease adopters, replacing the hand-written precedence cases it covers.

* chore(lint): move runOwned's classifier baseline entry with the file
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 17:44:13 +08:00
1 parent b87b318ae7
commit 26520bd290
23 files changed
+1066 -709

No files matched your search

+6
View File
@@ -786,6 +786,12 @@ controlled production seam for interleavings that cannot be reproduced reliably.
| Cleanup owners recover after an error was returned | Retain the original child and aggregate errors and project them after each owner releases. A successful peer stays released; the aggregate remains incomplete until all owners release. Preserve the first cause even when rejection is `undefined`. |
| Target exits without an acknowledgement, or transport disappears while it stays alive | Positive owned exit proof resolves target lifetime; other resources still need cleanup. Lost transport alone does not prove exit. Verify unrelated/attached targets survive and same-connection recovery works when supported. |
Owners that finalize through `runOwned` inherit the precedence rows above from
`src/providers/runOwned.lifecycle.test.ts`, which runs each scenario over a
table of adopter shapes (partial or diagnostics record, publication, lease).
Add a shape to that table instead of repeating those cases per provider; a
provider's own tests cover how its work and close reach `runOwned`.
Shared process-supervision controls separate leader exit from output closure.
Cover output during held or failed cleanup, bounded retention and drain timeout,
retry, and borrowed stream authority. The POSIX inherited-stderr control uses a
+1 -1
View File
@@ -482,7 +482,7 @@ failed request's physical, cancellation and cleanup facts.
confirmed release. CLI adapters should reuse
[`finishAnalysisCommand`](https://github.com/morluto/rea/blob/main/src/cli/finishAnalysisCommand.ts) where its contract
applies rather than duplicate close/finally handling. It projects
[`runOwned`](https://github.com/morluto/rea/blob/main/src/application/runOwned.ts),
[`runOwned`](https://github.com/morluto/rea/blob/main/src/providers/runOwned.ts),
which owns this precedence for owned work: a recorded failure stays primary,
cancellation before commit keeps the completed value as a partial
observation, cancellation after commit keeps the value, and unconfirmed
@@ -20,7 +20,6 @@
"src/application/managed/ManagedMemberComparisonService.ts": 1,
"src/application/ntfs/NtfsJournalService.ts": 1,
"src/application/process/ProcessCli.ts": 1,
"src/application/runOwned.ts": 1,
"src/artifacts/ArtifactOperationFailure.ts": 1,
"src/artifacts/ReleaseExposureReader.ts": 1,
"src/artifacts/StableArtifactFailure.ts": 2,
@@ -75,6 +74,7 @@
"src/process/privateWorkspaceFailure.ts": 1,
"src/providers/AnalysisClientFailure.ts": 1,
"src/providers/OwnedCommandCleanup.ts": 2,
"src/providers/runOwned.ts": 1,
"src/rust/RustBinaryProvider.ts": 1,
"src/signals/SigrokRecordingProvider.ts": 1,
"src/sqlite/SqliteDatabaseFailures.ts": 1,
@@ -11,7 +11,7 @@
"src/application/Doctor.ts": 4,
"src/application/EnhancedLiteralTracing.ts": 1,
"src/application/EnhancedTools.ts": 2,
"src/application/EvidenceBundleCommands.ts": 3,
"src/application/EvidenceBundleCommands.ts": 1,
"src/application/EvidenceBundleFiles.ts": 3,
"src/application/EvidenceInputResolver.ts": 2,
"src/application/JsonFiles.ts": 8,
@@ -121,7 +121,7 @@
"src/javascript/sourceMaps/SourceMapCommand.ts": 4,
"src/javascript/sourceMaps/SourceMapDecoder.ts": 3,
"src/native/AppleDispatchMetadata.ts": 3,
"src/native/LldbCallTracer.ts": 4,
"src/native/LldbCallTracer.ts": 2,
"src/native/NativeCallObservation.ts": 2,
"src/native/NativeCallTarget.ts": 2,
"src/native/NativeCommandAnalysisError.ts": 1,
@@ -129,7 +129,6 @@
"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,
@@ -381,7 +380,7 @@
"src/android/JadxJavaProbe.ts": 1,
"src/android/JadxProvider.ts": 2,
"src/apktool/ApktoolCommandCleanup.ts": 1,
"src/apktool/ApktoolProvider.ts": 3,
"src/apktool/ApktoolProvider.ts": 2,
"src/application/AnalysisContextQueries.ts": 1,
"src/application/DirectAnalysisCleanup.ts": 2,
"src/application/Doctor.ts": 1,
@@ -414,7 +413,6 @@
"src/application/native/NativeUiActionTrace.ts": 1,
"src/application/process/ProcessCli.ts": 1,
"src/application/releaseExposure/ReleaseExposureService.ts": 1,
"src/application/runOwned.ts": 1,
"src/artifacts/apple/AppleAssetCatalogAnalysis.ts": 2,
"src/artifacts/apple/KeyedArchiveReader.ts": 1,
"src/browser/BrowserScenarioPartialObservation.ts": 1,
@@ -471,7 +469,7 @@
"src/kuna/KunaProjectReader.ts": 2,
"src/kuna/KunaSessionClient.ts": 1,
"src/kuna/KunaTargetCommands.ts": 2,
"src/native/LldbCallTracer.ts": 6,
"src/native/LldbCallTracer.ts": 4,
"src/native/NativeCommandAnalysisError.ts": 1,
"src/native/NativeMacOSProvider.ts": 5,
"src/native/NativeUiObservation.ts": 1,
@@ -480,7 +478,7 @@
"src/native/pwntools/PwntoolsDecoder.ts": 6,
"src/pcap/PcapProvider.ts": 7,
"src/pcap/PcapSnapshot.ts": 1,
"src/process/ArtifactAnalysisResources.ts": 4,
"src/process/ArtifactAnalysisResources.ts": 3,
"src/process/DarwinProcessRunTokenReader.ts": 2,
"src/process/capture/FilesystemSnapshot.ts": 1,
"src/process/capture/ProcessCaptureError.ts": 3,
@@ -489,6 +487,7 @@
"src/providers/AnalysisClientCreation.ts": 3,
"src/providers/AnalysisClientFailure.ts": 1,
"src/providers/OwnedCommandCleanup.ts": 1,
"src/providers/runOwned.ts": 1,
"src/rizin/RizinDebugSessionManager.ts": 1,
"src/server/registerCloseLifecycleTool.ts": 1,
"src/server/registerResetInvestigationTool.ts": 1,
@@ -645,5 +644,24 @@
"src/native/MachoArchitectureSelection.ts": 2,
"src/native/NativeMachoInspection.ts": 1
},
"abort-without-reason": {}
"abort-without-reason": {},
"completed-abort-reclassification": {
"src/application/EvidenceInputResolver.ts": 1,
"src/application/binary/BinarySession.ts": 1,
"src/application/desktop/DesktopApplicationState.ts": 1,
"src/application/frida/FridaInstrumentationService.ts": 1,
"src/browser/CdpBrowserProvider.ts": 1,
"src/browser/CdpElectronProvider.ts": 1,
"src/browser/PlaywrightBrowserScenarioProvider.ts": 1,
"src/browser/execution/CdpWebRuntimeProvider.ts": 1,
"src/browser/history/HistoricalCaptureDecoder.ts": 1,
"src/cliJsonInput.ts": 1,
"src/evm/EvmoleInterfaceProvider.ts": 1,
"src/ghidra/GhidraClient.ts": 1,
"src/hopper/HopperClient.ts": 2,
"src/hopper/HopperMachOImage.ts": 1,
"src/native/pwntools/PwntoolsDecoder.ts": 1,
"src/reference/ReferenceSourceReaderEntries.ts": 1,
"src/sqlite/SqliteDatabaseProvider.ts": 1
}
}
+10
View File
@@ -330,6 +330,16 @@ checkCounted(
["src/pcap/TsharkJsonPackets.ts"],
);
// Turning a completed result into a cancellation after cleanup is the
// finalization precedence that runOwned owns (src/providers/runOwned.ts).
checkCounted(
"completed result reclassified by a hand-read abort signal (finalize through runOwned in src/providers/runOwned)",
"completed-abort-reclassification",
files,
/\.ok\s*&&\s*(?:isAborted\(|[\w.?]*signal\??\.aborted)/gu,
["src/providers/runOwned.ts"],
);
// An abort reason names who ended the work: a typed `CallerAbort` at the CLI,
// MCP and HTTP adapters, or an `OwnerCancellation` when REA closes an owner.
// A bare `.abort()` leaves an anonymous platform AbortError as the cause.
+72 -143
View File
@@ -74,6 +74,7 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import { readPngHeaderDimensions } from "../domain/pngHeader.js";
import { err, ok, type Result } from "../domain/result.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import {
@@ -616,35 +617,6 @@ const publishScreenExclusively = async (
throw writeFailure.cause;
};
const cancellationWithCleanupFailure = (
primary: AnalysisCancelledError,
cleanup: AnalysisError,
resources: readonly string[],
): AnalysisCancelledError =>
cancelledBy(
primary.operation,
carriedReason(
new AggregateError(
[primary, cleanup],
"ADB cancellation cleanup failed",
{ cause: primary },
),
),
{
diagnostics: primary.diagnostics,
...(primary.capturedOutput === undefined
? {}
: { capturedOutput: primary.capturedOutput }),
cleanup: {
reason: [primary.cleanup?.reason, cleanup.message]
.filter((reason): reason is string => reason !== undefined)
.join("; "),
resources: [...new Set([...primary.cleanupResources, ...resources])],
},
partialObservation: primary.partialObservation,
},
);
/** Publish an ADB-produced archive without replacing an existing caller file. */
const publishBugreport = async (
source: string,
@@ -1059,124 +1031,81 @@ export class AdbProvider {
);
}
let result: Result<AnalysisExecution, AnalysisError> | undefined;
let primary: AnalysisError | undefined;
const children = new ArtifactResourceScope();
try {
result = await work(root, children);
} catch (cause: unknown) {
if (cause instanceof AdbInputSnapshotFailure) {
primary =
cause.primaryCause instanceof AnalysisError
? cause.primaryCause
: new AnalysisOutputError(
operation,
`The caller input could not be staged for adb: ${cause.sourcePath}`,
{ cause: cause.primaryCause ?? cause },
);
// Snapshot handle cleanup may recover when the runtime-root lease is
// drained below. Keep that historical failure linked to the typed
// primary error while the lease owns and clears live cleanup state.
primary.retainSecondaryFailure(
new AnalysisOutputError(
operation,
`ADB input snapshot cleanup previously failed for ${cause.sourcePath}`,
{ cause },
),
{},
);
} else {
primary =
cause instanceof AnalysisError
? cause
: new AnalysisProtocolError(`ADB ${operation} failed`, { cause });
}
}
const rootLease = runtimeRootLease(root, children);
const released = await this.#resources.release({
kind: "lease",
resource: root.path,
lease: rootLease,
});
if (signal?.aborted === true) {
const failure =
primary ??
(result !== undefined && !result.ok ? result.error : undefined);
if (failure !== undefined)
primary = analysisErrorWithCancellation(
failure,
cancelledBy(operation, signal),
);
}
if (primary === undefined && result?.ok && signal?.aborted === true) {
const completed = result.value;
primary = cancelledBy(operation, signal, {
partialObservation: createEvidence(
completed.subject ?? undefined,
completed.provider,
{
operation,
parameters,
result: completed.result,
rawResult: completed.rawResult,
limitations: completed.limitations,
locations: completed.locations,
confidence: "observed",
return finalizedResult(
await runOwned(
async () => {
try {
return await work(root, children);
} catch (cause: unknown) {
if (!(cause instanceof AdbInputSnapshotFailure))
return err(
cause instanceof AnalysisError
? cause
: new AnalysisProtocolError(`ADB ${operation} failed`, {
cause,
}),
);
const primary =
cause.primaryCause instanceof AnalysisError
? cause.primaryCause
: new AnalysisOutputError(
operation,
`The caller input could not be staged for adb: ${cause.sourcePath}`,
{ cause: cause.primaryCause ?? cause },
);
// Snapshot handle cleanup may recover when the runtime-root lease
// is drained during close. Keep that historical failure linked to
// the typed primary error while the lease owns live cleanup state.
return err(
primary.retainSecondaryFailure(
new AnalysisOutputError(
operation,
`ADB input snapshot cleanup previously failed for ${cause.sourcePath}`,
{ cause },
),
{},
),
);
}
},
{
providerId: ADB_PROVIDER_IDENTITY.id,
operation,
signal,
close: async () => {
const released = await this.#resources.release({
kind: "lease",
resource: root.path,
lease: rootLease,
});
if (released.kind === "released") return;
const cleanup = this.#cleanupError(
operation,
parameters,
[released.cause],
[
root.path,
...(released.cause instanceof ArtifactReaderFailure &&
released.cause.cleanup !== undefined
? released.cause.cleanup.resources
: []),
],
);
rootLease.bindCleanupError(cleanup);
throw cleanup;
},
),
});
}
if (released.kind === "failed") {
const cleanupResources = [
root.path,
...(released.cause instanceof ArtifactReaderFailure &&
released.cause.cleanup !== undefined
? released.cause.cleanup.resources
: []),
];
const cleanup = this.#cleanupError(
operation,
parameters,
[released.cause],
cleanupResources,
result?.ok ? result.value : undefined,
);
const returnCleanupError = (error: AnalysisError) => {
rootLease.bindCleanupError(error);
return err(error);
};
if (primary instanceof AnalysisCancelledError) {
return returnCleanupError(
cancellationWithCleanupFailure(primary, cleanup, cleanupResources),
);
}
if (primary !== undefined)
return returnCleanupError(
analysisErrorWithCleanupFailure(primary, cleanup, operation),
);
if (result !== undefined && !result.ok) {
if (result.error instanceof AnalysisCancelledError) {
return returnCleanupError(
cancellationWithCleanupFailure(
result.error,
cleanup,
cleanupResources,
partial: (completed) =>
createAnalysisExecutionEvidence(
operation,
parameters,
completed,
"observed",
),
);
}
return returnCleanupError(
analysisErrorWithCleanupFailure(result.error, cleanup, operation),
);
}
return returnCleanupError(cleanup);
}
if (primary !== undefined) return err(primary);
if (result === undefined)
return err(
new AnalysisProtocolError(`ADB ${operation} returned no result`),
);
return result;
},
),
);
}
#retainOutputDirectoryCleanup(
+24 -36
View File
@@ -58,6 +58,7 @@ import {
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { err, ok, type Result } from "../domain/result.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
@@ -779,44 +780,31 @@ export class ApktoolProvider {
});
result = err(retained);
}
const released = await this.#artifacts.release(owner);
const observation =
result.ok && (released.kind === "failed" || signal?.aborted === true)
? createAnalysisExecutionEvidence(
const completed = result;
return finalizedResult(
await runOwned(async () => completed, {
providerId: "apktool",
operation,
signal,
close: async () => {
const released = await this.#artifacts.release(owner);
if (released.kind === "failed")
throw new ProviderCleanupError(
"apktool",
[root],
{},
{ operation, cause: released.cause },
);
},
cleanupLeases: [lease],
partial: (execution) =>
createAnalysisExecutionEvidence(
operation,
input,
result.value,
execution,
"observed",
)
: undefined;
if (result.ok && signal?.aborted)
result = err(
cancelledBy(operation, signal, { partialObservation: observation }),
);
if (released.kind === "failed") {
const cleanup = new ProviderCleanupError(
"apktool",
[root],
{},
{
operation,
cause: released.cause,
...(observation === undefined
? {}
: { partialObservation: observation }),
},
);
new AnalysisCleanupLeaseGroup(cleanup, [lease]);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
return result;
),
}),
);
}
}
+68 -31
View File
@@ -39,6 +39,11 @@ import {
import { inspectEvidenceBundle } from "./investigation/InspectEvidenceBundle.js";
import type { EvidenceRecordFilters } from "../domain/evidenceRecordSummary.js";
import { inspectAnalysisViewValidated } from "./analysisView/AnalysisViewService.js";
import {
finalizedResult,
runOwned,
type RunOwnedOptions,
} from "../providers/runOwned.js";
/** Validate a standalone bundle and report its complete record/revision counts. */
export const importEvidenceBundleCommand = async (
@@ -85,21 +90,25 @@ export const exportEvidenceBundleCommand = async (
signal,
);
if (!loaded.ok) return loaded;
const written = await persistEvidenceBundleCommand(
loaded.value,
outputPath,
overwrite,
signal,
"export_evidence_bundle",
const written = await finalizeBundleWork(
() =>
persistEvidenceBundleCommand(
loaded.value,
outputPath,
overwrite,
signal,
"export_evidence_bundle",
),
{
providerId: "rea",
operation: "export_evidence_bundle",
signal,
// Deliver interrupts queued during publication cleanup before deciding
// whether a written bundle survives cancellation.
close: dispatchQueuedInterrupts,
diagnostics: (receipt) => ({ evidence_bundle: { ...receipt } }),
},
);
await dispatchQueuedInterrupts();
if (written.ok && signal?.aborted)
return err(
new AnalysisCancelledError("export_evidence_bundle", {
cause: signal.reason,
diagnostics: { evidence_bundle: { ...written.value } },
}),
);
return projectWrite(loaded.value, written);
};
@@ -346,21 +355,49 @@ const withBundleReadResources = async <Value extends JsonValue>(
signal?: AbortSignal,
): Promise<Result<Value, AnalysisError>> => {
const resources = new ArtifactResourceScope();
let result: Result<Value, AnalysisError>;
try {
result = await read(resources);
} finally {
// Read errors own their retained leases and observe resolution or remaining
// failure in place. A retry must not replace the original read failure.
await resources.close().catch(() => undefined);
await dispatchQueuedInterrupts();
}
return result.ok && signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
diagnostics: { completed_result: result.value },
}),
)
: result;
let readFailed = false;
return finalizeBundleWork(() => read(resources), {
providerId: "rea",
operation,
signal,
inspectPrimary: (primary) => {
readFailed = !primary.ok;
},
close: async () => {
try {
await resources.close();
} catch (cause: unknown) {
// A failed read already owns its retained leases and observes their
// resolution in place; a retry must not replace that failure. Only
// a completed read leaves a close failure without another owner.
if (!readFailed) throw cause;
} finally {
await dispatchQueuedInterrupts();
}
},
fallbackResource: `${operation} bundle read resources`,
diagnostics: (value) => ({ completed_result: value }),
});
};
/**
* Finalize bundle work through runOwned. An unexpected throw is a defect the
* adapters report as such, so it is rethrown after cleanup instead of being
* typed as a provider failure.
*/
const finalizeBundleWork = async <Value>(
work: () => Promise<Result<Value, AnalysisError>>,
options: RunOwnedOptions<Value>,
): Promise<Result<Value, AnalysisError>> => {
let defect: { readonly cause: unknown } | undefined;
const finalized = await runOwned(async () => {
try {
return await work();
} catch (cause: unknown) {
defect = { cause };
throw cause;
}
}, options);
if (defect !== undefined) throw defect.cause;
return finalizedResult(finalized);
};
+16 -11
View File
@@ -64,6 +64,7 @@ import {
packetCaptureSchema,
} from "../domain/captures/packetCapture.js";
import { err, ok, type Result } from "../domain/result.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import {
runOwnedCommand,
OwnedCommandFailure,
@@ -477,17 +478,21 @@ export class TsharkCaptureProvider implements PacketCapturePort {
),
);
}
return this.#resources.finish(
result,
(execution) =>
createAnalysisExecutionEvidence(
operation,
input,
execution,
"observed",
),
operation,
options?.signal,
const completed = result;
return finalizedResult(
await runOwned(async () => completed, {
providerId: this.#resources.providerId,
operation,
signal: options?.signal,
close: () => this.#resources.release(),
partial: (execution) =>
createAnalysisExecutionEvidence(
operation,
input,
execution,
"observed",
),
}),
);
}
#failure(
+2 -1
View File
@@ -2,7 +2,7 @@ import {
finalizedResult,
runOwned,
type RecoveredCompletion,
} from "../application/runOwned.js";
} from "../providers/runOwned.js";
import type {
AnalysisError,
AnalysisPartialObservation,
@@ -53,6 +53,7 @@ export const finishAnalysisCommand = async <
operation: canonicalOperation,
signal,
...(fallbackResource === undefined ? {} : { fallbackResource }),
partial: (value) => value,
close: async () => {
try {
await service.close();
+16 -11
View File
@@ -18,6 +18,7 @@ import { resolveGhidraProject } from "./GhidraProjectSnapshot.js";
import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js";
import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -383,27 +384,31 @@ export class GhidraProvider implements AnalysisProviderCandidate {
): Promise<Result<AnalysisProfileResolution, AnalysisError>> {
try {
return await this.#runInspectionWork(async (signal) => {
const result = await this.#resolveProfile(target, {
const resolved = await this.#resolveProfile(target, {
...options,
signal,
});
if (!result.ok && result.error instanceof AnalysisCancelledError)
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
return err(
analysisClientFailureForOperation(
result.error,
resolved.error,
"ghidra",
"open_binary",
),
);
if (result.ok && signal.aborted)
return err(
cancelledBy("open_binary", signal, {
diagnostics: {
completed_profile_digest: result.value.profile?.digest ?? null,
},
// Resolution owns no resource of its own; runOwned still decides
// whether a completed profile survives a cancellation.
return finalizedResult(
await runOwned(async () => resolved, {
providerId: "ghidra",
operation: "open_binary",
signal,
close: async () => undefined,
diagnostics: (resolution) => ({
completed_profile_digest: resolution.profile?.digest ?? null,
}),
);
return result;
}),
);
}, options?.signal);
} catch (cause: unknown) {
return err(
+94 -116
View File
@@ -63,6 +63,7 @@ import { LldbReadbackFiles } from "./LldbReadbackFiles.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { resolvedAnalysisError } from "../domain/analysisErrorBase.js";
import { runOwned } from "../providers/runOwned.js";
import {
readLldbObservationJournal,
readBoundedPrefix,
@@ -270,128 +271,105 @@ export class LldbCallTracer implements NativeCallTracer {
processStopped: true,
targetStopped: true,
};
let result: Result<NativeCallTrace, AnalysisError>;
try {
result = await this.#run(lldb.value, owner, request, signal);
} catch (cause: unknown) {
const partial = await partialNativeObservation({
request,
files: owner.files,
pidPath: paths.pid,
stdoutPath: paths.stdoutCapture,
stderrPath: paths.stderrCapture,
observationPath: paths.observations,
version: null,
reason: "tracer-failure",
});
if (
!owner.processStopped ||
!owner.targetStopped ||
owner.files.hasPending
)
this.#pendingCleanup = owner;
const failure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation: partial.observation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
});
result = err(retainPartialReadFailures(failure, partial.readFailures));
}
if (result.ok && signal?.aborted)
result = err(
new AnalysisCancelledError(OPERATION, {
cause: signal.reason,
partialObservation: partialObservationFromTrace(
let primaryFailure: AnalysisError | undefined;
const finalized = await runOwned(
async (): Promise<Result<NativeCallTrace, AnalysisError>> => {
try {
return await this.#run(lldb.value, owner, request, signal);
} catch (cause: unknown) {
const partial = await partialNativeObservation({
request,
result.value,
"cancelled",
),
}),
);
if (owner.files.hasPending) this.#pendingCleanup = owner;
if (this.#pendingCleanup === owner) {
if (!result.ok && result.error.cleanupIncomplete) {
if (owner.files.hasPending) {
const files = lldbCleanupFailure(
owner,
"LLDB readback descriptor cleanup remains incomplete",
owner.files.cleanupCause,
);
result.error.retainSecondaryFailure(files, {
readback_cleanup: projectAnalysisError(files),
files: owner.files,
pidPath: paths.pid,
stdoutPath: paths.stdoutCapture,
stderrPath: paths.stderrCapture,
observationPath: paths.observations,
version: null,
reason: "tracer-failure",
});
if (
!owner.processStopped ||
!owner.targetStopped ||
owner.files.hasPending
)
this.#pendingCleanup = owner;
const failure = new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diagnostics: { reason: errorReason(cause) },
partialObservation: partial.observation,
...(this.#pendingCleanup === owner
? {
cleanup: {
reason: "LLDB trace failed while owned resources remain",
resources: lldbCleanupResources(owner),
},
}
: {}),
});
return err(retainPartialReadFailures(failure, partial.readFailures));
}
return err(bindLldbCleanup(result.error, owner));
}
const cleanup = lldbCleanupFailure(
owner,
"LLDB readback descriptor cleanup remains incomplete",
owner.files.cleanupCause,
result.ok
? partialObservationFromTrace(request, result.value)
: undefined,
);
return result.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
OPERATION,
).bindCleanupDependencies([result.error, cleanup]),
);
}
let rootCleanup: Result<void, unknown>;
try {
await releaseLldbRuntime(owner);
rootCleanup = ok(undefined);
} catch (cause: unknown) {
rootCleanup = err(cause);
this.#pendingCleanup = owner;
}
if (result.ok && signal?.aborted)
result = err(
new AnalysisCancelledError(OPERATION, {
cause: signal.reason,
partialObservation: partialObservationFromTrace(
},
{
providerId: PROVIDER,
operation: OPERATION,
signal,
inspectPrimary: (primary) => {
if (!primary.ok) primaryFailure = primary.error;
},
close: async () => {
if (owner.files.hasPending) this.#pendingCleanup = owner;
if (this.#pendingCleanup === owner) {
// A primary that already reports this cleanup keeps owning it;
// its unreleased readback files are a secondary failure.
if (primaryFailure?.cleanupIncomplete === true) {
if (owner.files.hasPending) {
const files = lldbCleanupFailure(
owner,
"LLDB readback descriptor cleanup remains incomplete",
owner.files.cleanupCause,
);
primaryFailure.retainSecondaryFailure(files, {
readback_cleanup: projectAnalysisError(files),
});
}
bindLldbCleanup(primaryFailure, owner);
return;
}
// The runtime root stays retained while its process or files do.
throw lldbCleanupFailure(
owner,
"LLDB readback descriptor cleanup remains incomplete",
owner.files.cleanupCause,
);
}
try {
await releaseLldbRuntime(owner);
} catch (cause: unknown) {
this.#pendingCleanup = owner;
if (primaryFailure?.cleanupIncomplete === true)
bindLldbCleanup(primaryFailure, owner);
throw lldbCleanupFailure(owner, errorReason(cause), cause);
}
},
// A cancellation keeps the trace as cancelled; a retained cleanup
// keeps it as the completed trace it was.
partial: (trace, replacedBy) =>
partialObservationFromTrace(
request,
result.value,
"cancelled",
trace,
replacedBy === "cancellation" ? "cancelled" : "cleanup-failure",
),
}),
},
);
if (finalized.kind === "completed") return ok(finalized.value);
if (
finalized.kind !== "completed-cleanup-retained" &&
finalized.cleanup.kind === "released"
)
return err(
resolvedAnalysisError(bindLldbCleanup(finalized.error, owner)),
);
if (!rootCleanup.ok) {
const cleanup = lldbCleanupFailure(
owner,
errorReason(rootCleanup.error),
rootCleanup.error,
result.ok
? partialObservationFromTrace(request, result.value)
: undefined,
);
if (!result.ok && result.error.cleanupIncomplete)
bindLldbCleanup(result.error, owner);
return result.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
OPERATION,
).bindCleanupDependencies([result.error, cleanup]),
);
}
return result.ok
? result
: err(resolvedAnalysisError(bindLldbCleanup(result.error, owner)));
return err(finalized.error);
}
/** Retry retained LLDB cleanup without abandoning its process or runtime owner. */
@@ -698,6 +698,13 @@ for (const ownerIndex of [0, 1])
expect(resolvedAnalysisError(result.error)).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
// The completed binding is not an observation; it stays as diagnostics.
diagnostics: {
snapshot_binding: {
identity: expect.any(String),
snapshot_path: expect.stringContaining("target.bin"),
},
},
});
});
+101 -112
View File
@@ -13,8 +13,10 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisOutputError,
cancelledBy,
carriedReason,
isAborted,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
@@ -31,6 +33,7 @@ import {
AnalysisCleanupLeaseGroup,
} from "../artifacts/AnalysisCleanupLease.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import type { ArtifactResourceOwner } from "../artifacts/ArtifactResourceScope.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacosProviderIdentity.js";
@@ -62,9 +65,7 @@ const signatureReadFailure = (
cause: unknown,
): AnalysisError => {
if (cause instanceof RegularFileOperationCancelled)
return new AnalysisCancelledError("inspect_signature", {
cause: cause.reason,
});
return cancelledBy("inspect_signature", carriedReason(cause.reason));
if (cause instanceof AnalysisError) return cause;
const code =
cause instanceof Error && "code" in cause ? cause.code : undefined;
@@ -120,12 +121,10 @@ export const withSignatureTarget = async (
? signatureReadFailure(target, cause)
: snapshotFailure(target, "create", cause);
return err(
signal?.aborted
isAborted(signal)
? analysisErrorWithCancellation(
cause === signal.reason ? undefined : failure,
new AnalysisCancelledError("inspect_signature", {
cause: signal.reason,
}),
cancelledBy("inspect_signature", signal),
)
: failure,
);
@@ -146,63 +145,62 @@ export const withSignatureTarget = async (
),
resource: root.path,
};
let result: Result<InspectSignature, AnalysisError>;
try {
const binding = await bindSignatureTarget(
target,
join(root.path, basename(target.path)),
children,
signal,
);
if (!binding.ok) result = binding;
else {
let completed: InspectSignature | undefined;
const finalized = await runOwned(
async (): Promise<Result<InspectSignature, AnalysisError>> => {
try {
result = await inspect(binding.value);
} catch (cause: unknown) {
result = err(
new AnalysisOutputError(
"inspect_signature",
"Native output parsing failed",
{ cause },
),
const binding = await bindSignatureTarget(
target,
join(root.path, basename(target.path)),
children,
signal,
);
if (!binding.ok) return binding;
try {
return await inspect(binding.value);
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"inspect_signature",
"Native output parsing failed",
{ cause },
),
);
}
} catch (cause: unknown) {
return err(signatureReadFailure(target, cause));
}
}
} catch (cause: unknown) {
result = err(signatureReadFailure(target, cause));
}
const cleanup = await resources.release(owner);
if (signal?.aborted)
result = err(
analysisErrorWithCancellation(
result.ok ? undefined : result.error,
new AnalysisCancelledError("inspect_signature", {
cause: signal.reason,
...(result.ok ? { partialObservation: result.value } : {}),
}),
),
);
if (cleanup.kind === "failed") {
const failure = signatureCleanupFailure(
target,
[root.path],
cleanup.cause,
"cleanup",
result.ok ? result.value : undefined,
);
new AnalysisCleanupLeaseGroup(failure, [owner.lease]);
return combineCleanupFailure(result, failure);
}
return result;
},
{
providerId: NATIVE_MACOS_PROVIDER_IDENTITY.id,
operation: "inspect_signature",
signal,
inspectPrimary: (primary) => {
if (primary.ok) completed = primary.value;
},
close: async () => {
const cleanup = await resources.release(owner);
if (cleanup.kind === "failed")
throw signatureCleanupFailure(
target,
[root.path],
cleanup.cause,
"cleanup",
completed,
);
},
cleanupLeases: [owner.lease],
partial: (inspected) => inspected,
},
);
return finalizedResult(finalized);
});
} catch (cause: unknown) {
const primary = signatureReadFailure(target, cause);
const outcome = signal?.aborted
const outcome = isAborted(signal)
? analysisErrorWithCancellation(
cause === signal.reason ? undefined : primary,
new AnalysisCancelledError("inspect_signature", {
cause: signal.reason,
}),
cancelledBy("inspect_signature", signal),
)
: primary;
const retained =
@@ -217,27 +215,18 @@ export const withSignatureTarget = async (
"retry",
);
bindArtifactFileCleanup(cause, failure);
return combineCleanupFailure(err(outcome), failure);
return err(
analysisErrorWithCleanupFailure(
outcome,
failure,
"inspect_signature",
).bindCleanupDependencies([outcome, failure]),
);
}
return err(outcome);
}
};
/** Each composed error follows the exact primary and secondary cleanup owners. */
const combineCleanupFailure = <T>(
primary: Result<T, AnalysisError>,
cleanup: AnalysisError,
): Result<T, AnalysisError> =>
primary.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
primary.error,
cleanup,
"inspect_signature",
).bindCleanupDependencies([primary.error, cleanup]),
);
const signatureCleanupFailure = (
target: BinaryTarget,
resources: readonly string[],
@@ -434,47 +423,47 @@ const bindSignatureTarget = async (
return err(signatureReadFailure(target, cause));
}
};
let result = await perform();
const cleanupFailures: {
readonly owner: SignatureOwner;
readonly cause: unknown;
}[] = [];
for (const owner of [snapshotOwner, sourceOwner]) {
if (owner === undefined) continue;
const cleanup = await resources.release(owner);
if (cleanup.kind === "failed")
cleanupFailures.push({ owner, cause: cleanup.cause });
}
if (result.ok && signal?.aborted)
result = err(
new AnalysisCancelledError("inspect_signature", {
cause: signal.reason,
diagnostics: {
snapshot_binding: {
identity: result.value.identity,
snapshot_path: result.value.snapshotPath,
},
return finalizedResult(
await runOwned(perform, {
providerId: NATIVE_MACOS_PROVIDER_IDENTITY.id,
operation: "inspect_signature",
signal,
close: async () => {
const failed: SignatureOwner[] = [];
const causes: unknown[] = [];
for (const owner of [snapshotOwner, sourceOwner]) {
if (owner === undefined) continue;
const cleanup = await resources.release(owner);
if (cleanup.kind === "failed") {
failed.push(owner);
causes.push(cleanup.cause);
}
}
if (failed.length === 0) return;
const cleanup = signatureCleanupFailure(
target,
failed.map((owner) => owner.resource),
new AggregateError(
causes,
"Signature snapshot file handles could not be closed",
),
"close",
);
// Only the leases that failed hold resources; they alone resolve it.
new AnalysisCleanupLeaseGroup(
cleanup,
failed.map((owner) => owner.lease),
);
throw cleanup;
},
diagnostics: (binding) => ({
snapshot_binding: {
identity: binding.identity,
snapshot_path: binding.snapshotPath,
},
}),
);
if (cleanupFailures.length > 0) {
const cause = new AggregateError(
cleanupFailures.map(({ cause: failure }) => failure),
"Signature snapshot file handles could not be closed",
);
const cleanup = signatureCleanupFailure(
target,
cleanupFailures.map(({ owner }) => owner.resource),
cause,
"close",
);
new AnalysisCleanupLeaseGroup(
cleanup,
cleanupFailures.map(({ owner }) => owner.lease),
);
result = combineCleanupFailure(result, cleanup);
}
return result;
}),
);
};
/** Reject target drift across signature command captures. */
+41 -7
View File
@@ -27,6 +27,30 @@ import { err, ok } from "../domain/result.js";
import { ArtifactAnalysisResources } from "./ArtifactAnalysisResources.js";
import { readLinuxResourceLimits } from "./LinuxResourceLimits.js";
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
import type {
AnalysisError,
AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import type { Result } from "../domain/result.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
/** Providers finalize these resources through runOwned with their release as the close. */
const finish = async <T>(
resources: ArtifactAnalysisResources,
result: Result<T, AnalysisError>,
partial: (value: T) => AnalysisPartialObservation,
operation = resources.operation,
signal?: AbortSignal,
): Promise<Result<T, AnalysisError>> =>
finalizedResult(
await runOwned(async () => result, {
providerId: resources.providerId,
operation,
signal,
close: () => resources.release(),
partial,
}),
);
it.runIf(process.platform !== "win32")(
"offers a resolved-file retry from the shared selected-artifact boundary",
@@ -155,7 +179,8 @@ it("projects the specific reason and member evidence for retained cleanup", asyn
);
resources.retain({ path: "runtime-root", close: async () => {} }, owner);
const result = await resources.finish(
const result = await finish(
resources,
err(new ProviderAdapterError("fixture-provider", "fixture-operation")),
() => {
throw new Error("unexpected success observation");
@@ -237,7 +262,7 @@ it("preserves the primary error and resolves cleanup status after retry", async
},
);
const result = await resources.finish(err(primary), () => {
const result = await finish(resources, err(primary), () => {
throw new Error("unexpected success observation");
});
@@ -277,7 +302,8 @@ it("resolves each returned cleanup error with its own retained root", async () =
if (closesA === 1) throw new Error("root A close failure");
},
});
const first = await resources.finish(
const first = await finish(
resources,
err(new AnalysisOutputError("fixture-operation", "primary failure")),
() => {
throw new Error("unexpected success observation");
@@ -302,7 +328,7 @@ it("resolves each returned cleanup error with its own retained root", async () =
confidence: "observed",
},
);
const second = await resources.finish(ok("completed"), () => partial);
const second = await finish(resources, ok("completed"), () => partial);
if (second.ok) throw new Error("expected root B cleanup failure");
expect(second.error.partialObservation).toEqual(partial);
@@ -428,7 +454,7 @@ it("keeps a runtime root until its descriptor cleanup owner is released", async
},
});
const outer = await resources.finish(first, () => {
const outer = await finish(resources, first, () => {
throw new Error("unexpected success observation");
});
@@ -807,7 +833,7 @@ it("preserves typed caller cancellation through failed workspace cleanup and ret
},
});
try {
const result = await resources.finish(err(primary), () => {
const result = await finish(resources, err(primary), () => {
throw new Error("Must not produce success Evidence");
});
if (result.ok) throw new Error("Expected retained workspace");
@@ -931,7 +957,8 @@ it.each([false, true])(
},
});
try {
const result = await resources.finish(
const result = await finish(
resources,
err(primary),
() => {
throw new Error("Failed work must not produce success Evidence");
@@ -943,6 +970,13 @@ it.each([false, true])(
expect(failsClose ? result.error.cleanupPrimary : result.error).toBe(
primary,
);
// The recorded failure stays primary and keeps the late cancellation.
expect(primary.secondaryFailures).toEqual([
expect.objectContaining({
_tag: "AnalysisCancelledError",
cause: { request: "late cancellation" },
}),
]);
denied = false;
await resources.close();
expect(result.error.cleanupIncomplete).toBe(false);
-54
View File
@@ -14,7 +14,6 @@ import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.
import {
AnalysisError,
type AnalysisCapturedOutput,
type AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
@@ -351,12 +350,6 @@ export class ArtifactAnalysisResources {
: ownedCommandResources(ownership, root.path);
}
get #pendingResources(): readonly string[] {
return [...this.#pending.keys()].flatMap((root) =>
this.#resourcesFor(root),
);
}
async release(): Promise<void> {
const failures: ProviderCleanupError[] = [];
for (const [root, retained] of this.#pending) {
@@ -567,53 +560,6 @@ export class ArtifactAnalysisResources {
});
}
async finish<T>(
result: Result<T, AnalysisError>,
observation: (value: T) => AnalysisPartialObservation,
operation = this.operation,
signal?: AbortSignal,
): Promise<Result<T, AnalysisError>> {
try {
await this.release();
if (result.ok && signal?.aborted)
return err(
cancelledBy(operation, signal, {
partialObservation: observation(result.value),
}),
);
return result;
} catch (cause: unknown) {
const partialObservation = result.ok
? observation(result.value)
: result.error.partialObservation;
const primary = !result.ok
? result.error
: signal?.aborted
? cancelledBy(operation, signal, { partialObservation })
: undefined;
const cleanup =
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
this.providerId,
this.#pendingResources,
{ reason: String(cause) },
{ operation, cause },
);
const failure =
primary === undefined
? cleanup
: analysisErrorWithCleanupFailure(
primary,
cleanup,
operation,
).bindCleanupDependencies([primary, cleanup]);
if (partialObservation !== undefined)
failure.retainPartialObservation(partialObservation);
return err(failure);
}
}
close(): Promise<void> {
this.#closed = true;
const work = this.#tail.then(() => this.#closeResources());
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
primaryAnalysisError,
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisProtocolError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
finalizedResult,
runOwned,
type CompletedValueRecord,
type CompletedValueReplacement,
type Finalized,
} from "./runOwned.js";
/**
* The lifecycle scenarios of docs/development/testing.md#lifecycle-regressions
* that `runOwned` owns, run over every adopter shape below. A provider that
* finalizes through `runOwned` inherits these semantics; its own tests cover
* only how its work and close reach this primitive.
*/
interface Completed {
readonly count: number;
}
const completed: Completed = { count: 3 };
const reason = { caller: "stop" };
/** How an adopter declares the value an error must keep, and how it publishes. */
interface AdopterFixture {
readonly name: string;
readonly record: CompletedValueRecord<Completed>;
/** Asserts that an error replacing the completed value kept it. */
readonly kept: (
error: AnalysisError,
replacedBy: CompletedValueReplacement,
) => void;
readonly publishes: boolean;
/** Close releases an exact lease instead of throwing a typed producer error. */
readonly lease: boolean;
}
const partialRecord: CompletedValueRecord<Completed> = {
partial: (value, replacedBy) => ({
kind: "retained-evidence",
evidence_id: `counted-${String(value.count)}-${replacedBy}`,
}),
};
const keptPartial = (
error: AnalysisError,
replacedBy: CompletedValueReplacement,
) =>
expect(error.partialObservation).toEqual({
kind: "retained-evidence",
evidence_id: `counted-3-${replacedBy}`,
});
const diagnosticsRecord: CompletedValueRecord<Completed> = {
diagnostics: (value, replacedBy) => ({
completed_count: value.count,
replaced_by: replacedBy,
}),
};
const keptDiagnostics = (
error: AnalysisError,
replacedBy: CompletedValueReplacement,
) => {
expect(error.partialObservation).toBeUndefined();
expect(error.diagnostics).toMatchObject({
completed_count: 3,
replaced_by: replacedBy,
});
};
const adopters: readonly AdopterFixture[] = [
{
// apktool, ADB, tshark and sigrok one-shot readers.
name: "one-shot reader keeping a partial observation through a lease",
record: partialRecord,
kept: keptPartial,
publishes: false,
lease: true,
},
{
// Ghidra profile, signature binding and LLDB call tracing.
name: "one-shot reader keeping a non-observation value as diagnostics",
record: diagnosticsRecord,
kept: keptDiagnostics,
publishes: false,
lease: false,
},
{
name: "publishing writer keeping a partial observation",
record: partialRecord,
kept: keptPartial,
publishes: true,
lease: false,
},
{
// Evidence bundle export.
name: "publishing writer keeping diagnostics through a lease",
record: diagnosticsRecord,
kept: keptDiagnostics,
publishes: true,
lease: true,
},
];
interface ScenarioControl {
/** The work's own result; `abort` cancels after the workload exits. */
readonly work: "completes" | "fails" | "throws" | "completes-then-abort";
/** Cancellation arrives during final cleanup. */
readonly abortDuringClose?: true;
readonly cleanupFails?: true;
readonly publicationFails?: true;
}
interface ScenarioRun {
readonly finalized: Finalized<Completed>;
readonly commits: readonly Completed[];
readonly primary: AnalysisProtocolError;
/** Lets the cleanup owner confirm release, as a retry would. */
readonly recoverCleanup: () => Promise<void>;
}
const run = async (
adopter: AdopterFixture,
control: ScenarioControl,
): Promise<ScenarioRun> => {
const controller = new AbortController();
const primary = new AnalysisProtocolError("fixture failure");
const commits: Completed[] = [];
let releaseAttempts = 0;
const lease = new AnalysisCleanupLease(async () => {
releaseAttempts += 1;
if (control.cleanupFails === true && releaseAttempts === 1)
throw new Error("root removal denied");
}, ["/owned/root"]);
const typedCleanup = new ProviderCleanupError("fixture", ["/owned/root"], {
reason: "still owned",
});
const work = async (): Promise<Result<Completed, AnalysisError>> => {
switch (control.work) {
case "completes":
return ok(completed);
case "completes-then-abort":
controller.abort(reason);
return ok(completed);
case "fails":
return err(primary);
case "throws":
throw new Error("unexpected");
}
};
const finalized = await runOwned(work, {
providerId: "fixture",
operation: "fixture_operation",
signal: controller.signal,
...adopter.record,
...(adopter.lease ? { cleanupLeases: [lease] } : {}),
close: async () => {
if (control.abortDuringClose === true) controller.abort(reason);
if (adopter.lease) await lease.release();
else if (control.cleanupFails === true) throw typedCleanup;
},
...(adopter.publishes
? {
commit: async (value: Completed) => {
commits.push(value);
if (control.publicationFails === true)
throw new Error("publication failed");
},
}
: {}),
});
return {
finalized,
commits,
primary,
recoverCleanup: async () => {
if (adopter.lease) await lease.release();
else typedCleanup.resolveCleanup();
},
};
};
const failure = (finalized: Finalized<Completed>): AnalysisError => {
const result = finalizedResult(finalized);
if (result.ok) throw new Error(`expected an error, got ${finalized.kind}`);
return result.error;
};
const expectCancelledBy = (error: AnalysisError) => {
const cancellation = primaryAnalysisError(error);
expect(cancellation).toBeInstanceOf(AnalysisCancelledError);
expect(cancellation.cause).toBe(reason);
};
interface LifecycleScenario {
/** The docs/development/testing.md lifecycle row this scenario covers. */
readonly row: string;
readonly name: string;
readonly control: ScenarioControl;
readonly applies?: (adopter: AdopterFixture) => boolean;
readonly expect: (adopter: AdopterFixture, run: ScenarioRun) => Promise<void>;
}
const scenarios: readonly LifecycleScenario[] = [
{
row: "Cancellation arrives after workload exit (non-aborted control)",
name: "completes when cleanup is released and no cancellation arrives",
control: { work: "completes" },
expect: async (adopter, { finalized, commits }) => {
expect(finalized).toEqual({ kind: "completed", value: completed });
expect(commits).toEqual(adopter.publishes ? [completed] : []);
},
},
{
row: "Cancellation arrives after workload exit, during result reads or temporary-root removal",
name: "cancellation after workload exit, before the commit cutoff",
control: { work: "completes-then-abort" },
expect: async (adopter, { finalized, commits }) => {
expect(commits).toEqual([]);
expect(finalized).toMatchObject({
kind: "cancelled",
cleanup: { kind: "released" },
});
const error = failure(finalized);
expectCancelledBy(error);
adopter.kept(error, "cancellation");
},
},
{
row: "Cancellation arrives after workload exit, during result reads or temporary-root removal",
name: "cancellation during final cleanup",
control: { work: "completes", abortDuringClose: true },
expect: async (adopter, { finalized, commits }) => {
if (adopter.publishes) {
// Publication precedes close, so the receipt stands.
expect(commits).toEqual([completed]);
expect(finalized).toEqual({
kind: "completed",
value: completed,
lateCancellation: reason,
});
return;
}
expect(finalized).toMatchObject({
kind: "cancelled",
cleanup: { kind: "released" },
});
const error = failure(finalized);
expectCancelledBy(error);
adopter.kept(error, "cancellation");
},
},
{
row: "Cleanup fails after success or primary failure",
name: "cleanup fails after success, then its owner confirms release",
control: { work: "completes", cleanupFails: true },
expect: async (adopter, { finalized, commits, recoverCleanup }) => {
expect(commits).toEqual(adopter.publishes ? [completed] : []);
expect(finalized.kind).toBe("completed-cleanup-retained");
const error = failure(finalized);
// The documented one-shot contract: an error that keeps the value.
expect(projectAnalysisError(error).code).toBe("cleanup_incomplete");
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources).toEqual(["/owned/root"]);
adopter.kept(error, "failure");
await recoverCleanup();
expect(error.cleanupIncomplete).toBe(false);
},
},
{
row: "Cleanup fails after success or primary failure",
name: "cleanup fails after a primary failure, then its owner confirms release",
control: { work: "fails", cleanupFails: true },
expect: async (
_adopter,
{ finalized, commits, primary, recoverCleanup },
) => {
expect(commits).toEqual([]);
expect(finalized).toMatchObject({
kind: "failed",
cleanup: { kind: "retained" },
});
const error = failure(finalized);
expect(primaryAnalysisError(error)).toBe(primary);
expect(error.cleanupIncomplete).toBe(true);
await recoverCleanup();
expect(error.cleanupIncomplete).toBe(false);
},
},
{
row: "Cancellation arrives after workload exit, during result reads or temporary-root removal",
name: "a recorded failure keeps precedence over cancellation during final cleanup",
control: { work: "fails", abortDuringClose: true },
expect: async (_adopter, { finalized, primary }) => {
expect(finalized).toMatchObject({
kind: "failed",
cleanup: { kind: "released" },
});
expect(primaryAnalysisError(failure(finalized))).toBe(primary);
expect(primary.secondaryFailures).toEqual([
expect.objectContaining({
_tag: "AnalysisCancelledError",
cause: reason,
}),
]);
},
},
{
row: "Cancellation arrives after workload exit, during result reads or temporary-root removal",
name: "cancellation during final cleanup that also fails",
control: { work: "completes", abortDuringClose: true, cleanupFails: true },
expect: async (adopter, { finalized, recoverCleanup }) => {
const error = failure(finalized);
expect(error.cleanupIncomplete).toBe(true);
if (adopter.publishes) {
expect(finalized.kind).toBe("completed-cleanup-retained");
adopter.kept(error, "failure");
} else {
expect(finalized).toMatchObject({
kind: "cancelled",
cleanup: { kind: "retained" },
});
expectCancelledBy(error);
}
await recoverCleanup();
expect(error.cleanupIncomplete).toBe(false);
},
},
{
row: "Cancellation arrives after workload exit (publication outcome after commit)",
name: "publication fails and keeps the completed value",
control: { work: "completes", publicationFails: true },
applies: (adopter) => adopter.publishes,
expect: async (adopter, { finalized, commits }) => {
expect(commits).toEqual([completed]);
expect(finalized).toMatchObject({
kind: "failed",
cleanup: { kind: "released" },
});
adopter.kept(failure(finalized), "failure");
},
},
{
row: "Launch returns an error (thrown work)",
name: "types a thrown work failure as a provider adapter failure",
control: { work: "throws" },
expect: async (_adopter, { finalized, commits }) => {
expect(commits).toEqual([]);
expect(finalized.kind).toBe("failed");
expect(failure(finalized)._tag).toBe("ProviderAdapterError");
},
},
];
describe.each(adopters)("runOwned lifecycle: $name", (adopter) => {
it.each(scenarios.filter((scenario) => scenario.applies?.(adopter) ?? true))(
"$name",
async (scenario) => {
await scenario.expect(adopter, await run(adopter, scenario.control));
},
);
});
@@ -9,6 +9,8 @@ import {
type AnalysisError,
type AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -19,147 +21,75 @@ const value: AnalysisPartialObservation = {
evidence_id: "evidence-1",
};
const reason = { caller: "stop" };
const completedWork = async (): Promise<
Result<AnalysisPartialObservation, AnalysisError>
> => ok(value);
/** A close that aborts the caller's signal, so cancellation lands during final cleanup. */
const closeThenAbort =
(controller: AbortController, failure?: unknown) => async () => {
controller.abort(reason);
if (failure !== undefined) throw failure;
};
// Scenarios shared by every adopter live in runOwned.lifecycle.test.ts; these
// cases cover producer-supplied errors, reader leases and recovered completions.
const options = (controller: AbortController, close: () => Promise<void>) => ({
providerId: "fixture",
operation: "fixture_operation",
signal: controller.signal,
close,
partial: (completed: AnalysisPartialObservation) => completed,
});
describe("runOwned precedence", () => {
it("completes when cleanup is released and no cancellation arrives", async () => {
const controller = new AbortController();
const finalized = await runOwned(
completedWork,
options(controller, async () => undefined),
);
expect(finalized).toEqual({ kind: "completed", value });
});
const counted = async (): Promise<
Result<{ readonly count: number }, AnalysisError>
> => ok({ count: 3 });
const project = (completed: { readonly count: number }) => ({
kind: "retained-evidence" as const,
evidence_id: `counted-${String(completed.count)}`,
});
it("cancels a completed value when cancellation arrives during final cleanup before commit", async () => {
const controller = new AbortController();
const finalized = await runOwned(
completedWork,
options(controller, closeThenAbort(controller)),
);
expect(finalized.kind).toBe("cancelled");
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBe(value);
});
it("keeps a committed value when cancellation arrives after the commit", async () => {
const controller = new AbortController();
const commits: unknown[] = [];
const finalized = await runOwned(completedWork, {
...options(controller, closeThenAbort(controller)),
commit: async (committed) => {
commits.push(committed);
},
});
expect(commits).toEqual([value]);
expect(finalized).toEqual({
kind: "completed",
value,
lateCancellation: reason,
});
});
it("does not commit after cancellation and reports it before commit", async () => {
const controller = new AbortController();
controller.abort(reason);
let committed = false;
const finalized = await runOwned(completedWork, {
...options(controller, async () => undefined),
commit: async () => {
committed = true;
},
});
expect(committed).toBe(false);
expect(finalized.kind).toBe("cancelled");
});
it("keeps a recorded failure primary when cancellation arrives during final cleanup", async () => {
const controller = new AbortController();
const failure = new AnalysisProtocolError("fixture failure");
const finalized = await runOwned(
async () => err(failure),
options(controller, closeThenAbort(controller)),
);
expect(finalized.kind).toBe("failed");
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected failure");
expect(primaryAnalysisError(result.error)).toBe(failure);
expect(failure.secondaryFailures).toEqual([
expect.objectContaining({
_tag: "AnalysisCancelledError",
cause: reason,
describe("runOwned close-failure lease binding", () => {
it("binds an artifact reader close failure to the leases it reports", async () => {
let attempts = 0;
const lease = new AnalysisCleanupLease(async () => {
attempts += 1;
if (attempts === 1) throw new Error("close failed");
}, ["/reader/handle"]);
await lease.release().catch(() => undefined);
const finalized = await runOwned(counted, {
...options(new AbortController(), async () => {
throw new ArtifactReaderFailure("io", "reader close failed", {
cleanup: { reason: "handle retained", resources: ["/reader/handle"] },
cleanupLeases: [lease],
});
}),
]);
});
it("reports a completed value with unconfirmed cleanup as cleanup_incomplete keeping the value", async () => {
const controller = new AbortController();
const finalized = await runOwned(
completedWork,
options(controller, async () => {
throw new Error("close failed");
}),
);
partial: project,
});
expect(finalized.kind).toBe("completed-cleanup-retained");
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected cleanup failure");
expect(result.error).toBeInstanceOf(ProviderCleanupError);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toBe(value);
await lease.release();
expect(result.error.cleanupIncomplete).toBe(false);
});
it("keeps the primary failure's identity when cleanup also fails", async () => {
it("binds a typed close failure to the leases while the failed work stays primary", async () => {
const controller = new AbortController();
const failure = new AnalysisProtocolError("fixture failure");
const cleanup = new ProviderCleanupError("fixture", ["fixture-run"], {
reason: "still running",
const typed = new ProviderCleanupError("fixture", ["/producer/root"], {
reason: "still owned",
});
const finalized = await runOwned(
async () => err(failure),
options(controller, async () => {
throw cleanup;
}),
const lease = new AnalysisCleanupLease(
async () => undefined,
["/owned/root"],
);
expect(finalized).toMatchObject({
kind: "failed",
cleanup: { kind: "retained", failure: cleanup },
const finalized = await runOwned(async () => err(failure), {
...options(controller, async () => {
throw typed;
}),
partial: project,
cleanupLeases: [lease],
});
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected failure");
expect(primaryAnalysisError(result.error)).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
});
it("types a thrown work failure as a provider adapter failure", async () => {
const controller = new AbortController();
const finalized = await runOwned(
async () => {
throw new Error("unexpected");
},
options(controller, async () => undefined),
);
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected failure");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cleanupResources).toEqual(["/owned/root"]);
await lease.release();
expect(typed.cleanupIncomplete).toBe(false);
expect(result.error.cleanupIncomplete).toBe(false);
});
});
@@ -205,17 +135,6 @@ it("classifies a producer composite whose primary is a cancellation as cancelled
});
});
it("keeps a failure with a later cancellation classified as failed", async () => {
const controller = new AbortController();
const failure = new AnalysisProtocolError("fixture failure");
const finalized = await runOwned(
async () => err(failure),
options(controller, closeThenAbort(controller)),
);
expect(finalized.kind).toBe("failed");
expect(failure.secondaryFailures).toHaveLength(1);
});
it("keeps producer cleanup retained when close does not resolve it", async () => {
const controller = new AbortController();
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
@@ -232,18 +151,6 @@ it("keeps producer cleanup retained when close does not resolve it", async () =>
});
});
it("retains completed work when publication fails", async () => {
const finalized = await runOwned(completedWork, {
...options(new AbortController(), async () => undefined),
commit: async () => {
throw new Error("publication failed");
},
});
const result = finalizedResult(finalized);
if (result.ok) throw new Error("expected publication failure");
expect(result.error.partialObservation).toBe(value);
});
it("publishes recovered completion after cleanup resolves", async () => {
const controller = new AbortController();
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
@@ -1,3 +1,7 @@
import {
AnalysisCleanupLeaseGroup,
type AnalysisCleanupLease,
} from "../artifacts/AnalysisCleanupLease.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
AnalysisError,
@@ -25,11 +29,11 @@ export type CleanupOutcome =
* Work, owner cleanup and cancellation composed by one precedence rule:
* - a recorded failure stays primary; a later cancellation is attached to it;
* - cancellation observed before commit turns a completed value into a
* cancellation that keeps the value as its partial observation;
* cancellation that keeps the value (see `CompletedValueRecord`);
* - cancellation observed after commit keeps the completed value;
* - unconfirmed cleanup never replaces the primary. A completed value whose
* cleanup is unconfirmed is a `cleanup_incomplete` error that keeps the
* value as its partial observation (the documented one-shot contract).
* value (the documented one-shot contract).
*/
export type Finalized<Value> =
| {
@@ -61,14 +65,53 @@ export interface RecoveredCompletion<Value> {
readonly cancellationDiagnostics?: Readonly<Record<string, JsonValue>>;
}
export interface RunOwnedOptions<Value> {
/** The error that replaces a completed value and must keep it. */
export type CompletedValueReplacement = "cancellation" | "failure";
/**
* How an error that replaces a completed value keeps it: as its partial
* observation, or as diagnostics when the value is not an observation a
* caller can recover. The replacement lets a producer keep a different
* projection for a cancellation than for a cleanup or publication failure.
*/
export type CompletedValueRecord<Value> =
| {
readonly partial: (
value: Value,
replacedBy: CompletedValueReplacement,
) => AnalysisPartialObservation;
readonly diagnostics?: never;
}
| {
readonly diagnostics: (
value: Value,
replacedBy: CompletedValueReplacement,
) => Readonly<Record<string, JsonValue>>;
readonly partial?: never;
};
type KeptCompletion =
| { readonly partialObservation: AnalysisPartialObservation }
| { readonly diagnostics: Readonly<Record<string, JsonValue>> };
export type RunOwnedOptions<Value> = RunOwnedBaseOptions<Value> &
CompletedValueRecord<Value>;
interface RunOwnedBaseOptions<Value> {
readonly providerId: string;
readonly operation: string;
readonly signal: AbortSignal;
readonly signal?: AbortSignal | undefined;
/** Releases everything the work acquired; awaited before the signal is read. */
readonly close: () => Promise<void>;
/** Names the owned resources when a non-REA close failure names none. */
readonly fallbackResource?: string;
/**
* The exact leases whose confirmed release resolves a close failure; they
* also attribute its resources. Omit them when close throws an error its
* producer already bound, or an artifact reader failure that names its own
* retained leases.
*/
readonly cleanupLeases?: readonly AnalysisCleanupLease[];
/**
* Publishes a completed value. Cancellation observed before it runs cancels
* the work; cancellation observed after it keeps the committed value.
@@ -87,10 +130,27 @@ export interface RunOwnedOptions<Value> {
) => RecoveredCompletion<Value> | undefined;
}
const keepCompleted = <Value>(
record: CompletedValueRecord<Value>,
value: Value,
replacedBy: CompletedValueReplacement,
): KeptCompletion =>
record.partial === undefined
? { diagnostics: record.diagnostics(value, replacedBy) }
: { partialObservation: record.partial(value, replacedBy) };
const retainCompleted = (
error: AnalysisError,
kept: KeptCompletion,
): AnalysisError =>
"partialObservation" in kept
? error.retainPartialObservation(kept.partialObservation)
: error.retainDiagnostics(kept.diagnostics);
const closeFailure = (
cause: unknown,
options: Pick<
RunOwnedOptions<unknown>,
RunOwnedBaseOptions<unknown>,
"providerId" | "operation" | "fallbackResource"
>,
): AnalysisError => {
@@ -114,7 +174,7 @@ const closeFailure = (
};
/** Run owned work, close its owner, and apply the finalization precedence once. */
export const runOwned = async <Value extends AnalysisPartialObservation>(
export const runOwned = async <Value>(
work: () => Promise<Result<Value, AnalysisError>>,
options: RunOwnedOptions<Value>,
): Promise<Finalized<Value>> => {
@@ -141,19 +201,20 @@ export const runOwned = async <Value extends AnalysisPartialObservation>(
committed = true;
return { ok: true, value };
} catch (cause) {
const kept = keepCompleted(options, value, "failure");
return {
ok: false,
error:
cause instanceof AnalysisError
? cause.retainPartialObservation(value)
? retainCompleted(cause, kept)
: new ProviderAdapterError(options.providerId, options.operation, {
cause,
partialObservation: value,
...kept,
}),
};
}
};
if (primary.ok && options.commit !== undefined && !options.signal.aborted)
if (primary.ok && options.commit !== undefined && !isAborted(options.signal))
primary = await commit(primary.value);
options.inspectPrimary?.(primary);
const hadIncompleteCleanup = !primary.ok && primary.error.cleanupIncomplete;
@@ -163,7 +224,16 @@ export const runOwned = async <Value extends AnalysisPartialObservation>(
// they can confirm it, so its status is read again afterwards.
await options.close();
} catch (cause) {
cleanup = { kind: "retained", failure: closeFailure(cause, options) };
const failure = closeFailure(cause, options);
const leases =
options.cleanupLeases ??
// A reader failure's leases are authoritative only for the cleanup it
// reports.
(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
? cause.cleanupLeases
: undefined);
if (leases !== undefined) new AnalysisCleanupLeaseGroup(failure, leases);
cleanup = { kind: "retained", failure };
}
const signal = options.signal;
let outcome:
@@ -197,7 +267,7 @@ export const runOwned = async <Value extends AnalysisPartialObservation>(
outcome.ok &&
!primary.ok &&
options.commit !== undefined &&
!signal.aborted
!isAborted(signal)
) {
const publication = await commit(outcome.value);
if (!publication.ok) {
@@ -232,14 +302,21 @@ export const runOwned = async <Value extends AnalysisPartialObservation>(
};
if (!committed) {
cancelled = true;
const kept = keepCompleted(options, outcome.value, "cancellation");
outcome = {
ok: false,
error: cancelledBy(options.operation, signal, {
partialObservation: outcome.value,
...(outcome.diagnostics === undefined
? {}
: { diagnostics: outcome.diagnostics }),
}),
error: cancelledBy(
options.operation,
signal,
"partialObservation" in kept
? {
partialObservation: kept.partialObservation,
...(outcome.diagnostics === undefined
? {}
: { diagnostics: outcome.diagnostics }),
}
: { diagnostics: { ...outcome.diagnostics, ...kept.diagnostics } },
),
};
}
}
@@ -248,7 +325,10 @@ export const runOwned = async <Value extends AnalysisPartialObservation>(
return {
kind: "completed-cleanup-retained",
value: outcome.value,
error: cleanup.failure.retainPartialObservation(outcome.value),
error: retainCompleted(
cleanup.failure,
keepCompleted(options, outcome.value, "failure"),
),
};
return {
kind: cancelled ? "cancelled" : "failed",
+16 -11
View File
@@ -66,6 +66,7 @@ import {
signalRecordingSchema,
} from "../domain/signals/signalRecording.js";
import { err, ok, type Result } from "../domain/result.js";
import { finalizedResult, runOwned } from "../providers/runOwned.js";
import {
runOwnedCommand,
OwnedCommandFailure,
@@ -451,17 +452,21 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
),
);
}
return this.#resources.finish(
result,
(execution) =>
createAnalysisExecutionEvidence(
operation,
input,
execution,
"observed",
),
operation,
options?.signal,
const completed = result;
return finalizedResult(
await runOwned(async () => completed, {
providerId: this.#resources.providerId,
operation,
signal: options?.signal,
close: () => this.#resources.release(),
partial: (execution) =>
createAnalysisExecutionEvidence(
operation,
input,
execution,
"observed",
),
}),
);
}
#failure(
@@ -9,6 +9,7 @@ import {
execFileOutput,
} from "../../../../src/process/ExecFileOutput.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { primaryAnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { AdbInputSnapshotFailure } from "../../../../src/adb/AdbInputSnapshot.js";
@@ -544,7 +545,9 @@ test.each([false, true])(
throw new Error("expected retained package digest cleanup failure");
expect(result.error.cleanupIncomplete).toBe(!cancelDuringClose);
if (cancelDuringClose) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupResources).toEqual([]);
}
@@ -649,7 +652,9 @@ test.each([false, true])(
signal: controller.signal,
});
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
if (failCleanup) {
expect(result.error.cause).toBeInstanceOf(AggregateError);
const causes = (result.error.cause as AggregateError).errors as [
@@ -713,7 +718,9 @@ test("cancels after the input snapshot copy and before contacting the device", a
signal: controller.signal,
});
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(execFileOutput).toHaveBeenCalledTimes(1);
expect(execFileBytes).not.toHaveBeenCalled();
expect(await readFile(f.source, "utf8")).toBe("immutable payload");
@@ -5,6 +5,7 @@ import { afterEach, expect, onTestFinished, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { primaryAnalysisError } from "../../../../src/domain/analysisErrorBase.js";
import {
execFileBytes,
execFileOutput,
@@ -73,7 +74,9 @@ test("does not publish a screen capture cancelled before the commit point", asyn
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cause).toBe(abortReason);
await expect(readFile(join(output, "screen.png"))).rejects.toMatchObject({
code: "ENOENT",
@@ -106,7 +109,9 @@ test("retains a committed screen capture receipt when cancellation arrives after
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cause).toBe(abortReason);
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence).toMatchObject({
@@ -262,7 +267,9 @@ test("retains the committed receipt and cleanup observation when cancellation ra
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cause).toBeInstanceOf(AggregateError);
const causes = (result.error.cause as AggregateError).errors as [
@@ -354,7 +361,9 @@ test("cancellation after a failed hard link but before exclusive creation does n
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cause).toBe(abortReason);
await expect(readFile(join(output, "screen.png"))).rejects.toMatchObject({
code: "ENOENT",
@@ -392,7 +401,9 @@ test("retains a receipt when cancellation arrives after exclusive output creatio
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cause).toBe(abortReason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "capture_adb_screen",
@@ -718,7 +729,9 @@ test("retains incomplete cleanup on a cancelled capture while keeping cancellati
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toContain(runtime.path);
await provider.close();
@@ -565,3 +565,22 @@ it.each(["before-commit", "after-commit", "cleanup-retry"])(
}
},
);
it("reports an unconfirmed read cleanup after a completed comparison as cleanup_incomplete", async () => {
const directory = await createTestTempDirectory("rea-bundle-close-failure-");
const { path, bundle } = await validBundleFile(directory);
const close = vi
.spyOn(ArtifactResourceScope.prototype, "close")
.mockRejectedValueOnce(new Error("injected scope close failure"));
onTestFinished(() => close.mockRestore());
const result = await compareEvidenceBundlesCommand({
leftPath: path,
rightPath: path,
});
if (result.ok) throw new Error("Expected an unconfirmed cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
// The completed comparison stays with the cleanup failure.
expect(result.error.diagnostics).toMatchObject({
completed_result: bundleComparison.compareBundles(bundle, bundle),
});
});