[GH-ISSUE #6002] [Bug] WABT cancellation uses the command wrapper instead of the caller reason #3670

Closed
opened 2026-10-31 00:42:55 +00:00 by bsakaguchi · 1 comment
Owner

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

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

Cancellation of an active command through WabtArtifactProvider.inspect returns AnalysisCancelledError, but its direct cause is an OwnedCommandFailure rather than the supplied abort reason. The same wrapper retains cancellation.reason === callerReason. Thus the shared command owner has already preserved the value; WABT's failure translator fails to select it.

The reproduction ran production provider/owned-process code with simulated WABT responses and real Node child processes. Cancellation was synchronized to a ready marker from the active simulated validator. Cleanup completed and the owned child was no longer alive. No real WABT engine, CLI signal path or MCP cancellation transport was exercised.

Reproduction

Add this focused test under src/wabtCancellationReason.test.ts, then run node_modules/.bin/vitest run src/wabtCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism. It was executed in a combined focused file with two ZIP controls. The final exact-cause assertion failed after the wrapper-provenance and cleanup assertions passed. The diagnostic run asserting the actual wrapper result passed.

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, it } from 'vitest';
import { WabtArtifactProvider } from './wasm/WabtArtifactProvider.js';
import { spawnOwnedProviderProcess } from './process/ProviderProcess.js';
import { OwnedCommandFailure } from './process/OwnedCommand.js';
import { projectAnalysisError } from './domain/analysisErrorProjection.js';

it('WABT active command cancellation preserves reason in owner but wraps it in typed error', async () => {
 const root = await mkdtemp(join(tmpdir(), 'rea-wabt-reason-'));
 const path = join(root, 'selected.wasm'); await writeFile(path, Buffer.from([0,97,115,109,1,0,0,0]));
 for (const tool of ['wasm-validate', 'wasm-objdump', 'wasm2wat']) await writeFile(join(root,tool), 'simulated WABT tool', { mode: 0o700 });
 const controller = new AbortController(); const reason = { requested: 'stop WABT' }; let pid: number | undefined;
 const provider = new WabtArtifactProvider({ REA_WABT_BIN_DIRECTORY: root }, async input => {
  const version = input.arguments[0] === '--version';
  const launched = await spawnOwnedProviderProcess({ ...input, command: process.execPath, expectedCommand: null,
   arguments: ['-e', version ? 'process.stdout.write("1.0.42\\n")' : 'process.stdout.write("ready\\n"); setInterval(() => {}, 1000)'] });
  if (!version) { pid = launched.process.pid; launched.process.stdout?.once('data', () => controller.abort(reason)); }
  return launched;
 });
 try {
  const result = await provider.inspect({ path, glue_paths: [], candidate_paths: [] }, { signal: controller.signal });
  expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected cancellation');
  const owner = result.error.cause;
  console.log('wabt-provider', { tag: result.error._tag, causeTag: owner instanceof Error ? owner.name : typeof owner, ownedWrapper: owner instanceof OwnedCommandFailure, exactReason: owner === reason, ownerRetainsReason: owner instanceof OwnedCommandFailure && owner.cancellation?.reason === reason, code: projectAnalysisError(result.error).code, cleanupIncomplete: result.error.cleanupIncomplete });
  expect(result.error._tag).toBe('AnalysisCancelledError'); expect(owner).toBeInstanceOf(OwnedCommandFailure);
  if (!(owner instanceof OwnedCommandFailure)) throw new Error('Expected owned wrapper');
  expect(owner.cancellation?.reason).toBe(reason); 
  expect(owner.cleanupFailure).toBeNull();
  if (pid !== undefined) expect(() => process.kill(pid!, 0)).toThrow();
  expect(result.error.cause).toBe(reason);
 } finally { await provider.close(); await rm(root, { recursive: true, force: true }); }
}, 60000);

Expected behavior

The lifecycle contract requires the typed cancellation cause to preserve the exact caller reason carried by the command owner. Consume OwnedCommandFailure.cancellation.reason when that record exists; keep physical failures, captured output and actual cleanup ownership separate.

Impact and scope

P2: bounded typed-error contract violation for active WABT command cancellation. Cancellation and owned-process cleanup still succeed. A consumer expecting the direct cause receives an internal lifecycle wrapper instead; inspecting that wrapper's cancellation record provides a workaround. The original reason remains recoverable. No real-client decision error, data loss, process escape, security boundary crossing or natural failure frequency was established. This does not imply that arbitrary causes should appear in public JSON.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; package metadata reports 6.7.0.

Runtime and platform

macOS 27.0.1 arm64, Node v26.12.0; repository-pinned Vitest 5.0.3. WABT protocol simulated at 1.0.42; no actual WABT executable used.

Target and artifact context

Generated minimal core-module header; trusted simulated tool files and owned Node subprocesses supplied through the existing launcher seam. No WASM module instantiated or executed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Executed diagnostics:

result.error._tag = AnalysisCancelledError
projectAnalysisError(result.error).code = cancelled
result.error.cause instanceof OwnedCommandFailure = true
result.error.cause === callerReason = false
result.error.cause.cancellation.reason === callerReason = true
cleanupIncomplete = false
OwnedCommandFailure.cleanupFailure = null
Owned child PID: no longer alive
Expected direct-cause assertion: failed (received OwnedCommandFailure)

The provider command call supplies the caller signal, and its catch invokes wabtFailure. The owned-command finalization retains the reason. WabtFailures builds generic options with the wrapper as cause, then reuses them for cancellation. Source-derived public reachability: MCP registration, service delegation, and CLI invocation.

Typed outcome or recovery expected

Keep AnalysisCancelledError for inspect_wasm_artifact, with its direct cause equal to the exact command-owned cancellation reason. Preserve null and other opaque values without inferring cleanup authority. Existing cleanup/physical-failure precedence should remain intact.

Analysis or suspected cause

Source-derived: the cancelled branch reuses the same { cause } options as other command failures. Here cause is the OwnedCommandFailure. The branch needs to consume its explicit cancellation record. Fixing the shared command owner alone cannot repair this boundary because that owner already retains the exact value in the reproduction.

Additional context

Related reports cover the same cancellation contract at different producers: #5935 (JavaScript artifact reader), #5944 (derived MCP operations), #5959 (Rizin), #5960 (call-path traversal), #5964 (JavaScript recovery), #5985 (managed entry), and #5987 (static JavaScript checkpoints). Those producer fixes do not change the boundary above.

Closed #4638 covers shared OwnedCommand provenance; that part already works here. #4254 concerns cancellation observed after successful work during final root removal, #4787 stale cleanup status after retry, #4798 command cleanup ownership, and #4939 final CLI close. Those have different fixes. PR #5768 adds configuration diagnostics and PR #5798 adjusts deadlines; neither changes cancellation cause selection.

Fetched upstream main c538a843cb49f7156f86beb3b21501e03255af9a and source-compared WabtFailures, WabtArtifactProvider and OwnedCommand: unchanged from the executed revision. The newer revision was not executed. NOT_ENOUGH_INFO: introducing commit, last known good release, real WABT cancellation frequency, or other OS qualification.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6002 ### 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 Cancellation of an active command through `WabtArtifactProvider.inspect` returns `AnalysisCancelledError`, but its direct cause is an `OwnedCommandFailure` rather than the supplied abort reason. The same wrapper retains `cancellation.reason === callerReason`. Thus the shared command owner has already preserved the value; WABT's failure translator fails to select it. The reproduction ran production provider/owned-process code with simulated WABT responses and real Node child processes. Cancellation was synchronized to a ready marker from the active simulated validator. Cleanup completed and the owned child was no longer alive. No real WABT engine, CLI signal path or MCP cancellation transport was exercised. ### Reproduction Add this focused test under `src/wabtCancellationReason.test.ts`, then run `node_modules/.bin/vitest run src/wabtCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism`. It was executed in a combined focused file with two ZIP controls. The final exact-cause assertion failed after the wrapper-provenance and cleanup assertions passed. The diagnostic run asserting the actual wrapper result passed. ```ts import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from 'vitest'; import { WabtArtifactProvider } from './wasm/WabtArtifactProvider.js'; import { spawnOwnedProviderProcess } from './process/ProviderProcess.js'; import { OwnedCommandFailure } from './process/OwnedCommand.js'; import { projectAnalysisError } from './domain/analysisErrorProjection.js'; it('WABT active command cancellation preserves reason in owner but wraps it in typed error', async () => { const root = await mkdtemp(join(tmpdir(), 'rea-wabt-reason-')); const path = join(root, 'selected.wasm'); await writeFile(path, Buffer.from([0,97,115,109,1,0,0,0])); for (const tool of ['wasm-validate', 'wasm-objdump', 'wasm2wat']) await writeFile(join(root,tool), 'simulated WABT tool', { mode: 0o700 }); const controller = new AbortController(); const reason = { requested: 'stop WABT' }; let pid: number | undefined; const provider = new WabtArtifactProvider({ REA_WABT_BIN_DIRECTORY: root }, async input => { const version = input.arguments[0] === '--version'; const launched = await spawnOwnedProviderProcess({ ...input, command: process.execPath, expectedCommand: null, arguments: ['-e', version ? 'process.stdout.write("1.0.42\\n")' : 'process.stdout.write("ready\\n"); setInterval(() => {}, 1000)'] }); if (!version) { pid = launched.process.pid; launched.process.stdout?.once('data', () => controller.abort(reason)); } return launched; }); try { const result = await provider.inspect({ path, glue_paths: [], candidate_paths: [] }, { signal: controller.signal }); expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected cancellation'); const owner = result.error.cause; console.log('wabt-provider', { tag: result.error._tag, causeTag: owner instanceof Error ? owner.name : typeof owner, ownedWrapper: owner instanceof OwnedCommandFailure, exactReason: owner === reason, ownerRetainsReason: owner instanceof OwnedCommandFailure && owner.cancellation?.reason === reason, code: projectAnalysisError(result.error).code, cleanupIncomplete: result.error.cleanupIncomplete }); expect(result.error._tag).toBe('AnalysisCancelledError'); expect(owner).toBeInstanceOf(OwnedCommandFailure); if (!(owner instanceof OwnedCommandFailure)) throw new Error('Expected owned wrapper'); expect(owner.cancellation?.reason).toBe(reason); expect(owner.cleanupFailure).toBeNull(); if (pid !== undefined) expect(() => process.kill(pid!, 0)).toThrow(); expect(result.error.cause).toBe(reason); } finally { await provider.close(); await rm(root, { recursive: true, force: true }); } }, 60000); ``` ### Expected behavior The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) requires the typed cancellation cause to preserve the exact caller reason carried by the command owner. Consume `OwnedCommandFailure.cancellation.reason` when that record exists; keep physical failures, captured output and actual cleanup ownership separate. ### Impact and scope P2: bounded typed-error contract violation for active WABT command cancellation. Cancellation and owned-process cleanup still succeed. A consumer expecting the direct cause receives an internal lifecycle wrapper instead; inspecting that wrapper's cancellation record provides a workaround. The original reason remains recoverable. No real-client decision error, data loss, process escape, security boundary crossing or natural failure frequency was established. This does not imply that arbitrary causes should appear in public JSON. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; package metadata reports 6.7.0. ### Runtime and platform macOS 27.0.1 arm64, Node v26.12.0; repository-pinned Vitest 5.0.3. WABT protocol simulated at 1.0.42; no actual WABT executable used. ### Target and artifact context Generated minimal core-module header; trusted simulated tool files and owned Node subprocesses supplied through the existing launcher seam. No WASM module instantiated or executed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Executed diagnostics: ```text result.error._tag = AnalysisCancelledError projectAnalysisError(result.error).code = cancelled result.error.cause instanceof OwnedCommandFailure = true result.error.cause === callerReason = false result.error.cause.cancellation.reason === callerReason = true cleanupIncomplete = false OwnedCommandFailure.cleanupFailure = null Owned child PID: no longer alive Expected direct-cause assertion: failed (received OwnedCommandFailure) ``` [The provider command call](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/wasm/WabtArtifactProvider.ts#L178-L204) supplies the caller signal, and [its catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/wasm/WabtArtifactProvider.ts#L449-L454) invokes `wabtFailure`. The [owned-command finalization](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/OwnedCommand.ts#L392-L444) retains the reason. [WabtFailures](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/wasm/WabtFailures.ts#L111-L169) builds generic options with the wrapper as cause, then reuses them for cancellation. Source-derived public reachability: [MCP registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerWasmTools.ts#L60-L90), [service delegation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/wasm/WasmArtifactService.ts#L70-L71), and [CLI invocation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/wasmCommands.ts#L42-L85). ### Typed outcome or recovery expected Keep `AnalysisCancelledError` for `inspect_wasm_artifact`, with its direct cause equal to the exact command-owned cancellation reason. Preserve `null` and other opaque values without inferring cleanup authority. Existing cleanup/physical-failure precedence should remain intact. ### Analysis or suspected cause Source-derived: the cancelled branch reuses the same `{ cause }` options as other command failures. Here `cause` is the `OwnedCommandFailure`. The branch needs to consume its explicit cancellation record. Fixing the shared command owner alone cannot repair this boundary because that owner already retains the exact value in the reproduction. ### Additional context Related reports cover the same cancellation contract at different producers: [#5935](https://github.com/morluto/rea/issues/5935) (JavaScript artifact reader), [#5944](https://github.com/morluto/rea/issues/5944) (derived MCP operations), [#5959](https://github.com/morluto/rea/issues/5959) (Rizin), [#5960](https://github.com/morluto/rea/issues/5960) (call-path traversal), [#5964](https://github.com/morluto/rea/issues/5964) (JavaScript recovery), [#5985](https://github.com/morluto/rea/issues/5985) (managed entry), and [#5987](https://github.com/morluto/rea/issues/5987) (static JavaScript checkpoints). Those producer fixes do not change the boundary above. Closed [#4638](https://github.com/morluto/rea/issues/4638) covers shared `OwnedCommand` provenance; that part already works here. [#4254](https://github.com/morluto/rea/issues/4254) concerns cancellation observed after successful work during final root removal, [#4787](https://github.com/morluto/rea/issues/4787) stale cleanup status after retry, [#4798](https://github.com/morluto/rea/issues/4798) command cleanup ownership, and [#4939](https://github.com/morluto/rea/issues/4939) final CLI close. Those have different fixes. [PR #5768](https://github.com/morluto/rea/pull/5768) adds configuration diagnostics and [PR #5798](https://github.com/morluto/rea/pull/5798) adjusts deadlines; neither changes cancellation cause selection. Fetched upstream main `c538a843cb49f7156f86beb3b21501e03255af9a` and source-compared `WabtFailures`, `WabtArtifactProvider` and `OwnedCommand`: unchanged from the executed revision. The newer revision was not executed. NOT_ENOUGH_INFO: introducing commit, last known good release, real WABT cancellation frequency, or other OS qualification.
Author
Owner

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

Fixed on main in github.com/morluto/rea@af8f0badf6. WABT cancellation unwraps the command-owned caller reason into the typed cancellation cause.

Validation: src/wasm/WabtFailures.test.ts passed in the retained audit receipt. Null and object reasons are checked by identity as the direct cause.

Scope: Controlled production module tests; original external-provider/public-adapter replay was not rerun. Closure is scoped to this deterministic owning-boundary defect, not general provider qualification.

<!-- gh-comment-id:6376019766 --> @morluto commented on GitHub (Oct 23, 2026): Fixed on main in https://github.com/morluto/rea/commit/af8f0badf64fd3546392b6aa4625777904b2f051. WABT cancellation unwraps the command-owned caller reason into the typed cancellation cause. Validation: `src/wasm/WabtFailures.test.ts` passed in the retained audit receipt. Null and object reasons are checked by identity as the direct cause. Scope: Controlled production module tests; original external-provider/public-adapter replay was not rerun. Closure is scoped to this deterministic owning-boundary defect, not general provider qualification.
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#3670
No description provided.