mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
* fix(process): preserve producer-owned inspection cancellation Prototype the explicit inspection origin and retained file/batch owner protocol. Downstream adapter migration and lease binding remain required before integration. * fix(process): type inspection ownership handoffs precisely * fix(process): retain plain inspection preparation cancellation * fix(process): retain explicit inspection cleanup and cancellation facts * fix(process): require explicit inspection facts and cleanup scope * refactor(process): remove redundant cleanup forwarding wrapper * fix(firmware): retain inspection cancellation and cleanup ownership * fix(wakaru): retain typed inspection outcomes and caller scope * fix(ghidra): preserve inspection facts and launch cleanup scope * fix(providers): preserve typed process inspection outcomes * refactor(process): remove obsolete inspection cancellation re-export * fix(native): preserve inspection preparation cleanup and cancellation * fix(registration): retain typed inspection cleanup during Git discovery * fix(process): preserve inspection deadline origin in adapter handoff * fix(process): consume the canonical inspection selector in binding * fix(native): preserve inspection deadlines and cleanup retry completion * test(process): narrow the retained Darwin preparation owner * fix(providers): preserve preparation ownership and timeout policy Narrow preparation facts through their canonical owner, preserve actual cleanup capabilities and recorded cancellation, and keep enclosing command deadlines authoritative. Retain raw inspection records through Java refusal and configuration projections without changing availability fallback policy. * refactor(process): use one inspection failure type owner * fix(providers): retain recorded secondary failures when rebinding * fix(ghidra): preserve normalized inspection facts through public projection * fix(process): normalize native process listing cancellation * docs(development): clarify failure provenance and cleanup verification * fix(process): preserve direct inspection cancellation facts * fix(process): retain inspection owners through termination * fix(process): preserve command inspection cleanup provenance * fix(desktop): preserve inspection facts after cleanup retry * refactor(process)!: migrate desktop capture with inspection producers * fix(desktop)!: preserve capture failure origins and cleanup owners (#10115) * fix(desktop): preserve capture failure origins and cleanup * fix(desktop): type keyring cleanup cancellation * fix(kuna)!: retain copy cleanup before retiring workspaces (#10114) * fix(kuna): own copy cancellation and descriptor cleanup * fix(kuna): close copy descriptors before workspace removal * test(kuna): type copy lifecycle gates for project library * docs(kuna): explain retained copy-file cleanup and staging ownership * docs(kuna): separate cache publication, integrity and cleanup guidance * fix(desktop): retain sampling inspection owners before release * fix(process): preserve capture file read cancellation origin * fix(process): retain capture inspection cancellation and cleanup owners * fix(process): retain sampling inspection failures in capture scope * fix(process): retain inspection failures through mandatory cleanup * fix(process): distinguish sampler shutdown from capture cancellation * fix(browser): retain startup EOF before later cancellation * fix(process): bind capture inspection cleanup to the owning run * test(process): migrate cancellation fixtures to typed owners Record explicit preparation and command cancellation facts, retain exact opaque reasons and physical causes, and guard the mocked version probe from native preparation. Keep the live Wakaru command control while adding a recording equivalent for bounded source verification. * fix(process): own sampling reads and settle inspection peers * fix(errors): retain authored recovery through cleanup composition * fix(process): retain all sampling inspection leaves for final cleanup * test(browser): report recording cleanup signal outcome * fix(process): keep inspection error context out of implicit JSON * fix(desktop): retain normalized sampling cleanup ownership * test(process): assert tagged startup cancellation
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import {
|
|
artifactFileCancellationCause,
|
|
bindArtifactFileCleanup,
|
|
} from "../artifacts/ArtifactFileOperation.js";
|
|
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
|
import {
|
|
retainProcessInspectionCleanup,
|
|
processInspectionCancellationError,
|
|
} from "../providers/OwnedCommandCleanup.js";
|
|
import { basename, join } from "node:path";
|
|
import { realpath } from "node:fs/promises";
|
|
import type { Writable } from "node:stream";
|
|
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisArtifactChangedError,
|
|
AnalysisCancelledError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
import { KunaDocumentError } from "./KunaDocumentError.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
|
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
|
|
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
|
import {
|
|
copyAndHash,
|
|
kunaCopyFailureFacts,
|
|
runKunaCommand,
|
|
type KunaInputPrefix,
|
|
type KunaProcessLauncher,
|
|
} from "./KunaCommand.js";
|
|
import type { KunaDeadline } from "./KunaConfiguration.js";
|
|
import type { KunaInstallation } from "./KunaInstallation.js";
|
|
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
|
import type { KunaRunRelease } from "./KunaRunSlots.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import {
|
|
AnalysisCleanupLease,
|
|
AnalysisCleanupLeaseGroup,
|
|
} from "../artifacts/AnalysisCleanupLease.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
|
|
interface WorkspaceRoot {
|
|
readonly root: PrivateRuntimeRoot;
|
|
readonly cleanup: AnalysisCleanupLease;
|
|
}
|
|
interface Workspace extends WorkspaceRoot {
|
|
readonly input: string;
|
|
readonly bytes: number;
|
|
/** Leading bytes of the input kept while it was copied and hashed. */
|
|
readonly prefix: Buffer;
|
|
}
|
|
|
|
/** The private input's size and the leading bytes kept from its verified copy. */
|
|
export interface KunaInputHead {
|
|
readonly bytes: number;
|
|
readonly prefix: Buffer;
|
|
}
|
|
|
|
export interface KunaTargetCommandSnapshot extends ProviderProcessSnapshot {
|
|
/** Canonical, digest-checked private input used by this command. */
|
|
readonly input: string;
|
|
}
|
|
|
|
export interface KunaTargetCommandRequest {
|
|
readonly installation: KunaInstallation;
|
|
readonly operation: string;
|
|
readonly arguments: (input: string) => readonly string[];
|
|
readonly outputBytes: number;
|
|
/** Parses stdout as it arrives instead of buffering it in the snapshot. */
|
|
readonly stdout?: Writable;
|
|
readonly deadline: KunaDeadline;
|
|
readonly signal: AbortSignal;
|
|
/** Admission remains owned until the native process is proven stopped. */
|
|
readonly releaseRun?: KunaRunRelease;
|
|
}
|
|
|
|
/** Own the digest-checked target copy and every command that can still use it. */
|
|
export class KunaTargetCommands {
|
|
readonly #resources = new ArtifactResourceScope();
|
|
readonly #retained: {
|
|
readonly error: ProviderCleanupError;
|
|
readonly process: AnalysisCleanupLease;
|
|
}[] = [];
|
|
readonly #roots: WorkspaceRoot[] = [];
|
|
#workspace: Promise<Result<Workspace, AnalysisError>> | undefined;
|
|
#closeAttempt: Promise<Result<null, ProviderCleanupError>> | undefined;
|
|
#closing = false;
|
|
|
|
constructor(
|
|
private readonly target: BinaryTarget,
|
|
private readonly lifetime: {
|
|
readonly signal: AbortSignal;
|
|
readonly launcher?: KunaProcessLauncher;
|
|
/** Leading bytes in-process readers need, kept while the copy is hashed. */
|
|
readonly inputPrefix?: KunaInputPrefix;
|
|
},
|
|
) {}
|
|
|
|
run(
|
|
request: KunaTargetCommandRequest,
|
|
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
|
if (this.#closing || request.signal.aborted) {
|
|
request.releaseRun?.();
|
|
return Promise.resolve(
|
|
err(
|
|
new AnalysisCancelledError(
|
|
request.operation,
|
|
request.signal.aborted
|
|
? { cause: request.signal.reason }
|
|
: undefined,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return this.#run(request);
|
|
}
|
|
|
|
/** Establish the immutable input before waiting for a foreground run slot. */
|
|
prepare(): Promise<Result<null, AnalysisError>> {
|
|
if (this.#closing || this.lifetime.signal.aborted)
|
|
return Promise.resolve(
|
|
err(
|
|
new AnalysisCancelledError(
|
|
"kuna:workspace",
|
|
this.lifetime.signal.aborted
|
|
? { cause: this.lifetime.signal.reason }
|
|
: undefined,
|
|
),
|
|
),
|
|
);
|
|
return this.#admit("kuna:workspace", () =>
|
|
this.#ensureWorkspace().then((workspace) =>
|
|
workspace.ok ? ok(null) : workspace,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The verified input's size and kept leading bytes, establishing the input
|
|
* first. They come from the stream that was hashed, so they are exactly
|
|
* the bytes Kuna analyzes, and reading them opens no file.
|
|
*/
|
|
inputHead(operation: string): Promise<Result<KunaInputHead, AnalysisError>> {
|
|
if (this.#closing || this.lifetime.signal.aborted)
|
|
return Promise.resolve(
|
|
err(
|
|
new AnalysisCancelledError(
|
|
operation,
|
|
this.lifetime.signal.aborted
|
|
? { cause: this.lifetime.signal.reason }
|
|
: undefined,
|
|
),
|
|
),
|
|
);
|
|
return this.#admit(operation, async () => {
|
|
const workspace = await this.#ensureWorkspace();
|
|
if (!workspace.ok) return workspace;
|
|
const { bytes, prefix } = workspace.value;
|
|
return ok({ bytes, prefix });
|
|
});
|
|
}
|
|
|
|
async #run(
|
|
request: KunaTargetCommandRequest,
|
|
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
|
const { releaseRun, ...options } = request;
|
|
let retained = false;
|
|
try {
|
|
return await this.#admit(request.operation, async () => {
|
|
const workspace = await this.#ensureWorkspace();
|
|
if (!workspace.ok) return workspace;
|
|
let failedHashClose:
|
|
| {
|
|
readonly owner: OwnedFileHandle;
|
|
readonly lease: AnalysisCleanupLease;
|
|
}
|
|
| undefined;
|
|
const outcome = await runKunaCommand({
|
|
...options,
|
|
arguments: request.arguments(workspace.value.input),
|
|
workspace: workspace.value.root.path,
|
|
timeoutMs: request.deadline.ms,
|
|
launcher: this.lifetime.launcher,
|
|
retainFailedFileClose: (owner, resource) => {
|
|
const lease = new AnalysisCleanupLease(
|
|
() => owner.close(),
|
|
[resource],
|
|
);
|
|
this.#resources.retain({ kind: "lease", resource, lease });
|
|
failedHashClose = { owner, lease };
|
|
},
|
|
});
|
|
if (outcome.kind === "retained") {
|
|
const process = new AnalysisCleanupLease(async () => {
|
|
const stopped = await outcome.owner?.stop();
|
|
if (stopped === undefined || stopped.status === "incomplete")
|
|
throw new KunaDocumentError("stop_unproven");
|
|
releaseRun?.();
|
|
}, outcome.processResources);
|
|
new AnalysisCleanupLeaseGroup(outcome.error, [
|
|
process,
|
|
workspace.value.cleanup,
|
|
]);
|
|
this.#retained.push({ error: outcome.error, process });
|
|
retained = true;
|
|
return err(outcome.error);
|
|
}
|
|
const fileFailure =
|
|
outcome.kind === "failed" ? outcome.fileCleanupFailure : undefined;
|
|
if (outcome.kind === "ok")
|
|
return ok({ ...outcome.snapshot, input: workspace.value.input });
|
|
if (
|
|
fileFailure !== undefined &&
|
|
fileFailure.owner === failedHashClose?.owner
|
|
)
|
|
failedHashClose.lease.bindCleanupError(outcome.error);
|
|
return err(outcome.error);
|
|
});
|
|
} finally {
|
|
if (!retained) releaseRun?.();
|
|
}
|
|
}
|
|
|
|
/** Called after the session signal aborts; failures keep their retry owners. */
|
|
close(): Promise<Result<null, ProviderCleanupError>> {
|
|
this.#closing = true;
|
|
this.#closeAttempt ??= this.#closeCommands().finally(() => {
|
|
this.#closeAttempt = undefined;
|
|
});
|
|
return this.#closeAttempt;
|
|
}
|
|
|
|
async #closeCommands(): Promise<Result<null, ProviderCleanupError>> {
|
|
const failures: ProviderCleanupError[] = [];
|
|
try {
|
|
await this.#resources.close();
|
|
} catch (cause: unknown) {
|
|
const observation = ArtifactReaderFailure.cleanupObservation(
|
|
cause,
|
|
"Kuna command file descriptor",
|
|
);
|
|
const failure = new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
observation.resources,
|
|
{ reason: observation.reason },
|
|
{ cause },
|
|
);
|
|
bindArtifactFileCleanup(cause, failure);
|
|
failures.push(failure);
|
|
}
|
|
for (const retained of [...this.#retained]) {
|
|
try {
|
|
await retained.process.release();
|
|
this.#retained.splice(this.#retained.indexOf(retained), 1);
|
|
} catch {
|
|
// The exact process lease remains available for the next retry.
|
|
}
|
|
}
|
|
failures.push(...this.#retained.map(({ error }) => error));
|
|
const workspace = await this.#workspace;
|
|
if (this.#retained.length === 0) {
|
|
if (workspace?.ok === true) {
|
|
this.#roots.push(workspace.value);
|
|
this.#workspace = undefined;
|
|
}
|
|
for (const owned of [...this.#roots])
|
|
try {
|
|
await owned.cleanup.release();
|
|
this.#roots.splice(this.#roots.indexOf(owned), 1);
|
|
} catch (cause: unknown) {
|
|
const failure = new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
[owned.root.path],
|
|
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
|
{ cause },
|
|
);
|
|
owned.cleanup.bindCleanupError(failure);
|
|
failures.push(failure);
|
|
}
|
|
}
|
|
return failures.length === 0
|
|
? ok(null)
|
|
: err(
|
|
new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
failures.flatMap((failure) => failure.cleanupResources),
|
|
{
|
|
reason: failures
|
|
.map((failure) =>
|
|
String(failure.diagnostics?.["reason"] ?? failure.message),
|
|
)
|
|
.join("; "),
|
|
},
|
|
{
|
|
cause: new AggregateError(
|
|
failures,
|
|
"Kuna command cleanup remains incomplete",
|
|
),
|
|
},
|
|
).bindCleanupDependencies(failures),
|
|
);
|
|
}
|
|
|
|
async #admit<T>(
|
|
operation: string,
|
|
work: () => Promise<Result<T, AnalysisError>>,
|
|
): Promise<Result<T, AnalysisError>> {
|
|
try {
|
|
return await this.#resources.run(work);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof ArtifactReaderFailure &&
|
|
cause.cleanup !== undefined
|
|
) {
|
|
const failure = new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
cause.cleanup.resources,
|
|
{ reason: cause.cleanup.reason },
|
|
{ cause, operation },
|
|
);
|
|
bindArtifactFileCleanup(cause, failure);
|
|
return err(failure);
|
|
}
|
|
if (this.#closing && cause instanceof ArtifactReaderFailure)
|
|
return err(new AnalysisCancelledError(operation, { cause }));
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
async #ensureWorkspace(): Promise<Result<Workspace, AnalysisError>> {
|
|
const pending = (this.#workspace ??= this.#createWorkspace());
|
|
const result = await pending;
|
|
if (
|
|
this.#workspace === pending &&
|
|
!result.ok &&
|
|
!result.error.cleanupIncomplete
|
|
)
|
|
this.#workspace = undefined;
|
|
return result;
|
|
}
|
|
|
|
async #createWorkspace(): Promise<Result<Workspace, AnalysisError>> {
|
|
let owned: WorkspaceRoot | undefined;
|
|
try {
|
|
const root = await PrivateRuntimeRoot.create({ prefix: "rea-kuna-" });
|
|
const copyResources = new ArtifactResourceScope();
|
|
owned = {
|
|
root,
|
|
cleanup: new AnalysisCleanupLease(async () => {
|
|
await copyResources.close();
|
|
await root.close();
|
|
}, [root.path]),
|
|
};
|
|
const input = join(root.path, basename(this.target.path));
|
|
let copied: Awaited<ReturnType<typeof copyAndHash>>;
|
|
try {
|
|
copied = await copyAndHash(this.target.path, input, {
|
|
signal: this.lifetime.signal,
|
|
resources: copyResources,
|
|
...(this.lifetime.inputPrefix === undefined
|
|
? {}
|
|
: { keep: this.lifetime.inputPrefix }),
|
|
});
|
|
} catch (cause: unknown) {
|
|
return await this.#workspaceFailure(this.#copyFailure(cause), owned);
|
|
}
|
|
if (copied.sha256 !== this.target.sha256)
|
|
return await this.#workspaceFailure(
|
|
new AnalysisArtifactChangedError(
|
|
"kuna:workspace",
|
|
this.target.path,
|
|
"the target no longer matches the digest selected for analysis",
|
|
),
|
|
owned,
|
|
);
|
|
try {
|
|
await prepareProcessOwnershipInspection(this.lifetime.signal);
|
|
} catch (cause: unknown) {
|
|
const primary =
|
|
processInspectionCancellationError(cause, "kuna:workspace") ??
|
|
new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", {
|
|
cause,
|
|
});
|
|
throw retainProcessInspectionCleanup(
|
|
cause,
|
|
primary,
|
|
this.#resources,
|
|
"kuna:workspace",
|
|
);
|
|
}
|
|
return ok({
|
|
...owned,
|
|
input: await realpath(input),
|
|
bytes: copied.bytes,
|
|
prefix: copied.prefix,
|
|
});
|
|
} catch (cause: unknown) {
|
|
return this.#workspaceFailure(
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", {
|
|
cause,
|
|
userMessage: `Kuna workspace failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
}),
|
|
owned,
|
|
);
|
|
}
|
|
}
|
|
|
|
#copyFailure(cause: unknown): AnalysisError {
|
|
const facts = kunaCopyFailureFacts(cause);
|
|
const completion = facts.completion;
|
|
const primaryCause = facts.cause;
|
|
const cancellation =
|
|
completion?.cancellation ??
|
|
(facts.origin === "owned-or-classified" &&
|
|
primaryCause instanceof ArtifactReaderFailure &&
|
|
primaryCause.reason === "cancelled"
|
|
? { reason: artifactFileCancellationCause(primaryCause) }
|
|
: undefined);
|
|
const primary =
|
|
cancellation !== undefined
|
|
? new AnalysisCancelledError("kuna:workspace", {
|
|
cause: cancellation.reason,
|
|
})
|
|
: completion === undefined
|
|
? new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", {
|
|
cause: primaryCause,
|
|
})
|
|
: undefined;
|
|
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
|
const error = new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
cause.cleanup.resources,
|
|
{ reason: cause.cleanup.reason },
|
|
{
|
|
operation: "kuna:workspace",
|
|
cause,
|
|
...(primary === undefined ? {} : { cleanupPrimary: primary }),
|
|
},
|
|
);
|
|
bindArtifactFileCleanup(cause, error);
|
|
return error;
|
|
}
|
|
return (
|
|
primary ??
|
|
new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { cause })
|
|
);
|
|
}
|
|
|
|
/** Rollback and the original outcome share the exact root cleanup lease. */
|
|
async #workspaceFailure(
|
|
primary: AnalysisError,
|
|
owned: WorkspaceRoot | undefined,
|
|
): Promise<Result<never, AnalysisError>> {
|
|
let failure = primary;
|
|
if (owned !== undefined) {
|
|
try {
|
|
await owned.cleanup.release();
|
|
} catch (cause: unknown) {
|
|
this.#roots.push(owned);
|
|
const cleanup = new ProviderCleanupError(
|
|
KUNA_PROVIDER_ID,
|
|
[owned.root.path],
|
|
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
|
{ cause, operation: "kuna:workspace" },
|
|
);
|
|
owned.cleanup.bindCleanupError(cleanup);
|
|
failure = analysisErrorWithCleanupFailure(
|
|
primary,
|
|
cleanup,
|
|
"kuna:workspace",
|
|
).bindCleanupDependencies([primary, cleanup]);
|
|
}
|
|
}
|
|
return err(
|
|
this.lifetime.signal.aborted
|
|
? analysisErrorWithCancellation(
|
|
failure,
|
|
new AnalysisCancelledError("kuna:workspace", {
|
|
cause: this.lifetime.signal.reason,
|
|
}),
|
|
)
|
|
: failure,
|
|
);
|
|
}
|
|
}
|