mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(kuna): preserve cancellation and cleanup ownership
Reuse shared startup ownership for query waits and preserve opaque cancellation reasons, captured command facts and typed primary failures. Bind retained command errors to exact process and workspace leases; share close attempts and update earlier caller and aggregate errors after cleanup retry. Refs: #4700, #4702
This commit is contained in:
1 parent
988494abdb
commit
5927937179
9 files changed
+490
-136
No files matched your search
@@ -91,6 +91,11 @@ Installation acquisition is shared, with independently cancellable caller waits.
|
||||
The final departing caller stops the probe and joins its cleanup; session close
|
||||
also stops and joins acquisition. A cleanly cancelled acquisition can be retried,
|
||||
while a retained cleanup failure blocks replacement until its owner is closed.
|
||||
Shared string and reference queries preserve caller cancellation reasons and
|
||||
join the final departing caller's command cleanup. A command cleanup error keeps
|
||||
its typed primary failure and captured output. Its live resource list follows
|
||||
both process and workspace ownership: proving process exit does not prove that
|
||||
the private workspace was removed, and retry updates earlier caller errors.
|
||||
|
||||
## Provenance and limitations
|
||||
|
||||
|
||||
+39
-45
@@ -83,6 +83,7 @@ export type KunaCommandOutcome =
|
||||
}
|
||||
| {
|
||||
readonly kind: "retained";
|
||||
readonly processResources: readonly string[];
|
||||
readonly error: ProviderCleanupError;
|
||||
/** The supervisor that can retry stopping the process tree, when one exists. */
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
@@ -266,12 +267,10 @@ const commandFailure = (
|
||||
if (!(cause instanceof OwnedCommandFailure))
|
||||
return {
|
||||
kind: "failed",
|
||||
error: request.signal.aborted
|
||||
? new AnalysisCancelledError(operation, { cause })
|
||||
: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
cause,
|
||||
userMessage: `Cannot run ${request.installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
error: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
cause,
|
||||
userMessage: `Cannot run ${request.installation.executable}: ${message(cause)}`,
|
||||
}),
|
||||
};
|
||||
const snapshot = cause.snapshot;
|
||||
const capturedOutput =
|
||||
@@ -297,53 +296,48 @@ const commandFailure = (
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
};
|
||||
let primary: AnalysisError;
|
||||
if (cause.reason === "cancelled")
|
||||
primary = new AnalysisCancelledError(operation, {
|
||||
...details,
|
||||
cause: cause.cancellation?.reason,
|
||||
});
|
||||
else if (cause.reason === "timeout")
|
||||
primary = new AnalysisTimeoutError(
|
||||
operation,
|
||||
request.reportedTimeoutMs,
|
||||
details,
|
||||
);
|
||||
// The same input overflows the same fixed buffer; waiting cannot help.
|
||||
else if (cause.reason === "output-limit")
|
||||
primary = new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
`kuna ${subcommand} wrote more than ${String(request.outputBytes)} bytes of output.`,
|
||||
{ output_limit_bytes: request.outputBytes },
|
||||
{
|
||||
...details,
|
||||
remediationAction: `REA buffers at most ${String(request.outputBytes)} bytes of kuna ${subcommand} output, so repeating this request for this target fails the same way. Use another provider for it.`,
|
||||
},
|
||||
);
|
||||
else {
|
||||
const exitCode = capturedOutput?.exit_code;
|
||||
primary = new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) || cause.message}`,
|
||||
});
|
||||
}
|
||||
if (cause.cleanupFailure !== null)
|
||||
return {
|
||||
kind: "retained",
|
||||
processResources: cause.resources,
|
||||
error: new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[...cause.resources, request.workspace],
|
||||
{ reason: cause.cleanupFailure },
|
||||
{ operation, cause },
|
||||
{ ...details, operation, cleanupPrimary: primary },
|
||||
),
|
||||
owner: cause.cleanupOwner,
|
||||
};
|
||||
if (cause.reason === "cancelled")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisCancelledError(operation, details),
|
||||
};
|
||||
if (cause.reason === "timeout")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisTimeoutError(
|
||||
operation,
|
||||
request.reportedTimeoutMs,
|
||||
details,
|
||||
),
|
||||
};
|
||||
// The same target and executable always produce the same output, so a
|
||||
// fixed buffer it overflows is a memory bound, not a busy admission queue.
|
||||
if (cause.reason === "output-limit")
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
`kuna ${subcommand} wrote more than ${String(request.outputBytes)} bytes of output.`,
|
||||
{ output_limit_bytes: request.outputBytes },
|
||||
{
|
||||
...details,
|
||||
remediationAction: `REA buffers at most ${String(request.outputBytes)} bytes of kuna ${subcommand} output, so repeating this request for this target fails the same way. Use another provider for it.`,
|
||||
},
|
||||
),
|
||||
};
|
||||
const exitCode = capturedOutput?.exit_code;
|
||||
return {
|
||||
kind: "failed",
|
||||
error: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
|
||||
...details,
|
||||
userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) || cause.message}`,
|
||||
}),
|
||||
};
|
||||
return { kind: "failed", error: primary };
|
||||
};
|
||||
+31
-39
@@ -4,7 +4,7 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js";
|
||||
import { matchRegexValues } from "../process/RegexMatcher.js";
|
||||
import type { KunaJsonDocumentReader } from "./KunaJsonStream.js";
|
||||
import { KUNA_DIAGNOSTIC_BYTES } from "./KunaLimits.js";
|
||||
@@ -16,12 +16,9 @@ import type {
|
||||
} from "./KunaTargetCommands.js";
|
||||
|
||||
/** One shared producer; individual callers own only their wait. */
|
||||
export interface KunaPendingQuery<T> {
|
||||
readonly promise: Promise<Result<T, AnalysisError>>;
|
||||
readonly controller: AbortController;
|
||||
waiters: number;
|
||||
settled: boolean;
|
||||
}
|
||||
export type KunaPendingQuery<T> = SharedStartupAttempt<
|
||||
Result<T, AnalysisError>
|
||||
>;
|
||||
|
||||
type RegexQuery = Omit<Parameters<typeof matchRegexValues>[0], "values"> & {
|
||||
readonly values: () => readonly string[];
|
||||
@@ -47,13 +44,21 @@ export class KunaQueryWork {
|
||||
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
||||
const local = await this.#nativeSlots.acquire(request.signal);
|
||||
if (local === undefined)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(request.operation, {
|
||||
cause: request.signal.reason,
|
||||
}),
|
||||
);
|
||||
let release: (() => void) | undefined;
|
||||
try {
|
||||
const foreground = await this.lifetime.runSlots.acquire(request.signal);
|
||||
if (foreground === undefined) {
|
||||
local();
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(request.operation, {
|
||||
cause: request.signal.reason,
|
||||
}),
|
||||
);
|
||||
}
|
||||
release = () => {
|
||||
foreground();
|
||||
@@ -119,7 +124,9 @@ export class KunaQueryWork {
|
||||
): ReturnType<typeof matchRegexValues> {
|
||||
const release = await this.#regexSlots.acquire(signal);
|
||||
if (release === undefined)
|
||||
return err(new AnalysisCancelledError(input.operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(input.operation, { cause: signal.reason }),
|
||||
);
|
||||
try {
|
||||
return await matchRegexValues({
|
||||
...input,
|
||||
@@ -135,22 +142,15 @@ export class KunaQueryWork {
|
||||
load: (signal: AbortSignal) => Promise<Result<T, AnalysisError>>,
|
||||
accept: (value: T) => void,
|
||||
): KunaPendingQuery<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = AbortSignal.any([this.lifetime.signal, controller.signal]);
|
||||
const pending: KunaPendingQuery<T> = {
|
||||
promise: this.lifetime.own(load(signal)),
|
||||
controller,
|
||||
settled: false,
|
||||
waiters: 0,
|
||||
};
|
||||
const pending = new SharedStartupAttempt(
|
||||
(signal) => this.lifetime.own(load(signal)),
|
||||
this.lifetime.signal,
|
||||
);
|
||||
void pending.promise.then(
|
||||
(result) => {
|
||||
pending.settled = true;
|
||||
if (result.ok && !signal.aborted) accept(result.value);
|
||||
},
|
||||
() => {
|
||||
pending.settled = true;
|
||||
if (result.ok && !pending.signal.aborted) accept(result.value);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
@@ -160,21 +160,13 @@ export class KunaQueryWork {
|
||||
signal: AbortSignal | undefined,
|
||||
operation: string,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
pending.waiters += 1;
|
||||
try {
|
||||
const result = await waitForAbortable(pending.promise, signal);
|
||||
if (result === ABORTED && pending.waiters === 1 && !pending.settled) {
|
||||
pending.controller.abort();
|
||||
const stopped = await pending.promise;
|
||||
if (!stopped.ok && !(stopped.error instanceof AnalysisCancelledError))
|
||||
return stopped;
|
||||
}
|
||||
return result === ABORTED
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
: result;
|
||||
} finally {
|
||||
pending.waiters -= 1;
|
||||
if (pending.waiters === 0 && !pending.settled) pending.controller.abort();
|
||||
}
|
||||
const waited = await pending.wait(signal);
|
||||
if (waited.status === "completed") return waited.value;
|
||||
// Final departure retains all producer failures, including a typed
|
||||
// cancellation with captured facts or incomplete cleanup.
|
||||
if (waited.status === "stopped" && !waited.value.ok) return waited.value;
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export class KunaReferenceQueries {
|
||||
): Promise<Result<KunaCallGraph, AnalysisError>> {
|
||||
if (this.#graphFailure !== undefined) return err(this.#graphFailure);
|
||||
if (this.#graph === undefined) {
|
||||
if (this.#pendingGraph?.controller.signal.aborted === true) {
|
||||
if (this.#pendingGraph?.signal.aborted === true) {
|
||||
const retired = await this.work.wait(
|
||||
this.#pendingGraph,
|
||||
signal,
|
||||
@@ -102,7 +102,9 @@ export class KunaReferenceQueries {
|
||||
if (!retired.ok && !(retired.error instanceof AnalysisCancelledError))
|
||||
return retired;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
);
|
||||
return this.#loadGraph(installation, operation, signal);
|
||||
}
|
||||
if (this.#pendingGraph === undefined) {
|
||||
@@ -225,12 +227,14 @@ export class KunaReferenceQueries {
|
||||
return ok(cached);
|
||||
}
|
||||
let pending = this.#pendingXrefs.get(address);
|
||||
if (pending?.controller.signal.aborted === true) {
|
||||
if (pending?.signal.aborted === true) {
|
||||
const retired = await this.work.wait(pending, signal, "xrefs");
|
||||
if (!retired.ok && !(retired.error instanceof AnalysisCancelledError))
|
||||
return retired;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("xrefs"));
|
||||
return err(
|
||||
new AnalysisCancelledError("xrefs", { cause: signal?.reason }),
|
||||
);
|
||||
return this.incoming(installation, address, signal);
|
||||
}
|
||||
if (pending === undefined) {
|
||||
|
||||
@@ -402,7 +402,11 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
if (!answer.ok)
|
||||
return err(kunaQueryFailureForCaller(answer.error, operation));
|
||||
if (cancelled(options.signal))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: options.signal?.reason,
|
||||
}),
|
||||
);
|
||||
return ok(
|
||||
this.#execution(
|
||||
kunaLimitations(operation),
|
||||
@@ -480,7 +484,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
),
|
||||
operation: "close_binary",
|
||||
},
|
||||
),
|
||||
).bindCleanupDependencies(failures),
|
||||
);
|
||||
this.#releasePin?.();
|
||||
this.#releasePin = undefined;
|
||||
@@ -1006,7 +1010,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SingleFunctionOutcome> {
|
||||
if (cancelled(signal))
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
return Promise.resolve(
|
||||
err(new AnalysisCancelledError(operation, { cause: signal?.reason })),
|
||||
);
|
||||
const kept = this.#singleFunctionTexts.get(address);
|
||||
if (kept !== undefined) {
|
||||
this.#singleFunctionTexts.delete(address);
|
||||
@@ -1050,13 +1056,17 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
if (!waiting) return;
|
||||
leave();
|
||||
if (shared.waiters > 0) {
|
||||
resolve(err(new AnalysisCancelledError(operation)));
|
||||
resolve(
|
||||
err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A run whose waiters all left winds down; a new request starts afresh.
|
||||
if (this.#singleFunctionRuns.get(address) === shared)
|
||||
this.#singleFunctionRuns.delete(address);
|
||||
shared.controller.abort();
|
||||
shared.controller.abort(signal?.reason);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
void shared.outcome.then((outcome) => {
|
||||
@@ -1100,7 +1110,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
const admitted = await this.#admitSingleFunction(signal);
|
||||
if (admitted === EXPORT_READY) return admitted;
|
||||
if (admitted === undefined)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
const run = await this.#commands.run({
|
||||
installation,
|
||||
arguments: (input) => ["decompile", input, address, "--addr"],
|
||||
|
||||
@@ -41,7 +41,9 @@ export class KunaStringQueries {
|
||||
>
|
||||
> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
);
|
||||
const query =
|
||||
operation === "search_strings"
|
||||
? analysisSearchQuerySchema.safeParse(parameters)
|
||||
@@ -68,7 +70,9 @@ export class KunaStringQueries {
|
||||
if (!inventory.ok)
|
||||
return err(kunaQueryFailureForCaller(inventory.error, operation));
|
||||
if (this.#cancelled(signal))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
);
|
||||
const available = inventory.value.items;
|
||||
let items: KunaStringItem[] | undefined;
|
||||
if (address !== undefined)
|
||||
@@ -114,12 +118,14 @@ export class KunaStringQueries {
|
||||
): Promise<Result<KunaStringInventory, AnalysisError>> {
|
||||
if (this.#inventory !== undefined) return ok(this.#inventory);
|
||||
if (this.#failure !== undefined) return err(this.#failure);
|
||||
if (this.#pending?.controller.signal.aborted === true) {
|
||||
if (this.#pending?.signal.aborted === true) {
|
||||
const stopped = await this.work.wait(this.#pending, signal, operation);
|
||||
if (!stopped.ok && !(stopped.error instanceof AnalysisCancelledError))
|
||||
return stopped;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal?.reason }),
|
||||
);
|
||||
return this.#load(installation, operation, signal);
|
||||
}
|
||||
if (this.#pending === undefined) {
|
||||
|
||||
@@ -13,10 +13,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.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,
|
||||
ProviderProcessSupervisor,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import {
|
||||
copyAndHash,
|
||||
runKunaCommand,
|
||||
@@ -26,12 +23,18 @@ 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 } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
|
||||
interface Workspace {
|
||||
interface WorkspaceRoot {
|
||||
readonly root: PrivateRuntimeRoot;
|
||||
readonly cleanup: AnalysisCleanupLease;
|
||||
}
|
||||
interface Workspace extends WorkspaceRoot {
|
||||
readonly input: string;
|
||||
}
|
||||
|
||||
@@ -58,11 +61,11 @@ export class KunaTargetCommands {
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
readonly #retained: {
|
||||
readonly error: ProviderCleanupError;
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
readonly releaseRun: KunaRunRelease | undefined;
|
||||
readonly process: AnalysisCleanupLease;
|
||||
}[] = [];
|
||||
readonly #roots: PrivateRuntimeRoot[] = [];
|
||||
readonly #roots: WorkspaceRoot[] = [];
|
||||
#workspace: Promise<Result<Workspace, AnalysisError>> | undefined;
|
||||
#closeAttempt: Promise<Result<null, ProviderCleanupError>> | undefined;
|
||||
#closing = false;
|
||||
|
||||
constructor(
|
||||
@@ -127,11 +130,17 @@ export class KunaTargetCommands {
|
||||
},
|
||||
});
|
||||
if (outcome.kind === "retained") {
|
||||
this.#retained.push({
|
||||
error: outcome.error,
|
||||
owner: outcome.owner,
|
||||
releaseRun,
|
||||
});
|
||||
const process = new AnalysisCleanupLease(async () => {
|
||||
const stopped = await outcome.owner?.stop();
|
||||
if (stopped === undefined || stopped.status === "incomplete")
|
||||
throw new Error("Kuna process stop remains 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);
|
||||
}
|
||||
@@ -152,8 +161,15 @@ export class KunaTargetCommands {
|
||||
}
|
||||
|
||||
/** Called after the session signal aborts; failures keep their retry owners. */
|
||||
async close(): Promise<Result<null, ProviderCleanupError>> {
|
||||
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();
|
||||
@@ -172,34 +188,33 @@ export class KunaTargetCommands {
|
||||
);
|
||||
}
|
||||
for (const retained of [...this.#retained]) {
|
||||
const stopped = await retained.owner?.stop();
|
||||
if (stopped !== undefined && stopped.status !== "incomplete") {
|
||||
retained.releaseRun?.();
|
||||
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.root);
|
||||
this.#roots.push(workspace.value);
|
||||
this.#workspace = undefined;
|
||||
}
|
||||
for (const root of [...this.#roots])
|
||||
for (const owned of [...this.#roots])
|
||||
try {
|
||||
await root.close();
|
||||
this.#roots.splice(this.#roots.indexOf(root), 1);
|
||||
await owned.cleanup.release();
|
||||
this.#roots.splice(this.#roots.indexOf(owned), 1);
|
||||
} catch (cause: unknown) {
|
||||
failures.push(
|
||||
new ProviderCleanupError(
|
||||
KUNA_PROVIDER_ID,
|
||||
[root.path],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
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
|
||||
@@ -221,7 +236,7 @@ export class KunaTargetCommands {
|
||||
"Kuna command cleanup remains incomplete",
|
||||
),
|
||||
},
|
||||
),
|
||||
).bindCleanupDependencies(failures),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,6 +265,10 @@ export class KunaTargetCommands {
|
||||
async #ensureWorkspace(): Promise<Result<Workspace, AnalysisError>> {
|
||||
this.#workspace ??= (async () => {
|
||||
const root = await PrivateRuntimeRoot.create({ prefix: "rea-kuna-" });
|
||||
const owned = {
|
||||
root,
|
||||
cleanup: new AnalysisCleanupLease(() => root.close(), [root.path]),
|
||||
};
|
||||
try {
|
||||
const input = join(root.path, basename(this.target.path));
|
||||
const digest = await copyAndHash(
|
||||
@@ -258,7 +277,7 @@ export class KunaTargetCommands {
|
||||
this.lifetime.signal,
|
||||
);
|
||||
if (digest !== this.target.sha256) {
|
||||
await this.#release(root);
|
||||
await this.#release(owned);
|
||||
return err(
|
||||
new AnalysisArtifactChangedError(
|
||||
"kuna:workspace",
|
||||
@@ -268,9 +287,9 @@ export class KunaTargetCommands {
|
||||
);
|
||||
}
|
||||
await prepareProcessOwnershipInspection(this.lifetime.signal);
|
||||
return ok({ root, input: await realpath(input) });
|
||||
return ok({ ...owned, input: await realpath(input) });
|
||||
} catch (cause: unknown) {
|
||||
await this.#release(root);
|
||||
await this.#release(owned);
|
||||
throw cause;
|
||||
}
|
||||
})().catch((cause: unknown) =>
|
||||
@@ -286,11 +305,11 @@ export class KunaTargetCommands {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #release(root: PrivateRuntimeRoot): Promise<void> {
|
||||
async #release(owned: WorkspaceRoot): Promise<void> {
|
||||
try {
|
||||
await root.close();
|
||||
await owned.cleanup.release();
|
||||
} catch {
|
||||
this.#roots.push(root);
|
||||
this.#roots.push(owned);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, it as test, vi } from "vitest";
|
||||
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { fakeKunaCalls } from "./fakeKuna.js";
|
||||
import { session, unprovenCleanupLauncher } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it.each([
|
||||
null,
|
||||
"selected reason",
|
||||
new ProviderCleanupError("foreign", ["foreign-owner"], {}),
|
||||
])(
|
||||
"keeps the exact last string waiter's command cancellation reason: %j",
|
||||
async (reason) => {
|
||||
const fixture = await session("rea-kuna-string-reason-", {
|
||||
strings: "hang",
|
||||
});
|
||||
const client = fixture.open();
|
||||
const caller = new AbortController();
|
||||
const pending = client.execute(
|
||||
"list_strings",
|
||||
{},
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await fakeKunaCalls(fixture.kuna)).some(
|
||||
({ argv }) => argv[0] === "strings",
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBe(true);
|
||||
caller.abort(reason);
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.capturedOutput).toBeDefined();
|
||||
} finally {
|
||||
caller.abort(reason);
|
||||
await pending;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["peer", "last"] as const)(
|
||||
"retains the exact %s single-function waiter reason",
|
||||
async (mode) => {
|
||||
const fixture = await session("rea-kuna-function-reason-", {
|
||||
project: "hang",
|
||||
decompile: "hang",
|
||||
});
|
||||
const client = fixture.open();
|
||||
const caller = new AbortController();
|
||||
const reason = { caller: "function" };
|
||||
const pending = client.execute(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main" },
|
||||
{ signal: caller.signal },
|
||||
);
|
||||
const peer =
|
||||
mode === "peer"
|
||||
? client.execute("procedure_pseudo_code", { procedure: "main" })
|
||||
: undefined;
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await fakeKunaCalls(fixture.kuna)).some(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.toBe(true);
|
||||
caller.abort(reason);
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(
|
||||
(await fakeKunaCalls(fixture.kuna)).filter(
|
||||
({ argv }) => argv[0] === "decompile",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
caller.abort(reason);
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
await Promise.all([pending, peer]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation output and primary reason while exact command cleanup is retried", async () => {
|
||||
const unproven = unprovenCleanupLauncher("strings");
|
||||
const fixture = await session(
|
||||
"rea-kuna-query-cleanup-reason-",
|
||||
{ strings: "hang" },
|
||||
{},
|
||||
unproven.launcher,
|
||||
);
|
||||
const client = fixture.open();
|
||||
const caller = new AbortController();
|
||||
const reason = "selected cleanup reason";
|
||||
const createRoot = PrivateRuntimeRoot.create;
|
||||
let workspace: PrivateRuntimeRoot | undefined;
|
||||
const roots = vi
|
||||
.spyOn(PrivateRuntimeRoot, "create")
|
||||
.mockImplementation(async (options) => {
|
||||
const root = await createRoot(options);
|
||||
if (options?.prefix === "rea-kuna-") workspace = root;
|
||||
return root;
|
||||
});
|
||||
let refuseRootClose: ReturnType<typeof vi.spyOn> | undefined;
|
||||
const pending = client.execute("list_strings", {}, { signal: caller.signal });
|
||||
try {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await fakeKunaCalls(fixture.kuna)).some(
|
||||
({ argv }) => argv[0] === "strings",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
caller.abort(reason);
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected retained cleanup");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
});
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
expect(result.error.capturedOutput).toBeDefined();
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
if (workspace === undefined) throw new Error("Missing owned workspace");
|
||||
refuseRootClose = vi
|
||||
.spyOn(workspace, "close")
|
||||
.mockRejectedValue(new Error("controlled workspace close refusal"));
|
||||
unproven.release();
|
||||
const [rootFailure, concurrentFailure] = await Promise.all([
|
||||
client.close(),
|
||||
client.close(),
|
||||
]);
|
||||
expect(rootFailure.ok).toBe(false);
|
||||
if (rootFailure.ok) throw new Error("Expected workspace cleanup failure");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual([workspace.path]);
|
||||
expect(refuseRootClose).toHaveBeenCalledTimes(1);
|
||||
refuseRootClose.mockRestore();
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(rootFailure.error.cleanupIncomplete).toBe(false);
|
||||
if (concurrentFailure.ok)
|
||||
throw new Error("Expected concurrent cleanup failure");
|
||||
expect(concurrentFailure.error.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
} finally {
|
||||
caller.abort(reason);
|
||||
unproven.release();
|
||||
roots.mockRestore();
|
||||
refuseRootClose?.mockRestore();
|
||||
await pending;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { KunaQueryWork } from "../../../../src/kuna/KunaQueryWork.js";
|
||||
import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js";
|
||||
import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../../../../src/domain/result.js";
|
||||
import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js";
|
||||
|
||||
// These controls exercise shared wait semantics without executing commands or opening files.
|
||||
const fixture = () => {
|
||||
const session = new AbortController();
|
||||
const work = new KunaQueryWork(
|
||||
new KunaTargetCommands(
|
||||
{
|
||||
path: "/unused-query-wait-target",
|
||||
sha256: "unused",
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
{ signal: session.signal },
|
||||
),
|
||||
{
|
||||
signal: session.signal,
|
||||
own: <T>(pending: Promise<T>) => pending,
|
||||
runSlots: new KunaRunSlots(1),
|
||||
},
|
||||
);
|
||||
return { session, work };
|
||||
};
|
||||
|
||||
it.each([
|
||||
null,
|
||||
"caller stopped",
|
||||
new ProviderCleanupError("foreign", ["foreign-owner"], {}),
|
||||
])(
|
||||
"keeps exact peer/final reasons without adopting foreign cleanup: %j",
|
||||
async (reason) => {
|
||||
const { work } = fixture();
|
||||
const pending = work.start<string>(
|
||||
(signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
resolve(
|
||||
err(
|
||||
new AnalysisCancelledError("producer", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
() => {},
|
||||
);
|
||||
const first = new AbortController(),
|
||||
last = new AbortController();
|
||||
const leaving = work.wait(pending, first.signal, "search_strings");
|
||||
const staying = work.wait(pending, last.signal, "list_strings");
|
||||
first.abort(reason);
|
||||
const peer = await leaving;
|
||||
if (peer.ok) throw new Error("Expected peer cancellation");
|
||||
expect(peer.error.cause).toBe(reason);
|
||||
expect(peer.error.cleanupIncomplete).toBe(false);
|
||||
expect(pending.signal.aborted).toBe(false);
|
||||
last.abort(reason);
|
||||
const final = await staying;
|
||||
if (final.ok) throw new Error("Expected final cancellation");
|
||||
expect(final.error.cause).toBe(reason);
|
||||
expect(final.error.cleanupIncomplete).toBe(false);
|
||||
expect(pending.signal.reason).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["physical", "cancel-with-cleanup"] as const)(
|
||||
"joins finalization and preserves the authored %s failure",
|
||||
async (kind) => {
|
||||
const { work } = fixture();
|
||||
let release!: (result: Result<string, AnalysisError>) => void;
|
||||
const output = { stdout: "completed output", stderr: "", truncated: false };
|
||||
const reason = { caller: "selected" };
|
||||
const primary =
|
||||
kind === "physical"
|
||||
? new AnalysisTimeoutError("producer", 17, { capturedOutput: output })
|
||||
: new AnalysisCancelledError("producer", {
|
||||
cause: reason,
|
||||
capturedOutput: output,
|
||||
cleanup: {
|
||||
reason: "controlled refusal",
|
||||
resources: ["owned-test-resource"],
|
||||
},
|
||||
});
|
||||
const pending = work.start<string>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
() => {},
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const waiting = work.wait(pending, controller.signal, "search_strings");
|
||||
let settled = false;
|
||||
void waiting.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
controller.abort(reason);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
expect(pending.signal.reason).toBe(reason);
|
||||
release(err(primary));
|
||||
const result = await waiting;
|
||||
if (result.ok) throw new Error("Expected producer failure");
|
||||
expect(result.error).toBe(primary);
|
||||
expect(result.error.capturedOutput).toEqual(output);
|
||||
if (kind === "cancel-with-cleanup") {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
primary.resolveCleanup();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not cache a producer success completed after final departure", async () => {
|
||||
const { work } = fixture();
|
||||
let release!: (result: Result<string, AnalysisError>) => void;
|
||||
let accepted = false;
|
||||
const pending = work.start<string>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
() => {
|
||||
accepted = true;
|
||||
},
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const waiting = work.wait(pending, controller.signal, "search_strings");
|
||||
controller.abort(null);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
release(ok("late result"));
|
||||
const result = await waiting;
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error.cause).toBe(null);
|
||||
expect(accepted).toBe(false);
|
||||
});
|
||||
Reference in new issue
Block a user