test(providers): preserve cleanup and uncertainty contracts

This commit is contained in:
morluto
2026-10-24 07:06:20 +08:00
parent f42ade1dda
commit 46a5f55f74
5 changed files with 98 additions and 55 deletions
@@ -12,6 +12,7 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { Evidence } from "../../domain/evidence.js";
import { ok } from "../../domain/result.js";
@@ -455,10 +456,16 @@ it.each([false, true])(
return cleanupFails
? {
ok: false,
error: new AnalysisCapabilityUnavailableError(
error: new ProviderAdapterError(
"frida",
"close_frida_session",
"session cleanup failed",
{
userMessage: "session cleanup failed",
cleanup: {
reason: "session cleanup failed",
resources: [createSession().sessionId],
},
},
),
}
: { ok: true, value: { targetResumeObserved: false } };
@@ -485,10 +492,14 @@ it.each([false, true])(
if (result.ok) return;
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
if (cleanupFails) {
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(primary);
expect(result.error.diagnostics).toEqual({
native_status: "resume_rejected",
expect(result.error).toBeInstanceOf(ProviderAdapterError);
expect(result.error.cleanupPrimary).toBe(primary);
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect((result.error.cause as AggregateError).errors[0]).toBe(primary);
expect(result.error.diagnostics).toMatchObject({
primary_error: {
details: { diagnostics: { native_status: "resume_rejected" } },
},
});
expect(result.error.cleanup).toMatchObject({
reason: expect.stringContaining("session cleanup failed"),
@@ -906,39 +917,56 @@ it("records target selection in Evidence without remote authentication values",
expect(parameters).not.toContain(certificate);
});
it("reports completed cleanup when closing the parent session releases a failed script", async () => {
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
return {
ok: false,
error: new AnalysisCapabilityUnavailableError(
"frida",
"load_frida_script",
"script load failed",
{
cleanup: {
reason: "script unload failed",
resources: ["script-1"],
},
},
),
};
it.each([false, true])(
"tracks the failed script owner after parent close (released=%s)",
async (released) => {
const scriptOwner = { cleanupResources: ["script-1"] };
const failure = new AnalysisCapabilityUnavailableError(
"frida",
"load_frida_script",
"script load failed",
{
cleanup: {
reason: "script unload failed",
resources: ["script-1"],
},
},
}),
);
);
failure.bindCleanupResourceOwner(scriptOwner);
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
return { ok: false, error: failure };
},
async closeSession() {
// The parent provider owns the script and confirms its release.
if (released) {
scriptOwner.cleanupResources = [];
failure.resolveCleanup();
}
return { ok: true, value: { targetResumeObserved: false } };
},
}),
);
const result = await service.instrument({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
});
const result = await service.instrument({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "inline", source: "send('ready');" },
durationMs: 1,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false);
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(!released);
expect(result.error.cleanupResources).toEqual(
released ? [] : ["script-1"],
);
}
},
);
it("cleans up a session and returns typed cancellation after a pending start", async () => {
let finishStart: (() => void) | undefined;
@@ -1080,10 +1108,16 @@ it.each([
return cleanupFails
? {
ok: false,
error: new AnalysisCapabilityUnavailableError(
error: new ProviderAdapterError(
"frida",
"close_frida_session",
"session cleanup failed",
{
userMessage: "session cleanup failed",
cleanup: {
reason: "session cleanup failed",
resources: [createSession().sessionId],
},
},
),
}
: { ok: true, value: { targetResumeObserved: false } };
@@ -1106,10 +1140,14 @@ it.each([
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
expect(controller.signal.reason).toBe(abortReason);
if (cleanupFails) {
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(primary);
expect(result.error.diagnostics).toEqual({
native_status: "compile_rejected",
expect(result.error).toBeInstanceOf(ProviderAdapterError);
expect(result.error.cleanupPrimary).toBe(primary);
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect((result.error.cause as AggregateError).errors[0]).toBe(primary);
expect(result.error.diagnostics).toMatchObject({
primary_error: {
details: { diagnostics: { native_status: "compile_rejected" } },
},
});
expect(result.error.cleanup).toMatchObject({
reason: expect.stringContaining("session cleanup failed"),
+3 -4
View File
@@ -279,10 +279,9 @@ test.each([true, false])(
if (cancel) {
controller.abort(reason);
const result = await pending;
expect(selected.mock.calls[0]?.[0]).toHaveProperty(
"signal",
controller.signal,
);
const selectedSignal = selected.mock.calls[0]?.[0].signal;
expect(selectedSignal?.aborted).toBe(true);
expect(selectedSignal?.reason).toBe(reason);
expect(inspected).not.toHaveBeenCalled();
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect((result.error as Error).cause).toBe(reason);
+1
View File
@@ -213,6 +213,7 @@ describe("headless IDA containing procedure", () => {
procedure: {
address: "0x1000",
name: "main",
symbol: null,
classification: null,
body: {
available: false,
+2 -1
View File
@@ -322,12 +322,13 @@ it("bounds a stalled health poll by the remaining readiness deadline", async ()
expect(pendingCalls).toBe(2);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(failure).toMatchObject({
operation: "server_health: headless worker still busy in config_json_get",
operation: "server_health",
timeoutMs,
userMessage: expect.stringContaining("800 ms"),
remediationAction: expect.stringContaining("timeoutMs"),
cause: busy,
diagnostics: {
busy_tool: "config_json_get",
health_request_operation: "server_health",
health_request_timeout_ms: 250,
},
+12 -8
View File
@@ -248,7 +248,7 @@ describe("identityless lineage refresh follows tree membership", () => {
expect(environment).toHaveBeenCalledWith(102);
});
it("rejects a cached foreign row after an ancestor joins the launcher", async () => {
it("keeps a tokenless cached row unverified after its ancestor joins the launcher", async () => {
let outsiderReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101 || pid === 103)
@@ -283,7 +283,8 @@ describe("identityless lineage refresh follows tree membership", () => {
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
reason:
"REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified",
});
expect(outsiderReads).toBe(2);
expect(environment.mock.calls.filter(([pid]) => pid === 50)).toHaveLength(
@@ -291,7 +292,7 @@ describe("identityless lineage refresh follows tree membership", () => {
);
});
it("re-reads a cached descendant when its relation is unchanged", async () => {
it("re-reads a cached descendant and keeps its missing token unverified", async () => {
const { environment, reads } = environmentForReplacementReads((read) =>
read === 1 ? "run-token" : undefined,
);
@@ -309,7 +310,8 @@ describe("identityless lineage refresh follows tree membership", () => {
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
reason:
"REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified",
});
expect(reads()).toBe(2);
});
@@ -399,7 +401,7 @@ describe("identityless lineage refresh rechecks the launcher", () => {
});
});
it("rejects a refresh whose launcher PID is no longer owned", async () => {
it("keeps a refreshed launcher with a missing ownership token unverified", async () => {
let launcherReads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101)
@@ -425,14 +427,15 @@ describe("identityless lineage refresh rechecks the launcher", () => {
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
reason:
"REA_PROCESS_RUN_ID was absent from the readable environment for PID 100; process lineage ownership remains unverified",
});
expect(launcherReads).toBe(2);
});
});
describe("identityless lineage refresh rechecks an initial descendant", () => {
it("rejects a reused PID after an earlier descendant token matched", async () => {
it("keeps a descendant with a missing token unverified after an earlier match", async () => {
let reads = 0;
const environment = vi.fn((pid: number) => {
if (pid === 101)
@@ -459,7 +462,8 @@ describe("identityless lineage refresh rechecks an initial descendant", () => {
}),
).resolves.toMatchObject({
status: "unavailable",
reason: "process lineage contains an unowned or PID-reused process",
reason:
"REA_PROCESS_RUN_ID was absent from the readable environment for PID 102; process lineage ownership remains unverified",
});
expect(reads).toBe(2);
});