From 48af28bf71223e3db549a8a8a497d2e2a3d0f5a5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:04:36 +0800 Subject: [PATCH] fix(tracing): retain caller reasons at traversal checkpoints --- src/application/CallPathTracing.test.ts | 54 +++++++++++++++++++++++++ src/application/CallPathTracing.ts | 16 ++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/application/CallPathTracing.test.ts diff --git a/src/application/CallPathTracing.test.ts b/src/application/CallPathTracing.test.ts new file mode 100644 index 0000000000..927057a187 --- /dev/null +++ b/src/application/CallPathTracing.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { traceCallPath } from "./CallPathTracing.js"; +import { createAnalysisExecution } from "./AnalysisProvider.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { err, ok } from "../domain/result.js"; + +const input = { start: "entry", direction: "forward" as const }; +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +describe("call-path traversal cancellation ownership", () => { + for (const phase of ["selection", "relationship"] as const) + for (const reason of [null, { requested: "stop after provider reply" }]) + it(`preserves the exact reason at the ${phase} continuation (${reason === null ? "null" : "object"})`, async () => { + const controller = new AbortController(); + const result = await traceCallPath( + async () => { + if (phase === "selection") controller.abort(reason); + return ok("0x1000"); + }, + async () => { + controller.abort(reason); + return ok(createAnalysisExecution([], provider)); + }, + input, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + }); + + it("preserves a selected provider failure when cancellation arrives with its reply", async () => { + const controller = new AbortController(); + const failure = new AnalysisOutputError( + "procedure_callees", + "invalid fixture reply", + ); + const result = await traceCallPath( + async () => ok("0x1000"), + async () => { + controller.abort({ requested: "stop" }); + return err(failure); + }, + input, + controller.signal, + ); + expect(result).toEqual(err(failure)); + }); +}); diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index 1fd6807b3d..1fc9258577 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -98,14 +98,18 @@ export const traceCallPath = async ( direction: input.direction, }; if (isAborted(signal)) - return err(new AnalysisCancelledError("trace_call_path")); + return err( + new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }), + ); const state = createTraceState(resolvedInput); while ( state.queueIndex < state.queue.length && state.traversalPath.length === 0 ) { if (isAborted(signal)) - return err(new AnalysisCancelledError("trace_call_path")); + return err( + new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }), + ); const address = state.queue[state.queueIndex++]; if (address === undefined) break; const current = state.visited.get(address); @@ -121,7 +125,9 @@ export const traceCallPath = async ( if (expanded.value) break; } if (isAborted(signal)) - return err(new AnalysisCancelledError("trace_call_path")); + return err( + new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }), + ); return ok(projectTrace(resolvedInput, state)); }; @@ -155,7 +161,9 @@ const expandAddress = async ( return result; } if (isAborted(signal)) - return err(new AnalysisCancelledError("trace_call_path")); + return err( + new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }), + ); if (!result.ok) { state.failures.push({ address,