fix(errors): preserve caller abort reasons at admission (#5671)
* fix(errors): preserve caller abort reasons at admission Keep the opaque caller reason on reverse, pcap, and SQLite cancellation, and leave a recognized physical digest or command failure in place when a late abort is also observed. Fixes #5670 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(reverse): keep a foreign digest ABORT_ERR as an adapter failure Treat ABORT_ERR as cancellation on the command boundary, where that code already meant an aborted provider process. A digest or stat rejection with the same code stays a physical adapter failure unless the caller signal is aborted. Cover aborted command EACCES, ETIMEDOUT, and ABORT_ERR with and without captured output. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
6 files changed
+324
-10
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { PcapAnalysisService } from "./PcapAnalysisService.js";
|
||||
|
||||
it("preserves the exact caller reason when pcap admission is already aborted", async () => {
|
||||
const reason = Object.freeze({ requested: "stop" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let calls = 0;
|
||||
const service = new PcapAnalysisService({
|
||||
inspectAvailability: () => Promise.reject(new Error("unused")),
|
||||
close: () => Promise.resolve(),
|
||||
execute: () => {
|
||||
calls += 1;
|
||||
return Promise.reject(new Error("provider must not run"));
|
||||
},
|
||||
});
|
||||
const result = await service.execute(
|
||||
"inspect_pcap_client",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
@@ -24,7 +24,11 @@ export class PcapAnalysisService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
const request = pcapRequestSchema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
|
||||
@@ -250,7 +250,9 @@ export class ReverseEngineeringService {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (signal?.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
return Promise.resolve(
|
||||
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
|
||||
);
|
||||
if (this.#closing)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
@@ -311,7 +313,10 @@ export class ReverseEngineeringService {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const { executable: command, arguments: args } = profile;
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
const cleanup = await this.retryCleanup(operation);
|
||||
if (!cleanup.ok) return cleanup;
|
||||
const absolutePath =
|
||||
@@ -322,8 +327,12 @@ export class ReverseEngineeringService {
|
||||
digest = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch (cause) {
|
||||
const code = commandFailureCode(cause);
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation, { cause }));
|
||||
if (preflightCancelled(signal, cause, code))
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
}),
|
||||
);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
const deniedPath = absolutePath === undefined ? command : absolutePath;
|
||||
return err(
|
||||
@@ -464,9 +473,9 @@ export class ReverseEngineeringService {
|
||||
code === "ENOENT" ||
|
||||
code === "ERR_PROVIDER_LAUNCH")
|
||||
) {
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
if (commandCancelled(signal, cause, code))
|
||||
earlyFailure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
@@ -520,9 +529,9 @@ export class ReverseEngineeringService {
|
||||
: null,
|
||||
signal: capture.signal,
|
||||
};
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
if (commandCancelled(signal, cause, code))
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
@@ -605,6 +614,7 @@ export class ReverseEngineeringService {
|
||||
}
|
||||
if (signal?.aborted && failure === undefined) {
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
capturedOutput: {
|
||||
stdout,
|
||||
stderr,
|
||||
@@ -972,6 +982,48 @@ const digestFile = async (
|
||||
});
|
||||
};
|
||||
|
||||
const PHYSICAL_FAILURE_CODES = new Set([
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"ETIMEDOUT",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"EINVAL",
|
||||
"ELOOP",
|
||||
"ENAMETOOLONG",
|
||||
"E2BIG",
|
||||
"EBUSY",
|
||||
"ERR_PROVIDER_LAUNCH",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A late abort does not replace a recognized physical failure. Other rejections
|
||||
* observed while the caller signal is aborted remain cancellation. A bare
|
||||
* `ABORT_ERR` is not cancellation until the command boundary, which already
|
||||
* used that code for an aborted provider process.
|
||||
*/
|
||||
const preflightCancelled = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
code: string | number | undefined,
|
||||
): boolean => {
|
||||
if (cause instanceof AnalysisCancelledError) return true;
|
||||
if (signal?.aborted !== true) return false;
|
||||
if (cause === signal.reason) return true;
|
||||
return typeof code !== "string" || !PHYSICAL_FAILURE_CODES.has(code);
|
||||
};
|
||||
|
||||
const commandCancelled = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
code: string | number | undefined,
|
||||
): boolean => code === "ABORT_ERR" || preflightCancelled(signal, cause, code);
|
||||
|
||||
const callerCancellationCause = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
): unknown => (signal?.aborted === true ? signal.reason : cause);
|
||||
|
||||
const commandFailureCode = (cause: unknown): string | number | undefined => {
|
||||
if (typeof cause !== "object" || cause === null) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
|
||||
@@ -145,3 +145,25 @@ it("preserves completed observed evidence when cancellation arrives after provid
|
||||
value: { normalized_result: value },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact caller reason when inspection is already aborted", async () => {
|
||||
const reason = Object.freeze({ requested: "stop" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let calls = 0;
|
||||
const service = new SqliteDatabaseService({
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.reject(new Error("provider must not run"));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: "/selected.db" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
@@ -71,7 +71,11 @@ export class SqliteDatabaseService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
|
||||
@@ -101,3 +101,207 @@ it("bounds concurrent reverse-engineering provider calls", async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the exact caller reason when objdump admission is already aborted", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-reverse-preabort-"));
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, "fixture");
|
||||
const reason = Object.freeze({ requested: "stop" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let runs = 0;
|
||||
const service = new TestReverseEngineeringService({
|
||||
run: async () => {
|
||||
runs += 1;
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.inspectWithObjdump(
|
||||
{ path, operation: "symbols" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(runs).toBe(0);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the caller reason when cancellation arrives after provider output", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-reverse-postabort-"));
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, "fixture");
|
||||
const reason = Object.freeze({ requested: "stop-after-output" });
|
||||
const controller = new AbortController();
|
||||
const service = new TestReverseEngineeringService({
|
||||
run: async () => {
|
||||
controller.abort(reason);
|
||||
return { stdout: "symbols", stderr: "" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.inspectWithObjdump(
|
||||
{ path, operation: "symbols" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
raw_result: { stdout: "symbols" },
|
||||
});
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a physical digest denial primary when the caller aborts during that failure", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-reverse-digest-deny-"));
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, "fixture");
|
||||
const reason = Object.freeze({ requested: "stop-during-denial" });
|
||||
const controller = new AbortController();
|
||||
const denied = Object.assign(new Error("permission denied"), {
|
||||
code: "EACCES",
|
||||
});
|
||||
let runs = 0;
|
||||
const service = new TestReverseEngineeringService({
|
||||
digest: async () => {
|
||||
controller.abort(reason);
|
||||
throw denied;
|
||||
},
|
||||
run: async () => {
|
||||
runs += 1;
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.inspectWithObjdump(
|
||||
{ path, operation: "symbols" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisAccessDeniedError" },
|
||||
});
|
||||
if (result.ok) return;
|
||||
expect(result.error.cause).toBe(denied);
|
||||
expect(runs).toBe(0);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an unrelated digest ABORT_ERR as an adapter failure", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-reverse-digest-abort-"));
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, "fixture");
|
||||
const denied = Object.assign(new Error("digest aborted elsewhere"), {
|
||||
code: "ABORT_ERR",
|
||||
});
|
||||
const service = new TestReverseEngineeringService({
|
||||
digest: async () => {
|
||||
throw denied;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.inspectWithObjdump({
|
||||
path,
|
||||
operation: "symbols",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ProviderAdapterError" },
|
||||
});
|
||||
if (result.ok) return;
|
||||
expect(result.error.cause).toBe(denied);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
captured: true,
|
||||
code: "EACCES",
|
||||
tag: "AnalysisAccessDeniedError",
|
||||
},
|
||||
{
|
||||
captured: true,
|
||||
code: "ETIMEDOUT",
|
||||
tag: "AnalysisTimeoutError",
|
||||
},
|
||||
{
|
||||
captured: true,
|
||||
code: "ABORT_ERR",
|
||||
tag: "AnalysisCancelledError",
|
||||
},
|
||||
{
|
||||
captured: false,
|
||||
code: "EACCES",
|
||||
tag: "AnalysisAccessDeniedError",
|
||||
},
|
||||
{
|
||||
captured: false,
|
||||
code: "ETIMEDOUT",
|
||||
tag: "AnalysisTimeoutError",
|
||||
},
|
||||
{
|
||||
captured: false,
|
||||
code: "ABORT_ERR",
|
||||
tag: "AnalysisCancelledError",
|
||||
},
|
||||
] as const)(
|
||||
"classifies a $code command failure with captured output $captured while the caller is aborted",
|
||||
async ({ captured, code, tag }) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-reverse-command-abort-"));
|
||||
const path = join(directory, "fixture.bin");
|
||||
await writeFile(path, "fixture");
|
||||
const reason = Object.freeze({ requested: `stop-${code}` });
|
||||
const controller = new AbortController();
|
||||
const failure = Object.assign(new Error("provider failure"), {
|
||||
code,
|
||||
...(captured
|
||||
? { stdout: "partial stdout", stderr: "partial stderr" }
|
||||
: {}),
|
||||
});
|
||||
const service = new TestReverseEngineeringService({
|
||||
run: async () => {
|
||||
controller.abort(reason);
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.inspectWithObjdump(
|
||||
{ path, operation: "symbols" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { _tag: tag } });
|
||||
if (result.ok) return;
|
||||
expect(result.error.cause).toBe(
|
||||
code === "ABORT_ERR" ? reason : failure,
|
||||
);
|
||||
if (captured && code === "ABORT_ERR") {
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
});
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
raw_result: { stdout: "partial stdout" },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user