From ac9709a80a999d21333837f47d8a88a0ce7d48c1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:21:28 +0800 Subject: [PATCH 1/8] perf(reconstruction): index semantic evidence contexts per derivation --- ...ReconstructionObligationCandidates.test.ts | 120 ++++++++++++++++++ .../ReconstructionObligationCandidates.ts | 19 ++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index ed3ac93cef..2d01d9c7ee 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -1,3 +1,11 @@ +import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { + createJavaScriptSemanticGraph, + createJavaScriptSemanticGraphNode, + JavaScriptSemanticEvidenceContextRegistry, +} from "../domain/javascript/javascriptSemanticGraph.js"; +import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js"; import { describe, expect, it } from "vitest"; import { createEvidence } from "../domain/evidence.js"; @@ -171,3 +179,115 @@ describe("reconstruction obligation candidates", () => { ); }); }); + +it("keeps each semantic candidate bound to its own file and authority context", () => { + const contexts = new JavaScriptSemanticEvidenceContextRegistry(); + const base = javascriptApplicationAnalysisResultSchema.parse( + JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result, + ); + const sourceRecords = [ + proofEvidence("first-file"), + proofEvidence("second-file"), + ]; + const module = createJavaScriptSemanticGraphNode( + { + kind: "module", + identity: { + artifact_sha256: base.root_artifact_sha256, + module_path: "app.js", + source_range: null, + role_key: "module", + }, + function_node_id: null, + application_node_ids: [], + label: "app.js", + properties: {}, + evidence: artifactEvidence(base.root_artifact_sha256, "app.js"), + }, + contexts, + ); + const requests = sourceRecords.flatMap((source, fileIndex) => + [1, 2].map((line) => { + const sha256 = String(fileIndex + 4).repeat(64); + const path = `file-${String(fileIndex)}.js`; + return createJavaScriptSemanticGraphNode( + { + kind: "request", + identity: { + artifact_sha256: sha256, + module_path: path, + source_range: { + start: { line, column: 0 }, + end: { line, column: 8 }, + }, + role_key: `request-${String(line)}`, + }, + function_node_id: null, + application_node_ids: [], + label: `${path}:${String(line)}`, + properties: {}, + evidence: { + ...artifactEvidence(sha256, path, "ast-static-analysis"), + evidence_ids: [source.evidence_id], + }, + }, + contexts, + ); + }), + ); + const { graph_id: _graphId, ...semantic } = base.semantic_graph; + const semanticGraph = createJavaScriptSemanticGraph({ + ...semantic, + root_node_ids: [module.node_id], + nodes: [module, ...requests], + evidence_contexts: contexts.contexts, + }); + const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE; + const application = createEvidence( + { + path: base.input_path, + sha256: base.root_artifact_sha256, + format: base.format, + }, + example.provider, + { + predicateType: example.predicate_type, + operation: example.operation, + parameters: example.parameters, + result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }), + confidence: "derived", + authority: "shipped-artifact", + evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id), + }, + ); + const generated = deriveReconstructionObligationCandidates( + createEvidenceBundle([...sourceRecords, application]), + [], + ); + expect(generated.limitations).toEqual([]); + expect(generated.candidates).toHaveLength(requests.length); + for (const [index, node] of requests.entries()) { + const source = sourceRecords[Math.floor(index / 2)]; + expect( + generated.candidates.find( + ({ target }) => target.semantic_node_id === node.node_id, + ), + ).toMatchObject({ + title: node.label, + family: "request", + source_state: "candidate", + target: { + artifact_sha256: node.identity.artifact_sha256, + semantic_node_id: node.node_id, + }, + authority_references: [ + { + evidence_id: source?.evidence_id, + authority: "controlled-replay", + state: "candidate", + location: `${semanticGraph.graph_id}/node/${node.node_id}`, + }, + ], + }); + } +}); diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index e099527698..60eaaed135 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -12,7 +12,6 @@ import type { ReconstructionObligation, ReviewedReconstructionObligation, } from "../domain/reconstructionObligationLedgerSchemas.js"; -import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js"; import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js"; import { applicationObligationPolicy, @@ -115,14 +114,21 @@ const deriveApplicationCandidates = ( }), ); } - if (source.semanticGraph !== null) + if (source.semanticGraph !== null) { + const evidenceContexts = new Map( + source.semanticGraph.evidence_contexts.map((context) => [ + context.context_id, + context, + ]), + ); for (const node of source.semanticGraph.nodes) { const candidatePolicy = semanticObligationPolicy(node.kind); if (candidatePolicy === undefined) continue; - const evidenceContext = resolveJavaScriptSemanticEvidence( - source.semanticGraph, - node.evidence, - ); + const evidenceContext = evidenceContexts.get(node.evidence.context_id); + if (evidenceContext === undefined) + throw new TypeError( + "Semantic evidence reference names an absent context", + ); addCandidate( candidates, generatedCandidate({ @@ -144,6 +150,7 @@ const deriveApplicationCandidates = ( }), ); } + } if (source.graph.coverage.status !== "complete") limitations.add( `Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`, From bd972bf80ba27f708916afb815523b80e29cfac6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:21:35 +0800 Subject: [PATCH 2/8] perf(javascript): reuse capture completeness during load classification --- .../javascript/javascriptRuntimeLoadState.ts | 11 +--- .../javascriptRuntimeReconciliation.test.ts | 63 ++++++++++++++++--- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 58cf9aeef0..838f606f64 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -62,9 +62,7 @@ const classifyLayer = ( options.reconciliationComplete && layer.graph.coverage.status === "complete" && scopedEntities.length > 0 && - scopedEntities.every(({ capture }) => - captureScriptsComplete(capture.evidence.evidence_id, runtimeEntities), - ); + scopedEntities.every(({ capture }) => capture.scriptsCompleteWithinScope); const states = nodes.map((node) => { const runtimeNodeIds = uniqueSorted(direct.get(node.node_id) ?? []); if (runtimeNodeIds.length > 0) @@ -134,13 +132,6 @@ const append = ( else current.push(value); }; -const captureScriptsComplete = ( - evidenceId: string, - entities: readonly RuntimeReconciliationEntity[], -): boolean => - entities.find(({ capture }) => capture.evidence.evidence_id === evidenceId) - ?.capture.scriptsCompleteWithinScope === true; - const isLoadStateNode = ( node: ApplicationNode, ): node is ApplicationNode & { diff --git a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts index ec59b35c7c..9e90a1da4d 100644 --- a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts +++ b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts @@ -186,6 +186,40 @@ it("matches renderer, frame, script bytes, and worker without claiming execution ); }); +it("keeps static load states unknown when one scoped capture omits scripts", async () => { + const fixture = await applicationFixture(); + const staticEvidence = await analyzeFixture(fixture); + const result = reconcileInput({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [ + electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + targetId: "target-complete", + }), + electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + scriptsUnavailable: true, + targetId: "target-incomplete", + }), + ], + }); + + expect( + result.runtime_captures.map( + ({ scripts_complete_within_scope }) => scripts_complete_within_scope, + ), + ).toEqual(expect.arrayContaining([false, true])); + expect(result.summary.static_not_observed).toBe(0); + expect(result.static_load_states).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "unknown", + reason: "static-or-runtime-coverage-incomplete", + }), + ]), + ); +}); + it("reports a captured digest disagreement instead of accepting a path match", async () => { const fixture = await applicationFixture(); const staticEvidence = await analyzeFixture(fixture); @@ -420,6 +454,7 @@ const electronRuntimeEvidence = ( readonly targetId?: string; readonly sourceIncluded?: boolean; readonly workersUnavailable?: boolean; + readonly scriptsUnavailable?: boolean; } = {}, ) => { const scriptFile = options.scriptFile ?? "app.js"; @@ -455,15 +490,7 @@ const electronRuntimeEvidence = ( ended_at: "2026-07-15T00:00:00.100Z", observation_ms: 100, }, - completeness: options.workersUnavailable - ? { - ...completeCapture(), - status: "attach_limited" as const, - conditions: ["attach_limited" as const], - attach_limited_sections: ["workers" as const], - unavailable_sections: ["workers" as const], - } - : completeCapture(), + completeness: captureCompleteness(options), frames: [ { frame_id: "frame-main", @@ -532,3 +559,21 @@ const completeCapture = () => ({ total: 0, }, }); + +const captureCompleteness = (options: { + readonly workersUnavailable?: boolean; + readonly scriptsUnavailable?: boolean; +}) => { + const unavailableSections = [ + ...(options.scriptsUnavailable ? (["scripts"] as const) : []), + ...(options.workersUnavailable ? (["workers"] as const) : []), + ]; + if (unavailableSections.length === 0) return completeCapture(); + return { + ...completeCapture(), + status: "attach_limited" as const, + conditions: ["attach_limited" as const], + attach_limited_sections: unavailableSections, + unavailable_sections: unavailableSections, + }; +}; From cef6d6d3c4f4dd8ac3b9b019707f1b01fc44b801 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:21:40 +0800 Subject: [PATCH 3/8] perf(artifacts): reuse trie membership for case-folded siblings --- src/artifacts/ArtifactPaths.test.ts | 33 +++++++++++ src/artifacts/ArtifactPaths.ts | 8 +-- .../artifactInventoryEntryOrder.test.ts | 55 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts index 4834cf2d4e..2300c63bbb 100644 --- a/src/artifacts/ArtifactPaths.test.ts +++ b/src/artifacts/ArtifactPaths.test.ts @@ -60,6 +60,30 @@ describe("English Unicode case collisions", () => { ]); }); + it("retains every spelling in a high-cardinality folded sibling group", () => { + const count = 8_192; + const variants = caseVariants("abcdefghijklm", count); + const { limitation, byPath } = annotate(variants); + const ordered = [...variants].sort(); + const first = ordered[0]; + const second = ordered[1]; + const original = variants[0]; + if (first === undefined || second === undefined || original === undefined) { + throw new Error("Expected at least two case variants"); + } + expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION); + expect(byPath.size).toBe(count); + expect(byPath.get(first)).toEqual([ + `Logical path ${first} collides under English (en-US) Unicode case folding with ${second} and ${String(count - 2)} other spellings; ${NOTE}`, + ]); + expect(byPath.get(original)?.[0]).toContain( + `with ${first} and ${String(count - 2)} other spellings;`, + ); + expect( + [...byPath.values()].every((limitations) => limitations.length === 1), + ).toBe(true); + }); + it("stays linear for large case-variant subtrees", () => { const count = 5_000; const paths = Array.from({ length: count }, (_, index) => [ @@ -81,3 +105,12 @@ describe("English Unicode case collisions", () => { expect(byPath.get(deep)).toEqual([]); }); }); + +const caseVariants = (lowercase: string, count: number): string[] => + Array.from({ length: count }, (_, mask) => + [...lowercase] + .map((character, index) => + (mask & (1 << index)) === 0 ? character : character.toUpperCase(), + ) + .join(""), + ); diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index 3950b4c6e2..ea81ea41fa 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -177,12 +177,12 @@ const englishUnicodeCaseCollisions = ( if (path.length === 0 || path === ".") continue; let node = root; for (const part of path.split("/")) { - const folded = englishCaseFold(part); - const spellings = node.spellingsByFold.get(folded); - if (spellings === undefined) node.spellingsByFold.set(folded, [part]); - else if (!spellings.includes(part)) spellings.push(part); let child = node.bySpelling.get(part); if (child === undefined) { + const folded = englishCaseFold(part); + const spellings = node.spellingsByFold.get(folded); + if (spellings === undefined) node.spellingsByFold.set(folded, [part]); + else spellings.push(part); child = emptyCaseNode(); node.bySpelling.set(part, child); } diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index 64c8e2f7c7..ad28301677 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -154,6 +154,61 @@ describe("entry-order validation and reader controls", () => { } }); + it("retains high-cardinality case-folded directories and their descendants", async () => { + const root = await createTestTempDirectory( + "rea-entry-order-case-capacity-", + ); + const archive = join(root, "case-capacity.zip"); + const variants = Array.from({ length: 256 }, (_, mask) => + [..."abcdefgh"] + .map((character, index) => + (mask & (1 << index)) === 0 ? character : character.toUpperCase(), + ) + .join(""), + ); + await writeOrderedZip( + archive, + variants.flatMap((variant) => [variant + "/", variant + "/entry.txt"]), + ); + + const inventory = await inventoryArtifact(archive); + const directories = variants.map((variant) => + artifactOccurrenceAt(inventory, variant), + ); + const descendants = variants.map((variant) => + artifactOccurrenceAt(inventory, `${variant}/entry.txt`), + ); + const ordered = [...variants].sort(); + const first = ordered[0]; + const second = ordered[1]; + + expect(inventory.occurrences).toHaveLength(1 + variants.length * 2); + expect( + new Set(inventory.occurrences.map(({ logical_path }) => logical_path)) + .size, + ).toBe(1 + variants.length * 2); + expect( + new Set( + [...directories, ...descendants].map( + ({ occurrence_id }) => occurrence_id, + ), + ).size, + ).toBe(variants.length * 2); + expect( + directories.every(({ limitations }) => limitations.length === 1), + ).toBe(true); + expect( + descendants.every(({ limitations }) => limitations.length === 1), + ).toBe(true); + expect( + directories.find(({ logical_path }) => logical_path === first) + ?.limitations[0], + ).toContain(`with ${second} and 254 other spellings;`); + expect(descendants[0]?.limitations[0]).toContain( + `is under ${variants[0]}, which collides under English (en-US) Unicode case folding with ${first} and 254 other spellings;`, + ); + }); + it("preserves ordinary directory and ASAR structure", async () => { const root = await createTestTempDirectory("rea-entry-order-readers-"); const directory = join(root, "source"); From 92a901e0dba5dde19f243eb603ccb65be64e1eab Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:21:50 +0800 Subject: [PATCH 4/8] perf(process): share settlement probes across captured groups --- .../ProcessOwnership.observation.test.ts | 169 +++++++++++++++++- .../ProcessOwnership.validation.test.ts | 26 ++- src/process/ProcessOwnershipObservation.ts | 67 ++++--- .../capture/ProcessCaptureLifecycle.ts | 21 ++- 4 files changed, 241 insertions(+), 42 deletions(-) diff --git a/src/process/ProcessOwnership.observation.test.ts b/src/process/ProcessOwnership.observation.test.ts index e29aa5965e..58085eb21a 100644 --- a/src/process/ProcessOwnership.observation.test.ts +++ b/src/process/ProcessOwnership.observation.test.ts @@ -4,7 +4,7 @@ import { type ProcessOwnershipHost, } from "./ProcessOwnership.js"; import { - observeOwnedProcessGroup, + observeOwnedProcessGroups, observeOwnedProcessLineage, } from "./ProcessOwnershipObservation.js"; import { host, ownership } from "./ProcessOwnership.fixture.js"; @@ -17,6 +17,158 @@ const processes = [100, 101].map((pid) => ({ command: "fixture", })); +describe("multi-group ownership observation", () => { + it("observes multiple groups from one process snapshot and token batch", async () => { + const rows = [ + ...processes, + { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "other-owned-group", + }, + { + pid: 300, + parentPid: 1, + processGroupId: 300, + state: "Z", + command: "[exited]", + }, + ]; + const listProcesses = vi.fn(() => Promise.resolve(rows)); + const environment = vi.fn(() => Promise.resolve({})); + const runTokens = vi.fn>( + (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + { + state: "readable" as const, + runId: pid === 200 ? "other-run" : ownership.runId, + }, + ]), + ), + ), + ); + + await expect( + observeOwnedProcessGroups(ownership.runId, [100, 200, 300], { + listProcesses, + environment, + runTokens, + signalGroup: vi.fn(), + }), + ).resolves.toEqual( + new Map([ + [100, { state: "alive" }], + [ + 200, + { state: "unverifiable", reason: "process ownership did not match" }, + ], + [300, { state: "empty" }], + ]), + ); + expect(listProcesses).toHaveBeenCalledTimes(1); + expect(runTokens).toHaveBeenCalledTimes(1); + expect(runTokens).toHaveBeenCalledWith( + rows.filter(({ state }) => state !== "Z"), + ); + expect(environment).not.toHaveBeenCalled(); + }); + + it("rechecks unavailable members individually before classifying their groups", async () => { + const rows = [ + ...processes, + { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "unreadable-group", + }, + ]; + const listProcesses = vi + .fn() + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101)) + .mockResolvedValueOnce(rows); + const runTokens = vi.fn>( + (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 101 || pid === 200 + ? { + state: "unavailable" as const, + reason: `unreadable-${String(pid)}`, + } + : { state: "readable" as const, runId: ownership.runId }, + ]), + ), + ), + ); + + await expect( + observeOwnedProcessGroups(ownership.runId, [100, 200], { + listProcesses, + environment: vi.fn(() => Promise.resolve({})), + runTokens, + signalGroup: vi.fn(), + }), + ).resolves.toEqual( + new Map([ + [100, { state: "alive" }], + [ + 200, + { + state: "unverifiable", + reason: + "process ownership could not be revalidated for PID 200: unreadable-200", + }, + ], + ]), + ); + expect(listProcesses).toHaveBeenCalledTimes(3); + expect(runTokens).toHaveBeenCalledTimes(1); + }); +}); + +describe("multi-group observation short-circuit", () => { + it("stops missing-row fallbacks once every live group is unverifiable", async () => { + const listProcesses = vi.fn(() => Promise.resolve(processes)); + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 100 ? "other-run" : ownership.runId, + }), + ); + const runTokens = vi.fn>( + () => Promise.resolve(new Map()), + ); + + await expect( + observeOwnedProcessGroups(ownership.runId, [100], { + listProcesses, + environment, + runTokens, + signalGroup: vi.fn(), + }), + ).resolves.toEqual( + new Map([ + [ + 100, + { state: "unverifiable", reason: "process ownership did not match" }, + ], + ]), + ); + expect(runTokens).toHaveBeenCalledTimes(1); + expect(environment.mock.calls).toEqual([[100]]); + expect(listProcesses).toHaveBeenCalledTimes(1); + }); +}); + describe("batched ownership observation", () => { it("observes group and lineage with one batch each and falls back only for missing rows", async () => { const { adapter } = host({ @@ -32,8 +184,14 @@ describe("batched ownership observation", () => { ); const observedHost = { ...adapter, environment, runTokens }; await expect( - observeOwnedProcessGroup(ownership, observedHost), - ).resolves.toEqual({ state: "alive" }); + observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], + observedHost, + ), + ).resolves.toEqual( + new Map([[ownership.processGroupId, { state: "alive" }]]), + ); await expect( observeOwnedProcessLineage(ownership, observedHost), ).resolves.toMatchObject({ @@ -67,8 +225,9 @@ describe("batched ownership observation", () => { }, ); await expect( - observeOwnedProcessGroup( - ownership, + observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], { listProcesses, environment, runTokens, signalGroup: vi.fn() }, controller.signal, ), diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index 2d6dd7275f..a516141736 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -6,7 +6,7 @@ import { type WindowsProcessTreeHost, } from "./ProcessOwnership.js"; import { - observeOwnedProcessGroup, + observeOwnedProcessGroups, observeOwnedProcessLineage, } from "./ProcessOwnershipObservation.js"; import { host, ownership } from "./ProcessOwnership.fixture.js"; @@ -160,7 +160,12 @@ describe("owned process-group cleanup validation: exited members", () => { }, }; await expect( - observeOwnedProcessGroup(ownership, adapter, controller.signal), + observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], + adapter, + controller.signal, + ), ).rejects.toBe(reason); }); it("does not inspect a process group after observation was cancelled", async () => { @@ -177,7 +182,12 @@ describe("owned process-group cleanup validation: exited members", () => { }, }; await expect( - observeOwnedProcessGroup(ownership, adapter, controller.signal), + observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], + adapter, + controller.signal, + ), ).rejects.toBe(reason); }); it("ignores exited zombie members during live ownership checks", async () => { @@ -229,9 +239,13 @@ describe("owned process-group cleanup validation: exited members", () => { environment, signalGroup: vi.fn(), }; - expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({ - state: "empty", - }); + expect( + await observeOwnedProcessGroups( + ownership.runId, + [ownership.processGroupId], + adapter, + ), + ).toEqual(new Map([[ownership.processGroupId, { state: "empty" }]])); expect(environment).not.toHaveBeenCalled(); }); it("is idempotent when the owned group has already exited", async () => { diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 43fc65e52b..db5c7cb3a9 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -16,51 +16,78 @@ import type { ProcessTableEntry, } from "./ProcessOwnership.js"; -/** Observe one group without signaling it, failing closed on identity doubt. */ -export const observeOwnedProcessGroup = async ( - ownership: OwnedProcessGroup, +/** Observe multiple groups from one process snapshot and one token batch. */ +export const observeOwnedProcessGroups = async ( + runId: string, + processGroupIds: readonly number[], host: ProcessOwnershipHost = systemProcessOwnershipHost, signal?: AbortSignal, -): Promise => { +): Promise> => { signal?.throwIfAborted(); - let members: readonly ProcessTableEntry[]; + const groupIds = [...new Set(processGroupIds)]; + if (groupIds.length === 0) return new Map(); + let processes: readonly ProcessTableEntry[]; try { - members = (await host.listProcesses(signal)).filter( - ({ processGroupId }) => processGroupId === ownership.processGroupId, - ); + processes = liveProcesses(await host.listProcesses(signal)); } catch (cause: unknown) { signal?.throwIfAborted(); - return { - state: "unverifiable", + const failure = { + state: "unverifiable" as const, reason: `process group could not be inspected: ${errorMessage(cause)}`, }; + return new Map(groupIds.map((groupId) => [groupId, failure])); } signal?.throwIfAborted(); - const liveMembers = liveProcesses(members); - if (liveMembers.length === 0) return { state: "empty" }; + const membersByGroup = new Map( + groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]), + ); + for (const process of processes) + membersByGroup.get(process.processGroupId)?.push(process); + const observations = new Map( + groupIds.map((groupId) => [ + groupId, + (membersByGroup.get(groupId)?.length ?? 0) > 0 + ? { state: "alive" } + : { state: "empty" }, + ]), + ); + const liveMembers = groupIds.flatMap( + (groupId) => membersByGroup.get(groupId) ?? [], + ); + const pendingGroups = new Set( + groupIds.filter( + (groupId) => (membersByGroup.get(groupId)?.length ?? 0) > 0, + ), + ); for await (const { process: member, observation } of readProcessRunTokens( host, liveMembers, signal, )) { + if (!pendingGroups.has(member.processGroupId)) continue; if (observation.state === "readable") { - if (observation.runId !== ownership.runId) - return { + if (observation.runId !== runId) { + observations.set(member.processGroupId, { state: "unverifiable", reason: "process ownership did not match", - }; + }); + pendingGroups.delete(member.processGroupId); + } } else { - if (!(await processIsGone(host, member.pid))) { - signal?.throwIfAborted(); - return { + const gone = await processIsGone(host, member.pid); + signal?.throwIfAborted(); + if (!gone) { + observations.set(member.processGroupId, { state: "unverifiable", reason: `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`, - }; + }); + pendingGroups.delete(member.processGroupId); } } + if (pendingGroups.size === 0) break; } signal?.throwIfAborted(); - return { state: "alive" }; + return observations; }; /** diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 19ac0173a9..e4e59d1716 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -25,7 +25,7 @@ import { type ProcessOwnershipBaseline, } from "../ProcessOwnership.js"; import { - observeOwnedProcessGroup, + observeOwnedProcessGroups, prepareProcessOwnershipInspection, systemProcessOwnershipHost, } from "../ProcessOwnershipObservation.js"; @@ -616,21 +616,20 @@ export const observeSettlement = async ( while (!deadlineReached) { onLiveProgress?.(); assertNotCancelled(signal); - const observations = await Promise.all( - [...new Set(processGroupIds)].map((processGroupId) => - observeOwnedProcessGroup( - { runId, leaderPid: processGroupId, processGroupId }, - undefined, - signal, - ), - ), + const observations = await observeOwnedProcessGroups( + runId, + processGroupIds, + undefined, + signal, ); assertNotCancelled(signal); - if (observations.some(({ state }) => state === "unverifiable")) { + if ( + [...observations.values()].some(({ state }) => state === "unverifiable") + ) { recordEvent("lifecycle", 1); return { state: "unverifiable", elapsed_ms: Date.now() - started }; } - if (observations.every(({ state }) => state === "empty")) { + if ([...observations.values()].every(({ state }) => state === "empty")) { consecutiveEmpty += 1; if (consecutiveEmpty >= 2) { recordEvent("lifecycle", 1); From e6d0b3993df391b3e7e0a6b500da04ffc6a0b049 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:31:07 +0800 Subject: [PATCH 5/8] perf(process): batch liveness rechecks within ownership phases Recheck unreadable token and identity observations against one fresh live process snapshot per phase. Preserve token mismatches, fail closed on inspection errors, and retain liveness failure diagnostics. Validated with focused process ownership, native capture, CLI and MCP boundaries. An eight-member injected-unreadable workload using native ps reduced snapshots from nine to two with equivalent failures; real permission-denied provider behavior was not exercised. --- src/process/ProcessOwnership.identity.test.ts | 31 +++--- src/process/ProcessOwnership.ts | 85 +++++++++-------- .../ProcessOwnership.validation.test.ts | 95 +++++++++++++++++++ 3 files changed, 160 insertions(+), 51 deletions(-) diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 3d79b281d4..8f6ea72239 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -377,7 +377,7 @@ describe("process identity during token validation", () => { expect(reusedDuringFinalRead).toBe(true); }); - it("fails closed when a candidate PID changes identity during token reading", async () => { + it("fails closed when candidate PIDs change identity during token reading", async () => { let identityReads = 0; const process: ProcessTableEntry = { pid: 911, @@ -386,8 +386,10 @@ describe("process identity during token validation", () => { state: "S", command: "reused-during-token-read", }; + const processes = [process, { ...process, pid: 912, processGroupId: 912 }]; + const listProcesses = vi.fn(async () => processes); const host: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve([process]), + listProcesses, environment: () => Promise.resolve({}), processIdentities: (entries) => { identityReads += 1; @@ -414,21 +416,24 @@ describe("process identity during token validation", () => { }; await expect( - verifyNoTokenOwnedProcesses("run-token", host, [ - { pid: process.pid, identity: "old-start" }, - ]), + verifyNoTokenOwnedProcesses( + "run-token", + host, + processes.map(({ pid }) => ({ pid, identity: "old-start" })), + ), ).resolves.toEqual({ cleaned: false, reason: "process identity could not be revalidated", - failures: [ - { - pid: process.pid, - reason: "process-identity-unavailable", - diagnostic: - "process identity changed or became unavailable during token validation", - }, - ], + failures: processes.map(({ pid }) => ({ + pid, + reason: "process-identity-unavailable", + diagnostic: + "process identity changed or became unavailable during token validation", + })), }); + // Initial observation, final token classification, then one fresh recheck + // shared by both changed identities. + expect(listProcesses).toHaveBeenCalledTimes(3); }); }); diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 6969cf834d..db53e08c72 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -628,6 +628,7 @@ const processOwnershipFailures = async ( expectedIdentities.has(pid), ); const observed = await host.processIdentities(expectedMembers); + const identityFailures: ProcessOwnershipValidationFailure[] = []; for (const member of expectedMembers) { const expected = expectedIdentities.get(member.pid); const current = observed.get(member.pid); @@ -636,8 +637,7 @@ const processOwnershipFailures = async ( current?.state !== "readable" || current.identity !== expected ) { - if (await processIsGone(host, member.pid)) continue; - failures.push({ + identityFailures.push({ pid: member.pid, reason: "process-identity-unavailable", diagnostic: @@ -645,6 +645,7 @@ const processOwnershipFailures = async ( }); } } + failures.push(...(await recheckOwnershipFailures(identityFailures, host))); } failures.push(...(await processRunTokenFailures(members, runId, host))); if ( @@ -656,6 +657,7 @@ const processOwnershipFailures = async ( expectedIdentities.has(pid), ); const observed = await host.processIdentities(expectedMembers); + const identityFailures: ProcessOwnershipValidationFailure[] = []; for (const member of expectedMembers) { const expected = expectedIdentities.get(member.pid); const current = observed.get(member.pid); @@ -665,14 +667,14 @@ const processOwnershipFailures = async ( current.identity === expected ) continue; - if (await processIsGone(host, member.pid)) continue; - failures.push({ + identityFailures.push({ pid: member.pid, reason: "process-identity-unavailable", diagnostic: "process identity changed or became unavailable during token validation", }); } + failures.push(...(await recheckOwnershipFailures(identityFailures, host))); } return failures; }; @@ -692,24 +694,43 @@ const processRunTokenFailures = async ( failures.push({ pid: member.pid, reason: "run-token-mismatch" }); continue; } - try { - const live = liveProcesses(await host.listProcesses()); - if (!live.some(({ pid }) => pid === member.pid)) continue; - } catch (recheckCause: unknown) { - failures.push({ - pid: member.pid, - reason: "environment-unreadable", - diagnostic: `${observation.reason}; process liveness recheck failed: ${errorMessage(recheckCause)}`, - }); - continue; - } failures.push({ pid: member.pid, reason: "environment-unreadable", diagnostic: observation.reason, }); } - return failures; + return recheckOwnershipFailures(failures, host); +}; + +/** + * Take a fresh liveness snapshot after each ownership-read phase. Only an + * absent PID resolves an unreadable observation; a readable token mismatch + * remains a failure. Never reuse this snapshot across identity/token reads. + */ +const recheckOwnershipFailures = async ( + failures: readonly ProcessOwnershipValidationFailure[], + host: ProcessOwnershipHost, +): Promise => { + if (!failures.some(({ reason }) => reason !== "run-token-mismatch")) + return failures; + try { + const live = new Set( + liveProcesses(await host.listProcesses()).map(({ pid }) => pid), + ); + return failures.filter( + ({ pid, reason }) => reason === "run-token-mismatch" || live.has(pid), + ); + } catch (cause: unknown) { + return failures.map((failure) => + failure.reason === "run-token-mismatch" + ? failure + : { + ...failure, + diagnostic: `${failure.diagnostic}; process liveness recheck failed: ${errorMessage(cause)}`, + }, + ); + } }; interface TokenOwnedProcessScan { @@ -785,18 +806,19 @@ const scanTokenOwnedProcesses = async ( const failures: ProcessOwnershipValidationFailure[] = []; if (captureBaseline !== undefined && host.processIdentities !== undefined) { const stableCandidates: ProcessTableEntry[] = []; + const identityFailures: ProcessOwnershipValidationFailure[] = []; for (const process of candidates) { if (candidateIdentities.get(process.pid)?.state === "readable") { stableCandidates.push(process); continue; } - if (!(await processIsGone(host, process.pid))) - failures.push({ - pid: process.pid, - reason: "process-identity-unavailable", - diagnostic: "process identity was unavailable during token scan", - }); + identityFailures.push({ + pid: process.pid, + reason: "process-identity-unavailable", + diagnostic: "process identity was unavailable during token scan", + }); } + failures.push(...(await recheckOwnershipFailures(identityFailures, host))); candidates = stableCandidates; } const owned: ProcessTableEntry[] = []; @@ -882,6 +904,7 @@ const scanTokenOwnedProcesses = async ( if (candidates.length > 0 && host.processIdentities !== undefined) { const afterRead = await host.processIdentities(candidates); const stillOwned: ProcessTableEntry[] = []; + const identityFailures: ProcessOwnershipValidationFailure[] = []; const ownedPids = new Set(owned.map(({ pid }) => pid)); for (const process of candidates) { const before = candidateIdentities.get(process.pid); @@ -894,14 +917,14 @@ const scanTokenOwnedProcesses = async ( if (ownedPids.has(process.pid)) stillOwned.push(process); continue; } - if (await processIsGone(host, process.pid)) continue; - failures.push({ + identityFailures.push({ pid: process.pid, reason: "process-identity-unavailable", diagnostic: "process identity changed or became unavailable during token validation", }); } + failures.push(...(await recheckOwnershipFailures(identityFailures, host))); return { owned: stillOwned, failures, @@ -963,17 +986,3 @@ const settleUnreadableTokenCandidates = async ( } return remaining; }; - -const processIsGone = async ( - host: ProcessOwnershipHost, - pid: number, -): Promise => { - try { - return !liveProcesses(await host.listProcesses()).some( - (process) => process.pid === pid, - ); - } catch (cause: unknown) { - void cause; - return false; - } -}; diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index a516141736..5364e4e2f7 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -115,6 +115,101 @@ describe("owned process-group cleanup validation: ownership and lineage", () => }); expect(signalGroup).not.toHaveBeenCalled(); }); +}); + +describe("owned process-group cleanup liveness rechecks", () => { + it("rechecks all unreadable members after the token phase in one fresh snapshot", async () => { + const members = [100, 101, 102, 103].map((pid) => ({ + pid, + parentPid: pid === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })); + const tokenReads: number[] = []; + const listProcesses = vi.fn(async () => { + if (tokenReads.length > 0) + expect(tokenReads).toEqual([100, 101, 102, 103]); + return members; + }); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses, + environment: async (pid) => { + tokenReads.push(pid); + throw new Error(`unreadable ${pid}`); + }, + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { + cleaned: false, + reason: + "process ownership token could not be read for 4 live process(es): other_unavailable=4; live candidates 100=other_unavailable, 101=other_unavailable, 102=other_unavailable, 103=other_unavailable", + failures: members.map(({ pid }) => ({ + pid, + reason: "environment-unreadable", + diagnostic: `unreadable ${pid}`, + })), + }, + ); + expect(listProcesses).toHaveBeenCalledTimes(2); + expect(signalGroup).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "retains mismatches and fails closed when the liveness recheck fails: %s", + async (recheckFails) => { + const members = [100, 101, 102].map((pid) => ({ + pid, + parentPid: pid === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })); + const listProcesses = vi + .fn() + .mockResolvedValueOnce(members); + if (recheckFails) + listProcesses.mockRejectedValue(new Error("process table denied")); + else listProcesses.mockResolvedValue([]); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses, + environment: async (pid) => { + if (pid === 101) return { REA_PROCESS_RUN_ID: "other-run" }; + throw new Error(`unreadable ${pid}`); + }, + signalGroup, + }; + await expect( + cleanupOwnedProcessGroup(ownership, adapter), + ).resolves.toEqual({ + cleaned: false, + reason: "process tree contains an unowned or PID-reused process", + failures: recheckFails + ? [ + { + pid: 100, + reason: "environment-unreadable", + diagnostic: + "unreadable 100; process liveness recheck failed: process table denied", + }, + { pid: 101, reason: "run-token-mismatch" }, + { + pid: 102, + reason: "environment-unreadable", + diagnostic: + "unreadable 102; process liveness recheck failed: process table denied", + }, + ] + : [{ pid: 101, reason: "run-token-mismatch" }], + }); + expect(listProcesses).toHaveBeenCalledTimes(2); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); + it("accepts a member that exits during ownership revalidation", async () => { const liveLauncher = { pid: 100, From acd8d868989454b52d0c53cad3d8ae4472b016a2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:31:57 +0800 Subject: [PATCH 6/8] perf(android): validate prerequisites once per retained JADX session Resolve current paths and JVM options and rehash source identities for each request. Probe Java modules and engine capabilities only when creating a new session; remove the superseded full configuration resolver. Focused configuration, session retirement, cancellation, cleanup and Android MCP boundaries passed. The 34-operation retained session now performs one prerequisite probe. Protocol fixtures do not establish real JADX or JDK performance. --- src/android/JadxConfiguration.ts | 93 +++++++++++++------ src/android/JadxProvider.ts | 11 ++- .../android/jadxConfiguration.test.ts | 33 +++++-- .../boundary/process/jadxIntegration.test.ts | 10 +- tests/fixtures/android/jadx.ts | 16 +++- 5 files changed, 117 insertions(+), 46 deletions(-) diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index 8c9d29357c..b44d1f28bf 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -52,7 +52,11 @@ export const inspectJadxAvailability = async ( signal?: AbortSignal, ): Promise => { try { - const configuration = await readJadxConfiguration(environment, signal); + const configuration = await readJadxConfigurationInputs( + environment, + signal, + ); + await validateJadxConfigurationContents(configuration, environment, signal); return { status: "available", code: null, @@ -77,52 +81,83 @@ export const inspectJadxAvailability = async ( } }; -/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */ -export const resolveJadxConfiguration = async ( +/** Resolve caller-selected paths and JVM options without probing their contents. */ +export const resolveJadxConfigurationInputs = async ( environment: Readonly>, operation: AndroidOperation, signal?: AbortSignal, ): Promise => { try { - return await readJadxConfiguration(environment, signal); + return await readJadxConfigurationInputs(environment, signal); } catch (cause) { if (signal?.aborted === true) throw cause; if (!(cause instanceof JadxConfigurationFailure)) throw cause; - const output = cause.diagnostics; - throw new AnalysisCapabilityUnavailableError( - "jadx", - operation, - cause.message, - { - cause, - userMessage: cause.message, - ...(typeof output.stdout === "string" && - typeof output.stderr === "string" - ? { - capturedOutput: { - stdout: output.stdout, - stderr: output.stderr, - truncated: output.output_truncated === true, - }, - } - : {}), - }, - ); + throw projectConfigurationFailure(operation, cause); } }; -const readJadxConfiguration = async ( +/** Probe Java and archive prerequisites before starting a new engine session. */ +export const validateJadxConfiguration = async ( + configuration: JadxConfiguration, + environment: Readonly>, + operation: AndroidOperation, + signal?: AbortSignal, +): Promise => { + try { + await validateJadxConfigurationContents(configuration, environment, signal); + } catch (cause) { + if (signal?.aborted === true) throw cause; + if (!(cause instanceof JadxConfigurationFailure)) throw cause; + throw projectConfigurationFailure(operation, cause); + } +}; + +const readJadxConfigurationInputs = async ( environment: Readonly>, signal?: AbortSignal, ): Promise => { + signal?.throwIfAborted(); requireSupportedHost(); const jar = await resolveJar(environment); const java = await resolveJava(environment, signal); await requireJavaHomeExecutable(environment.JAVA_HOME, java); - const jvmArguments = resolveJvmArguments(environment); - await inspectJavaRuntime(java, environment, signal); - await requireEngineArchive(jar, signal); - return { jar, java, jvmArguments }; + signal?.throwIfAborted(); + return { jar, java, jvmArguments: resolveJvmArguments(environment) }; +}; + +const validateJadxConfigurationContents = async ( + configuration: JadxConfiguration, + environment: Readonly>, + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + await inspectJavaRuntime(configuration.java, environment, signal); + await requireEngineArchive(configuration.jar, signal); +}; + +const projectConfigurationFailure = ( + operation: AndroidOperation, + cause: JadxConfigurationFailure, +): AnalysisCapabilityUnavailableError => { + const output = cause.diagnostics; + return new AnalysisCapabilityUnavailableError( + "jadx", + operation, + cause.message, + { + cause, + userMessage: cause.message, + ...(typeof output.stdout === "string" && typeof output.stderr === "string" + ? { + capturedOutput: { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.output_truncated === true, + }, + } + : {}), + }, + ); }; const requireEngineArchive = async ( diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 261c75bf4e..6fda5a143e 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -27,7 +27,8 @@ import { hashStableFile } from "../filesystem/StableFileHash.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; import { inspectJadxAvailability, - resolveJadxConfiguration, + resolveJadxConfigurationInputs, + validateJadxConfiguration, } from "./JadxConfiguration.js"; import type { JadxLauncher } from "./JadxMcpTransport.js"; @@ -333,7 +334,7 @@ export class JadxProvider implements AndroidAnalysisPort { request.operation, `Target ${target.path} is ${target.format}; provide one standalone APK.`, ); - const configuration = await resolveJadxConfiguration( + const configuration = await resolveJadxConfigurationInputs( this.environment, request.operation, signal, @@ -382,6 +383,12 @@ export class JadxProvider implements AndroidAnalysisPort { } let retained = this.#retained; if (retained === undefined) { + await validateJadxConfiguration( + configuration, + this.environment, + request.operation, + signal, + ); root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" }); const engine = await snapshotAndroidEngine( configuration.jar, diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index e29bcee495..57beee669d 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -5,7 +5,8 @@ import { expect, it as test } from "vitest"; import { inspectJadxAvailability, - resolveJadxConfiguration, + resolveJadxConfigurationInputs, + validateJadxConfiguration, } from "../../../src/android/JadxConfiguration.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -14,6 +15,18 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; const it = test.skipIf(process.platform === "win32"); +const resolveConfiguration = async ( + environment: Parameters[0], + operation: Parameters[1], +) => { + const configuration = await resolveJadxConfigurationInputs( + environment, + operation, + ); + await validateJadxConfiguration(configuration, environment, operation); + return configuration; +}; + it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => { const root = await createTestTempDirectory("rea-jadx-invalid-jar-"); const javaHome = await createFakeJdk(root); @@ -29,7 +42,7 @@ it("rejects malformed or unrelated engine archives with JAR-specific recovery", diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" }, }); await expect( - resolveJadxConfiguration(environment, "inspect_android_package"), + resolveConfiguration(environment, "inspect_android_package"), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", reason: expect.stringContaining("REA_JADX_MCP_JAR"), @@ -45,7 +58,7 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () = await writeJadxJarInventory(jar); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ), @@ -62,7 +75,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => { await writeJadxJarInventory(jar); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ), @@ -78,7 +91,7 @@ it("configures heap and visible processors independently without forcing either" const jar = join(root, "engine.jar"); const javaHome = await createFakeJdk(root); await writeJadxJarInventory(jar); - const configuration = await resolveJadxConfiguration( + const configuration = await resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -92,7 +105,7 @@ it("configures heap and visible processors independently without forcing either" "-XX:ActiveProcessorCount=4", ]); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -107,7 +120,7 @@ it("configures heap and visible processors independently without forcing either" for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"]) for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"]) await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome, @@ -127,7 +140,7 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => { await writeJadxJarInventory(jar); const jreHome = await createFakeJdk(root, false); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome }, "inspect_android_package", ), @@ -175,7 +188,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated" ); await chmod(java, 0o700); - const failure = await resolveJadxConfiguration( + const failure = await resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome }, "inspect_android_package", ).then( @@ -241,7 +254,7 @@ it("distinguishes an unreported Java version from an unsupported old version", a ); await chmod(join(oldHome, "bin", "java"), 0o700); await expect( - resolveJadxConfiguration( + resolveConfiguration( { REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome }, "inspect_android_package", ), diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 93ffa475bc..e8c296c670 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -91,8 +91,9 @@ it("reuses immutable engine state while keeping each Evidence observation separa await verifyCleanup(launches); }); -it("bounds output per operation across a retained session rather than accumulating earlier captures", async () => { - const { service, apk, launches, provider } = await setup("large-manifest"); +it("bounds output per operation without repeating prerequisite probes for a retained session", async () => { + const { service, apk, launches, provider, javaProbeCount } = + await setup("large-manifest"); const expectedManifest = "x".repeat(1024 * 1024); for (let index = 0; index < 34; index += 1) { const result = await service.execute("inspect_android_package", { @@ -106,12 +107,14 @@ it("bounds output per operation across a retained session rather than accumulati ).toBe(expectedManifest); } expect(launches).toHaveLength(1); + expect(await javaProbeCount()).toBe(1); await provider.close(); await verifyCleanup(launches); }); it("retire sessions when APK bytes, engine bytes or JVM options change", async () => { - const { service, apk, jar, launches, provider, environment } = await setup(); + const { service, apk, jar, launches, provider, environment, javaProbeCount } = + await setup(); const inspect = async () => { const result = await service.execute("inspect_android_package", { path: apk, @@ -133,6 +136,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async ( environment._JAVA_OPTIONS = "-XX:+UseG1GC"; await inspect(); expect(launches).toHaveLength(4); + expect(await javaProbeCount()).toBe(4); expect(launches[3]).toMatchObject({ javaToolOptions: "-Xmx8g", legacyJavaOptions: "-XX:+UseG1GC", diff --git a/tests/fixtures/android/jadx.ts b/tests/fixtures/android/jadx.ts index 8e8ac48847..69dc86be52 100644 --- a/tests/fixtures/android/jadx.ts +++ b/tests/fixtures/android/jadx.ts @@ -1,4 +1,11 @@ -import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { + access, + chmod, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, onTestFinished } from "vitest"; @@ -27,10 +34,11 @@ export const createJadxProtocolFixture = async ( const javaHome = join(root, "jdk"); const javaBin = join(javaHome, "bin"); const java = join(javaBin, "java"); + const javaProbeLog = join(root, "java-probes.log"); await mkdir(javaBin, { recursive: true }); await writeFile( java, - `#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`, + `#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") { require("node:fs").appendFileSync(${JSON.stringify(javaProbeLog)}, "probe\\n"); console.log("java.base@21.0.0\\njdk.compiler@21.0.0"); }\n`, ); await chmod(java, 0o755); await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]); @@ -115,6 +123,10 @@ export const createJadxProtocolFixture = async ( jar, environment, launches, + javaProbeCount: async () => { + const contents = await readFile(javaProbeLog, "utf8"); + return contents.split("\n").filter(Boolean).length; + }, }; }; From bb764d26bd661848856a7ee32101dd496b2843ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:32:04 +0800 Subject: [PATCH 7/8] perf(artifacts): stop parsing parent paths after a missing branch Walk delimiter-terminated parent segments lazily instead of splitting full paths before discovering that no deeper observed parent exists. Preserve canonical paths and observed ancestry. Verified real ZIP inventory with multiple 30000-component siblings and the full entry-order suite. A 96-member deep ZIP benchmark retained an identical full inventory digest and reduced median scan time from 517.5 to 464.5 ms across five measured inventories. --- .../inventory/ArtifactGraphConstruction.ts | 11 +++++-- .../artifactInventoryEntryOrder.test.ts | 30 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 96770f85ec..99d76e9f14 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -325,15 +325,20 @@ export const resolveOccurrenceParents = ( branch.occurrence = occurrence; } for (const occurrence of occurrences) { - const segments = occurrence.logical_path.split("/"); + const path = occurrence.logical_path; let branch = root; let parent: MutableOccurrence | undefined; - // The final segment identifies this occurrence, not one of its parents. - for (const segment of segments.slice(0, -1)) { + // Walk parent segments only. Stop scanning the path once no observed + // directory or expanded container can be an ancestor. + for (let start = 0; ;) { + const slash = path.indexOf("/", start); + if (slash < 0) break; + const segment = path.slice(start, slash); const child = branch.children.get(segment); if (child === undefined) break; branch = child; parent = branch.occurrence ?? parent; + start = slash + 1; } occurrence.parent_occurrence_id = parent?.occurrence_id ?? null; } diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index ad28301677..04cb7b7079 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -73,25 +73,29 @@ describe("artifact inventory entry order", () => { const root = await createTestTempDirectory("rea-deep-archive-parent-"); const archive = join(root, "deep.zip"); // Fits the ZIP filename field while exceeding the formerly expensive depth. - const member = `observed/${"d/".repeat(30_000)}data.txt`; - await writeOrderedZip(archive, [member, "observed/", "unrelated/"]); + const members = ["data.txt", "second.txt", "third.bin"].map( + (name) => `observed/${"d/".repeat(30_000)}${name}`, + ); + await writeOrderedZip(archive, [...members, "observed/", "unrelated/"]); const inventory = await inventoryArtifact(archive); expect(artifactParentPaths(inventory)).toEqual({ ".": null, observed: ".", unrelated: ".", - [member]: "observed", + ...Object.fromEntries(members.map((member) => [member, "observed"])), }); - const occurrence = artifactOccurrenceAt(inventory, member); - expect(occurrence.hash_status).toBe("verified"); - expect(inventory.edges).toContainEqual( - expect.objectContaining({ - occurrence_id: occurrence.occurrence_id, - parent_artifact_id: artifactOccurrenceAt(inventory, "observed") - .artifact_id, - relation: "contains", - }), - ); + const observedParent = artifactOccurrenceAt(inventory, "observed"); + for (const member of members) { + const occurrence = artifactOccurrenceAt(inventory, member); + expect(occurrence.hash_status).toBe("verified"); + expect(inventory.edges).toContainEqual( + expect.objectContaining({ + occurrence_id: occurrence.occurrence_id, + parent_artifact_id: observedParent.artifact_id, + relation: "contains", + }), + ); + } }); it.each([ From dce81ca400eda369fe65b3de6e5a9aef4dfdaa50 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:32:11 +0800 Subject: [PATCH 8/8] perf(javascript): count candidate relevance without a second adjacency Scan eligible candidate relations directly for query ambiguity instead of allocating and sorting another traversal index. Preserve direction, relation filters and cycle handling. Semantic graph and builder suites plus public application workflow CLI tests passed. Full query outputs matched on a generated 20000-node graph; the single repeated-query timing run is indicative only. --- .../javascriptSemanticGraphQuery.test.ts | 100 ++++++++++++++++++ .../javascript/javascriptSemanticQuery.ts | 35 ++++-- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index 9a99872d4f..17d03a1d1c 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { digestCanonicalValue } from "../canonicalDigest.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { + JAVASCRIPT_SEMANTIC_RELATION_FAMILY, JAVASCRIPT_SEMANTIC_NODE_KINDS, JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATIONS, @@ -27,6 +28,7 @@ import { parseJavaScriptSemanticGraph, serializeJavaScriptSemanticGraph, } from "./javascriptSemanticGraphSerialization.js"; +import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js"; const SHA = "a".repeat(64); const JAG_ID = `jag_${"b".repeat(64)}`; @@ -323,6 +325,104 @@ const registryForGraph = ( return registry; }; +const graphWithCandidateRelations = (): JavaScriptSemanticGraph => { + const base = fixtureGraph(); + const evidenceContexts = registryForGraph(base); + const relations = base.relations.map( + ({ relation_id: _relationId, ...relation }) => + createJavaScriptSemanticGraphRelation( + { + ...relation, + resolution: "candidate", + evidence: resolveJavaScriptSemanticEvidence(base, relation.evidence), + }, + evidenceContexts, + ), + ); + const binding = base.nodes.find(({ label }) => label === "binding"); + const callable = base.nodes.find(({ label }) => label === "function"); + if (binding === undefined || callable === undefined) + throw new Error("Expected candidate-cycle endpoints"); + for (const [source, target] of [ + [binding, callable], + [callable, binding], + ] as const) + relations.push( + createJavaScriptSemanticGraphRelation( + { + source_node_id: source.node_id, + target_node_id: target.node_id, + relation: "owns", + resolution: "candidate", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), + ); + + const { graph_id: _graphId, ...input } = base; + return createJavaScriptSemanticGraph({ + ...input, + evidence_contexts: evidenceContexts.contexts, + relations, + coverage: { + ...input.coverage, + families: input.coverage.families.map((family) => ({ + ...family, + retained_relations: relations.filter( + ({ relation }) => + JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family.family, + ).length, + })), + }, + }); +}; + +it("counts candidate edges by query direction and relation filter", () => { + const graph = graphWithCandidateRelations(); + const binding = graph.nodes.find(({ label }) => label === "binding"); + if (binding === undefined) throw new Error("Expected binding seed"); + const query = ( + direction: "forward-influence" | "backward-provenance", + allowedRelations: (typeof JAVASCRIPT_SEMANTIC_RELATIONS)[number][], + ) => + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: binding.node_id }, + direction, + allowed_relations: allowedRelations, + }); + + expect(query("forward-influence", ["captures"]).status).toBe("ambiguous"); + expect(query("forward-influence", ["defines"]).status).toBe("found"); + expect(query("backward-provenance", ["defines"]).status).toBe("ambiguous"); + expect(query("backward-provenance", ["captures"]).status).toBe("found"); +}); + +it("counts candidate ownership cycles once and traverses them finitely", () => { + const graph = graphWithCandidateRelations(); + const binding = graph.nodes.find(({ label }) => label === "binding"); + if (binding === undefined) throw new Error("Expected binding seed"); + const result = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: binding.node_id }, + direction: "ownership", + allowed_relations: ["owns"], + include_ambiguous_dynamic_edges: true, + }); + + expect(result.status).toBe("ambiguous"); + expect(result.relations).toHaveLength(2); + expect(result.summary.traversed_nodes).toBe(2); + expect(result.summary.traversed_relations).toBe(2); + expect( + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: binding.node_id }, + direction: "ownership", + allowed_relations: ["constructs-request"], + }).status, + ).toBe("found"); +}); + it("defines every required relation family and canonicalizes records", () => { expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter"); expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task"); diff --git a/src/domain/javascript/javascriptSemanticQuery.ts b/src/domain/javascript/javascriptSemanticQuery.ts index 1870162570..54c4071020 100644 --- a/src/domain/javascript/javascriptSemanticQuery.ts +++ b/src/domain/javascript/javascriptSemanticQuery.ts @@ -283,15 +283,32 @@ const relevantCandidateRelationCount = ( nodeIds: ReadonlySet, input: JavaScriptSemanticQueryInput, ): number => { - const adjacency = buildAdjacency( - graph.relations.filter(({ resolution }) => resolution === "candidate"), - { ...input, include_ambiguous_dynamic_edges: true }, - ); - const relevant = new Set(); - for (const nodeId of nodeIds) - for (const { relation } of adjacency.get(nodeId) ?? []) - relevant.add(relation.relation_id); - return relevant.size; + const allowed = + input.allowed_relations === undefined + ? null + : new Set(input.allowed_relations); + let relevant = 0; + for (const relation of graph.relations) { + if ( + relation.resolution !== "candidate" || + (allowed !== null && !allowed.has(relation.relation)) + ) + continue; + const sourceIsRelevant = nodeIds.has(relation.source_node_id); + const targetIsRelevant = nodeIds.has(relation.target_node_id); + if ( + (input.direction === "callers" && + relation.relation === "calls" && + targetIsRelevant) || + (input.direction === "ownership" && + ownershipRelation(relation.relation) && + (sourceIsRelevant || targetIsRelevant)) || + (input.direction === "backward-provenance" && targetIsRelevant) || + (input.direction === "forward-influence" && sourceIsRelevant) + ) + relevant += 1; + } + return relevant; }; const expectedMatchesFor = (