[GH-ISSUE #3977] [Bug] DOS load-image verification reports success after cancellation during snapshot cleanup #1571

Closed
opened 2026-10-16 21:07:17 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @coygeek on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3977

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

At the Ghidra provider boundary, inspect_native_load_image with {} returned ok: true and value.result.status: "verified" for a matching DOS MZ fixture even when the caller cancelled during snapshot descriptor cleanup. This reproduced in three of three deterministic close-boundary attempts. Each descriptor closed successfully and reached fd -1 before the operation returned with its signal aborted.

An uncancelled control returned verified; an already-aborted control returned AnalysisCancelledError. Both clients closed successfully. The probe used production snapshot I/O and cleanup with a simulated installation and the repository's fake Ghidra socket process. No real Ghidra engine ran. CLI interrupt delivery, MCP cancellation delivery, transport replies, and retained Evidence after cancellation were not executed.

Reproduction

The executed diagnostic was a focused temporary Vitest test beside the Ghidra adapter tests, run with npx --no-install vitest run selecting that test and --maxWorkers=1. Its assertions confirmed the incorrect baseline behavior. The temporary test was removed after the run; the following describes its setup rather than supplying a retained test file.

  1. At the source revision below, prepare the locked dependencies. Isolate HOME, TMPDIR, and REA_VERIFY_WORK in a disposable workspace. Generate fixtureDosLoadImage() from src/ghidra/GhidraLoadImage.fixture.ts and write its unchanged bytes as fixture.exe, retaining the supplied digest and matching observation.
  2. Start a production GhidraClient for that file and digest using the launcher pattern in tests/boundary/providers/ghidra/ghidraClient.test.ts:75-129 and tests/fixtures/fakeGhidra.mjs in success mode. Use Unix socket transport, provider version 12.1.4, a profile digest of 64 lowercase a characters, and a 5000 ms startup timeout. Pass the session endpoint, token, run ID, provider version, profile digest, target digest, and transport to the fake process through REA_GHIDRA_FIXTURE_CONFIG. Await successful startup.
  3. Construct GhidraProvider following the simulated installation-host pattern in src/ghidra/GhidraProvider.behavior.test.ts:46-78 and its DOS projection case at lines 1241-1280. The simulated installation reports Linux x64, Ghidra 12.1.4, and 64-bit OpenJDK 21.0.11. Its client factory returns the successful startup, supplies the matching fixture observation as JSON, delegates readTargetSnapshot(maximum, signal) unchanged to the started production client, and closes that client.
  4. Admit the fixture as an executable with format dos-mz, architecture x86, available architectures ["x86"], and its real SHA-256. Check support, resolve a non-null bound profile, and create the provider client. Execute inspect_native_load_image with {} for the uncancelled and already-aborted controls described above.
  5. For each of three attempts, create a fresh AbortController. Install a one-shot spy on OwnedFileHandle.prototype.close that aborts that controller, invokes and awaits the original close method with the same receiver, and records whether the handle's fd becomes -1. Execute providerClient.execute("inspect_native_load_image", {}, { signal: controller.signal }), then restore the spy.
  6. Observe one hook invocation, successful closure, an aborted signal, and successful verified output in every attempt. Close both clients successfully.

The CLI command node scripts/rea.mjs inspect-native-load-image fixture.exe --provider ghidra --json and MCP tool inspect_native_load_image with {} route to this operation by source inspection. Those interfaces were not used to inject cancellation in this reproduction.

Expected behavior

Cancellation received before the snapshot read and its required descriptor cleanup complete should produce typed cancellation after successful cleanup. The uncancelled fixture should retain its verified result.

The general provider signal contract is in docs/mcp-contracts.md:418-421. docs/ghidra-provider.md:38 states that established Ghidra operations wait for their reply or caller cancellation. docs/user-visible-errors.csv:24 documents caller cancellation. The operation admits empty parameters and DOS verification in src/contracts/officialToolContracts.ts:45-49.

Impact and scope

P2: a read-only inspection reports success after cancellation at a narrow asynchronous cleanup boundary. Three deterministic attempts failed; ordinary-use frequency is unknown. No target-byte changes, descriptor leak, or broad workflow outage were observed. No existing REA option was verified to restore the typed cancellation outcome.

DOS COM shares this branch by source inspection but was not executed. Raw-binary attestation has a post-await abort check and is outside this report.

REA version

rea 6.5.0 at source commit 4692bb3330

The cited files are unchanged on current origin/main fc712b3750. The defect remains present by source inspection there; the runtime diagnostic was run at the baseline above.

Runtime and platform

Node 24.18.0; macOS arm64. Simulated Linux x64 Ghidra installation reporting 12.1.4 and OpenJDK 21.0.11; repository fake socket provider, not a real engine.

Target and artifact context

Repository-owned matching DOS MZ x86 fixture from fixtureDosLoadImage(), its SHA-256, and matching provider observation. Production immutable-snapshot admission, reading, validation, and descriptor cleanup ran unchanged.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed diagnostic output:

{"case":"control","signal_aborted":false,"result":"verified"}
{"case":"already_aborted","result":"AnalysisCancelledError"}
{"case":"cancel_at_close","attempt":1,"signal_aborted":true,"handle_closed":true,"result":"verified"}
{"case":"cancel_at_close","attempt":2,"signal_aborted":true,"handle_closed":true,"result":"verified"}
{"case":"cancel_at_close","attempt":3,"signal_aborted":true,"handle_closed":true,"result":"verified"}
{"case":"cleanup","result":"ok"}

Source references pinned to the reproduced revision:

Typed outcome or recovery expected

Repeat the same close-boundary cancellation and require AnalysisCancelledError after successful cleanup. Preserve the uncancelled verified result, the already-aborted typed-cancellation control, and truthful cleanup-failure reporting when close itself fails. A skipped close or unconditional cancellation does not restore the contract.

Analysis or suspected cause

Source-derived: GhidraClient.ts:395-412 checks the signal before awaiting owner.close(), then returns its saved success without another abort check. GhidraLoadImageAttest.ts:154-176 consumes that snapshot and returns DOS attestation without checking cancellation after the await. GhidraProviderClient.ts:928-954 projects the successful attestation.

Source-derived reachability is through src/cli/coreNativeCommands.ts:60-76, src/application/DirectAnalysis.ts:442-480, src/application/binary/BinarySession.ts:684-755, and the actual MCP registration and runner in src/server/registerOfficialTools.ts:28-151. This tracing does not establish an observed CLI/MCP reply or retained Evidence record after cancellation.

Additional context

Related #3334 and its open PR #3661 concern concurrent MCP snapshot-open ordering, not cancellation during a Ghidra snapshot descriptor close. Closed #3198 concerns batch/graph handling of an already typed provider cancellation with an un-aborted request signal; this report concerns the provider returning success after the request signal aborts. These require different restoration checks.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3977 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior At the Ghidra provider boundary, `inspect_native_load_image` with `{}` returned `ok: true` and `value.result.status: "verified"` for a matching DOS MZ fixture even when the caller cancelled during snapshot descriptor cleanup. This reproduced in three of three deterministic close-boundary attempts. Each descriptor closed successfully and reached fd -1 before the operation returned with its signal aborted. An uncancelled control returned `verified`; an already-aborted control returned `AnalysisCancelledError`. Both clients closed successfully. The probe used production snapshot I/O and cleanup with a simulated installation and the repository's fake Ghidra socket process. No real Ghidra engine ran. CLI interrupt delivery, MCP cancellation delivery, transport replies, and retained Evidence after cancellation were not executed. ### Reproduction The executed diagnostic was a focused temporary Vitest test beside the Ghidra adapter tests, run with `npx --no-install vitest run` selecting that test and `--maxWorkers=1`. Its assertions confirmed the incorrect baseline behavior. The temporary test was removed after the run; the following describes its setup rather than supplying a retained test file. 1. At the source revision below, prepare the locked dependencies. Isolate `HOME`, `TMPDIR`, and `REA_VERIFY_WORK` in a disposable workspace. Generate `fixtureDosLoadImage()` from `src/ghidra/GhidraLoadImage.fixture.ts` and write its unchanged bytes as `fixture.exe`, retaining the supplied digest and matching observation. 2. Start a production `GhidraClient` for that file and digest using the launcher pattern in `tests/boundary/providers/ghidra/ghidraClient.test.ts:75-129` and `tests/fixtures/fakeGhidra.mjs` in success mode. Use Unix socket transport, provider version `12.1.4`, a profile digest of 64 lowercase `a` characters, and a 5000 ms startup timeout. Pass the session endpoint, token, run ID, provider version, profile digest, target digest, and transport to the fake process through `REA_GHIDRA_FIXTURE_CONFIG`. Await successful startup. 3. Construct `GhidraProvider` following the simulated installation-host pattern in `src/ghidra/GhidraProvider.behavior.test.ts:46-78` and its DOS projection case at lines 1241-1280. The simulated installation reports Linux x64, Ghidra 12.1.4, and 64-bit OpenJDK 21.0.11. Its client factory returns the successful startup, supplies the matching fixture observation as JSON, delegates `readTargetSnapshot(maximum, signal)` unchanged to the started production client, and closes that client. 4. Admit the fixture as an executable with format `dos-mz`, architecture `x86`, available architectures `["x86"]`, and its real SHA-256. Check support, resolve a non-null bound profile, and create the provider client. Execute `inspect_native_load_image` with `{}` for the uncancelled and already-aborted controls described above. 5. For each of three attempts, create a fresh `AbortController`. Install a one-shot spy on `OwnedFileHandle.prototype.close` that aborts that controller, invokes and awaits the original close method with the same receiver, and records whether the handle's fd becomes -1. Execute `providerClient.execute("inspect_native_load_image", {}, { signal: controller.signal })`, then restore the spy. 6. Observe one hook invocation, successful closure, an aborted signal, and successful `verified` output in every attempt. Close both clients successfully. The CLI command `node scripts/rea.mjs inspect-native-load-image fixture.exe --provider ghidra --json` and MCP tool `inspect_native_load_image` with `{}` route to this operation by source inspection. Those interfaces were not used to inject cancellation in this reproduction. ### Expected behavior Cancellation received before the snapshot read and its required descriptor cleanup complete should produce typed cancellation after successful cleanup. The uncancelled fixture should retain its `verified` result. The general provider signal contract is in `docs/mcp-contracts.md:418-421`. `docs/ghidra-provider.md:38` states that established Ghidra operations wait for their reply or caller cancellation. `docs/user-visible-errors.csv:24` documents caller cancellation. The operation admits empty parameters and DOS verification in `src/contracts/officialToolContracts.ts:45-49`. ### Impact and scope P2: a read-only inspection reports success after cancellation at a narrow asynchronous cleanup boundary. Three deterministic attempts failed; ordinary-use frequency is unknown. No target-byte changes, descriptor leak, or broad workflow outage were observed. No existing REA option was verified to restore the typed cancellation outcome. DOS COM shares this branch by source inspection but was not executed. Raw-binary attestation has a post-await abort check and is outside this report. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860 The cited files are unchanged on current origin/main fc712b3750cbe16755c4f78c1f90fdf09fbaca31. The defect remains present by source inspection there; the runtime diagnostic was run at the baseline above. ### Runtime and platform Node 24.18.0; macOS arm64. Simulated Linux x64 Ghidra installation reporting 12.1.4 and OpenJDK 21.0.11; repository fake socket provider, not a real engine. ### Target and artifact context Repository-owned matching DOS MZ x86 fixture from `fixtureDosLoadImage()`, its SHA-256, and matching provider observation. Production immutable-snapshot admission, reading, validation, and descriptor cleanup ran unchanged. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed diagnostic output: ```json {"case":"control","signal_aborted":false,"result":"verified"} {"case":"already_aborted","result":"AnalysisCancelledError"} {"case":"cancel_at_close","attempt":1,"signal_aborted":true,"handle_closed":true,"result":"verified"} {"case":"cancel_at_close","attempt":2,"signal_aborted":true,"handle_closed":true,"result":"verified"} {"case":"cancel_at_close","attempt":3,"signal_aborted":true,"handle_closed":true,"result":"verified"} {"case":"cleanup","result":"ok"} ``` Source references pinned to the reproduced revision: - [Snapshot reader and awaited descriptor close](https://github.com/morluto/rea/blob/4692bb33300c7cb77b095b6cc0c33da861c1b860/src/ghidra/GhidraClient.ts#L395). - [DOS attestation after the snapshot await](https://github.com/morluto/rea/blob/4692bb33300c7cb77b095b6cc0c33da861c1b860/src/ghidra/GhidraLoadImageAttest.ts#L154). - [Provider attestation and successful projection](https://github.com/morluto/rea/blob/4692bb33300c7cb77b095b6cc0c33da861c1b860/src/ghidra/GhidraProviderClient.ts#L928). ### Typed outcome or recovery expected Repeat the same close-boundary cancellation and require `AnalysisCancelledError` after successful cleanup. Preserve the uncancelled `verified` result, the already-aborted typed-cancellation control, and truthful cleanup-failure reporting when close itself fails. A skipped close or unconditional cancellation does not restore the contract. ### Analysis or suspected cause Source-derived: `GhidraClient.ts:395-412` checks the signal before awaiting `owner.close()`, then returns its saved success without another abort check. `GhidraLoadImageAttest.ts:154-176` consumes that snapshot and returns DOS attestation without checking cancellation after the await. `GhidraProviderClient.ts:928-954` projects the successful attestation. Source-derived reachability is through `src/cli/coreNativeCommands.ts:60-76`, `src/application/DirectAnalysis.ts:442-480`, `src/application/binary/BinarySession.ts:684-755`, and the actual MCP registration and runner in `src/server/registerOfficialTools.ts:28-151`. This tracing does not establish an observed CLI/MCP reply or retained Evidence record after cancellation. ### Additional context Related #3334 and its open PR #3661 concern concurrent MCP snapshot-open ordering, not cancellation during a Ghidra snapshot descriptor close. Closed #3198 concerns batch/graph handling of an already typed provider cancellation with an un-aborted request signal; this report concerns the provider returning success after the request signal aborts. These require different restoration checks.
Author
Owner

@yankikucuk commented on GitHub (Oct 15, 2026):

Working on this. Reproduced the close-boundary interleaving at the production snapshot reader (request aborted from the descriptor close) and at the provider (snapshot factory aborting the request): both returned verified. readTargetSnapshot now rechecks the signal after a successful descriptor close and reports the typed cancelled failure, and the DOS/DOS COM attestation applies the same post-await check the raw-binary branch already had. Uncancelled and already-aborted controls behave as before; a failed close still reports as a cleanup failure. Draft PR follows shortly.

<!-- gh-comment-id:6191463552 --> @yankikucuk commented on GitHub (Oct 15, 2026): Working on this. Reproduced the close-boundary interleaving at the production snapshot reader (request aborted from the descriptor close) and at the provider (snapshot factory aborting the request): both returned `verified`. `readTargetSnapshot` now rechecks the signal after a successful descriptor close and reports the typed cancelled failure, and the DOS/DOS COM attestation applies the same post-await check the raw-binary branch already had. Uncancelled and already-aborted controls behave as before; a failed close still reports as a cleanup failure. Draft PR follows shortly.
Author
Owner

@morluto commented on GitHub (Oct 17, 2026):

Pushed a43ce8a98 (NativeAOT measured-error context) and 9c56d09c0 (load-image consumption/typed boundary) to main after rebasing over ae029fe2f's inventory aggregate. The common source-reader repair was already shipped in #4752; this change completes consumer-side cancellation and fact retention.

The provider now consumes the inventory owner's typed load-image observation once. DOS/COM/raw comparisons share a final post-await cancellation check; valid completed comparisons retain their report and exact abort reason. A pre-read cancellation retains measured facts. Independent snapshot/output failures remain primary. Source file/NativeAOT partial facts and exact cleanup capabilities survive alongside measured mappings/relocations/digests, including after cleanup leases release. Redundant downstream observation parsing and separate format-specific abort guards were removed.

Baseline5120eb441: corrected consumer controls6failed/2passed. DOS MZ and COM returned success after a fulfilled snapshot callback aborted; raw and already-cancelled cases lost reasons/facts. The initial raw fixture control was invalid, corrected before the retained baseline rerun. These consumer callbacks are simulated, rather than an OS-close reproduction.

Final rebased validation:139 checks across six contract/value/error/provider-CLI-SDK-MCP projection files,41 selected provider/NativeAOT checks (79 deselected),24 selected actual private-file/socket lifecycle checks (60 deselected); typecheck/lint, builds and commit/pre-push hooks passed. The two DOS boundary controls use a production GhidraClient and actual private snapshot I/O with a small authored fake-Ghidra Node/socket process: one aborts after confirmed descriptor close(fd -1), the other after the successful reader returns but before consumer comparison. Both return typed cancellation with the exact object reason, retained facts and successful final client close. A source-owner control also retains prior snapshot facts and cleanup when a later caller contradicts its image-base identity.

CLI/MCP checks qualify error projection through existing adapters and installed SDK in-memory transport with controlled session failures. Real Ghidra, investigated target execution, real-engine interruption delivery, CLI signal delivery and MCP wire cancellation delivery remain unverified. No heavy CPU test, stress or Flameox run occurred.

Small completed tasks against ae029fe2f preserve byte-identical full replies and one source read. Warm medians baseline/candidate:

Task Reply tokens Elapsed ms CPU ms OS family peak MB
DOS inspection+close 3295/3295 127.2/125.2 150.6/144.7 104.4/102.2
Raw inspection+close 2431/2431 127.1/125.8 136.4/138.5 101.9/101.7
NativeAOT summary+cached detail+close 2399/2399 132.1/125.5 138.6/136.5 103.6/102.4

Same tiny source-owned fixtures; simulated installation/session/snapshot factories. Separate first processes and two alternating warm repeats per scenario, no true cold cache or statistical speed claim. Each measured task has one Node process/no children, so OS wait4 process peak is its known family peak; periodic RSS samples are incomplete estimates. Raw cl100k replies only; whole-agent/catalog tokens, large inputs, real-engine and injected-failure costs unmeasured. Both owned baseline worktrees were verified and removed.

Context7 primary Node AbortSignal documentation and supplemental DeepWiki research informed the review; the completion policy follows docs/tool-design.md#own-the-lifecycle and is established by the controlled boundaries above. Broader PE section/overlay and format-attestation failure-detail audit remains active.

<!-- gh-comment-id:6233048392 --> @morluto commented on GitHub (Oct 17, 2026): Pushed `a43ce8a98` (NativeAOT measured-error context) and `9c56d09c0` (load-image consumption/typed boundary) to main after rebasing over `ae029fe2f`'s inventory aggregate. The common source-reader repair was already shipped in #4752; this change completes consumer-side cancellation and fact retention. The provider now consumes the inventory owner's typed load-image observation once. DOS/COM/raw comparisons share a final post-await cancellation check; valid completed comparisons retain their report and exact abort reason. A pre-read cancellation retains measured facts. Independent snapshot/output failures remain primary. Source file/NativeAOT partial facts and exact cleanup capabilities survive alongside measured mappings/relocations/digests, including after cleanup leases release. Redundant downstream observation parsing and separate format-specific abort guards were removed. Baseline5120eb441: corrected consumer controls6failed/2passed. DOS MZ and COM returned success after a fulfilled snapshot callback aborted; raw and already-cancelled cases lost reasons/facts. The initial raw fixture control was invalid, corrected before the retained baseline rerun. These consumer callbacks are simulated, rather than an OS-close reproduction. Final rebased validation:139 checks across six contract/value/error/provider-CLI-SDK-MCP projection files,41 selected provider/NativeAOT checks (79 deselected),24 selected actual private-file/socket lifecycle checks (60 deselected); typecheck/lint, builds and commit/pre-push hooks passed. The two DOS boundary controls use a production GhidraClient and actual private snapshot I/O with a small authored fake-Ghidra Node/socket process: one aborts after confirmed descriptor close(fd -1), the other after the successful reader returns but before consumer comparison. Both return typed cancellation with the exact object reason, retained facts and successful final client close. A source-owner control also retains prior snapshot facts and cleanup when a later caller contradicts its image-base identity. CLI/MCP checks qualify error projection through existing adapters and installed SDK in-memory transport with controlled session failures. Real Ghidra, investigated target execution, real-engine interruption delivery, CLI signal delivery and MCP wire cancellation delivery remain unverified. No heavy CPU test, stress or Flameox run occurred. Small completed tasks against ae029fe2f preserve byte-identical full replies and one source read. Warm medians baseline/candidate: | Task | Reply tokens | Elapsed ms | CPU ms | OS family peak MB | |---|---:|---:|---:|---:| | DOS inspection+close |3295/3295|127.2/125.2|150.6/144.7|104.4/102.2| | Raw inspection+close |2431/2431|127.1/125.8|136.4/138.5|101.9/101.7| | NativeAOT summary+cached detail+close |2399/2399|132.1/125.5|138.6/136.5|103.6/102.4| Same tiny source-owned fixtures; simulated installation/session/snapshot factories. Separate first processes and two alternating warm repeats per scenario, no true cold cache or statistical speed claim. Each measured task has one Node process/no children, so OS wait4 process peak is its known family peak; periodic RSS samples are incomplete estimates. Raw cl100k replies only; whole-agent/catalog tokens, large inputs, real-engine and injected-failure costs unmeasured. Both owned baseline worktrees were verified and removed. Context7 primary Node AbortSignal documentation and supplemental DeepWiki research informed the review; the completion policy follows docs/tool-design.md#own-the-lifecycle and is established by the controlled boundaries above. Broader PE section/overlay and format-attestation failure-detail audit remains active.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1571
No description provided.