[GH-ISSUE #6115] [Bug] Cancelled binary switches lose cancellation when previous-client retirement fails #3746

Closed
opened 2026-10-31 00:43:36 +00:00 by bsakaguchi · 0 comments
Owner

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

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

When a binary switch is aborted while the previous analysis client is closing, and that close then fails, BinarySession.open returns the cleanup failure without any cancellation primary or caller abort reason. The same controlled close failure produces the same error without cancellation.

Executed through the real BinarySession and provider-router composition with a simulated AnalysisClient. The cancelled failed-retirement case returned ProviderAdapterError, cleanupPrimary absent, open false and cleanup_pending true. A cancelled successful-retirement control returned AnalysisCancelledError with the exact reason and restored the previous target. Pending cleanup was successfully retried by session.close. No real engine or public transport ran.

Reproduction

Use a prepared checkout with the source revision below and locked dependencies. Save the following as src/application/cancellationReason.repro.test.ts, then run only that file with the repository-pinned Node.js 24.18.0:

node node_modules/vitest/vitest.mjs run src/application/cancellationReason.repro.test.ts --maxWorkers 1

The assertions characterize the current behavior, so a passing test demonstrates the defect and its controls. Remove the test after running it.

import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { expect, it } from 'vitest';
import { inspectDartAot } from './flutter/DartAotInspection.js';
import { FlutterBuildAnalysisService } from './flutter/FlutterBuildAnalysisService.js';
import { FlutterBuildProvider } from '../flutter/FlutterBuildProvider.js';
import { inspectAnalysisViewWithFiles } from './analysisView/AnalysisViewService.js';
import { readEvidenceFileSource } from './analysisView/evidenceFileSource.js';
import { BinarySession } from './binary/BinarySession.js';
import { InvestigationRecords } from './investigation/InvestigationRecords.js';
import { createTestProviderRouter } from '../../tests/fixtures/binarySession.js';
import { createAnalysisExecution, type AnalysisClient } from './AnalysisProvider.js';
import { ProviderAdapterError } from '../domain/providerAdapterError.js';
import { createAnalysisProfile } from '../domain/analysisProfile.js';
import { elf } from '../domain/binaryTarget.fixture.js';
import { ok, err } from '../domain/result.js';

it.each(['cancel-fail', 'no-cancel-fail', 'cancel-success'] as const)('Binary retirement %s', async mode => {
  const root = await mkdtemp(join(tmpdir(), 'rea-cancellation-test-'));
  const target = join(root, 'alternate.elf');
  await writeFile(target, elf(2, 1, 62));
  let started!: () => void; const closing = new Promise<void>(r => { started = r; });
  let release!: () => void; const gate = new Promise<void>(r => { release = r; });
  let attempts = 0;
  const identity = { id: 'fixture', name: 'Fixture analysis provider', version: '1' };
  const physical = new ProviderAdapterError(identity.id, 'close_binary');
  const client: AnalysisClient = {
    execute: async operation => ok(createAnalysisExecution(operation === 'health' ? null : 'name', identity)),
    close: async () => {
      attempts++;
      if (attempts === 1) { started(); await gate; if (mode !== 'cancel-success') return err(physical); }
      return ok(null);
    },
  };
  const session = new BinarySession(createTestProviderRouter(() => client, {
    resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
  }), new InvestigationRecords());
  const controller = new AbortController(); const reason = { phase: 'binary-retirement' };
  try {
    expect((await session.open(process.execPath)).ok).toBe(true);
    expect((await session.execute('address_name', {})).ok).toBe(true);
    const running = session.open(target, { signal: controller.signal });
    await closing;
    if (mode !== 'no-cancel-fail') controller.abort(reason);
    release();
    const result = await running;
    expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected failure');
    const status = await session.status();
    if (mode === 'cancel-success') {
      expect(result.error._tag).toBe('AnalysisCancelledError'); expect(result.error.cause).toBe(reason);
      expect(status).toMatchObject({ open: true, cleanup_pending: false });
    } else {
      expect(result.error.cleanupPrimary).toBeUndefined();
      expect(result.error.cause).not.toBe(reason);
      expect(status).toMatchObject({ open: false, cleanup_pending: true });
    }
    console.log('BINARY', JSON.stringify({ mode, tag: result.error._tag, primary: result.error.cleanupPrimary?._tag ?? null, exactReason: result.error.cause === reason, open: status.open, cleanupPending: status.cleanup_pending }));
  } finally { release(); expect((await session.close()).ok).toBe(true); await rm(root, { recursive: true, force: true }); }
});

Expected behavior

When cancellation is observed during previous-client retirement, retain AnalysisCancelledError("open_binary", { cause: signal.reason }) as the primary outcome and attach incomplete cleanup separately. Keep the failed client pending for owner-confirmed retry. The lifecycle contract requires caller reason retention through awaited cleanup; the sibling openBundleResources path already composes this situation with the existing cleanup tracker.

Impact and scope

P2: this bounded combined abort/close-failure path loses the cancellation classification and reason used by lifecycle consumers. The retained resource is still reported as cleanup_pending, and the executed retry succeeded. No lost target data, forgotten cleanup owner or successful replacement startup was observed. Natural provider-close failures, real engine behavior and MCP wire delivery were not measured. Callers retaining the original signal can inspect it and retry session.close; the error alone lacks the cancellation fact.

REA version

rea-agents 6.7.0; source revision 5241597594.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin), locked Vitest 5.0.3. The initial nine-case run also reproduced on supported Node.js v26.12.0. The final combined focused run passed ten characterization checks on Node v24.18.0.

Target and artifact context

The running Node executable is admitted as the initial binary without executing target code; an inert ELF64 x86-64 fixture at a different path triggers replacement. The provider client is simulated. No Hopper, Ghidra, IDA or Kuna engine is started.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed diagnostics from the focused run:

BINARY {"mode":"cancel-fail","tag":"ProviderAdapterError","primary":null,"exactReason":false,"open":false,"cleanupPending":true}
BINARY {"mode":"no-cancel-fail","tag":"ProviderAdapterError","primary":null,"exactReason":false,"open":false,"cleanupPending":true}
BINARY {"mode":"cancel-success","tag":"AnalysisCancelledError","primary":null,"exactReason":true,"open":true,"cleanupPending":false}

The tests assert exact reason identity and the reported typed result. They do not assert that an already-cancelled MCP client receives a response.

Typed outcome or recovery expected

An incomplete-cleanup error retaining an AnalysisCancelledError primary whose cause is exactly signal.reason, plus the failed retirement owner and retry state. An un-aborted close failure should keep its normal error. Do not restore or replace the previous target while its cleanup remains unconfirmed.

Analysis or suspected cause

The failed-retirement branch returns closed before the following abort check can build cancellation. The existing cleanup tracker can preserve an abort observed during cleanup without losing the physical close failure. The open_binary registration passes its request signal to session.open. This identifies a distinct composition omission rather than a failure of the provider close owner.

Additional context

#5921 concerns restoring a profileless native workflow after a failed switch; #2657 concerns restored runtime availability after successful retirement; #5968 concerns snapshot-read operation attribution. #4628 and #5059 concern an existing primary during later error composition; this branch never creates a cancellation primary. PR #1260 retains failed clients for retry, which works in this reproduction, and PR #4238 retains session lineage and one-shot/composite observations. Neither covers cancellation composition on this failed previous-client retirement branch. #4638 and closed PR #4711 concern other producers. The relevant source is unchanged on checked upstream main d8a9673fc4. That newer revision was not executed.

Other cancellation-reason reports cover separate producer boundaries: #5935, #5944, #5959, #5960, #5964, #5985, #5987, #5998, #5999, #6002, #6032, #6037, #6042, #6100, #6101, #6110, #6113. These are related provenance defects; this report concerns cancellation composition when previous-client retirement fails.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6115 ### 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 When a binary switch is aborted while the previous analysis client is closing, and that close then fails, BinarySession.open returns the cleanup failure without any cancellation primary or caller abort reason. The same controlled close failure produces the same error without cancellation. Executed through the real BinarySession and provider-router composition with a simulated AnalysisClient. The cancelled failed-retirement case returned ProviderAdapterError, cleanupPrimary absent, open false and cleanup_pending true. A cancelled successful-retirement control returned AnalysisCancelledError with the exact reason and restored the previous target. Pending cleanup was successfully retried by session.close. No real engine or public transport ran. ### Reproduction Use a prepared checkout with the source revision below and locked dependencies. Save the following as `src/application/cancellationReason.repro.test.ts`, then run only that file with the repository-pinned Node.js 24.18.0: ```sh node node_modules/vitest/vitest.mjs run src/application/cancellationReason.repro.test.ts --maxWorkers 1 ``` The assertions characterize the current behavior, so a passing test demonstrates the defect and its controls. Remove the test after running it. ```ts import { writeFile, mkdtemp, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { expect, it } from 'vitest'; import { inspectDartAot } from './flutter/DartAotInspection.js'; import { FlutterBuildAnalysisService } from './flutter/FlutterBuildAnalysisService.js'; import { FlutterBuildProvider } from '../flutter/FlutterBuildProvider.js'; import { inspectAnalysisViewWithFiles } from './analysisView/AnalysisViewService.js'; import { readEvidenceFileSource } from './analysisView/evidenceFileSource.js'; import { BinarySession } from './binary/BinarySession.js'; import { InvestigationRecords } from './investigation/InvestigationRecords.js'; import { createTestProviderRouter } from '../../tests/fixtures/binarySession.js'; import { createAnalysisExecution, type AnalysisClient } from './AnalysisProvider.js'; import { ProviderAdapterError } from '../domain/providerAdapterError.js'; import { createAnalysisProfile } from '../domain/analysisProfile.js'; import { elf } from '../domain/binaryTarget.fixture.js'; import { ok, err } from '../domain/result.js'; it.each(['cancel-fail', 'no-cancel-fail', 'cancel-success'] as const)('Binary retirement %s', async mode => { const root = await mkdtemp(join(tmpdir(), 'rea-cancellation-test-')); const target = join(root, 'alternate.elf'); await writeFile(target, elf(2, 1, 62)); let started!: () => void; const closing = new Promise<void>(r => { started = r; }); let release!: () => void; const gate = new Promise<void>(r => { release = r; }); let attempts = 0; const identity = { id: 'fixture', name: 'Fixture analysis provider', version: '1' }; const physical = new ProviderAdapterError(identity.id, 'close_binary'); const client: AnalysisClient = { execute: async operation => ok(createAnalysisExecution(operation === 'health' ? null : 'name', identity)), close: async () => { attempts++; if (attempts === 1) { started(); await gate; if (mode !== 'cancel-success') return err(physical); } return ok(null); }, }; const session = new BinarySession(createTestProviderRouter(() => client, { resolveAnalysisProfile: async () => ok({ profile: createAnalysisProfile(identity, { fixture: true }) }), }), new InvestigationRecords()); const controller = new AbortController(); const reason = { phase: 'binary-retirement' }; try { expect((await session.open(process.execPath)).ok).toBe(true); expect((await session.execute('address_name', {})).ok).toBe(true); const running = session.open(target, { signal: controller.signal }); await closing; if (mode !== 'no-cancel-fail') controller.abort(reason); release(); const result = await running; expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected failure'); const status = await session.status(); if (mode === 'cancel-success') { expect(result.error._tag).toBe('AnalysisCancelledError'); expect(result.error.cause).toBe(reason); expect(status).toMatchObject({ open: true, cleanup_pending: false }); } else { expect(result.error.cleanupPrimary).toBeUndefined(); expect(result.error.cause).not.toBe(reason); expect(status).toMatchObject({ open: false, cleanup_pending: true }); } console.log('BINARY', JSON.stringify({ mode, tag: result.error._tag, primary: result.error.cleanupPrimary?._tag ?? null, exactReason: result.error.cause === reason, open: status.open, cleanupPending: status.cleanup_pending })); } finally { release(); expect((await session.close()).ok).toBe(true); await rm(root, { recursive: true, force: true }); } }); ``` ### Expected behavior When cancellation is observed during previous-client retirement, retain AnalysisCancelledError("open_binary", { cause: signal.reason }) as the primary outcome and attach incomplete cleanup separately. Keep the failed client pending for owner-confirmed retry. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L115) requires caller reason retention through awaited cleanup; the sibling [openBundleResources path](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L276-L302) already composes this situation with the existing cleanup tracker. ### Impact and scope P2: this bounded combined abort/close-failure path loses the cancellation classification and reason used by lifecycle consumers. The retained resource is still reported as cleanup_pending, and the executed retry succeeded. No lost target data, forgotten cleanup owner or successful replacement startup was observed. Natural provider-close failures, real engine behavior and MCP wire delivery were not measured. Callers retaining the original signal can inspect it and retry session.close; the error alone lacks the cancellation fact. ### REA version rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin), locked Vitest 5.0.3. The initial nine-case run also reproduced on supported Node.js v26.12.0. The final combined focused run passed ten characterization checks on Node v24.18.0. ### Target and artifact context The running Node executable is admitted as the initial binary without executing target code; an inert ELF64 x86-64 fixture at a different path triggers replacement. The provider client is simulated. No Hopper, Ghidra, IDA or Kuna engine is started. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed diagnostics from the focused run: ```text BINARY {"mode":"cancel-fail","tag":"ProviderAdapterError","primary":null,"exactReason":false,"open":false,"cleanupPending":true} BINARY {"mode":"no-cancel-fail","tag":"ProviderAdapterError","primary":null,"exactReason":false,"open":false,"cleanupPending":true} BINARY {"mode":"cancel-success","tag":"AnalysisCancelledError","primary":null,"exactReason":true,"open":true,"cleanupPending":false} ``` The tests assert exact reason identity and the reported typed result. They do not assert that an already-cancelled MCP client receives a response. ### Typed outcome or recovery expected An incomplete-cleanup error retaining an AnalysisCancelledError primary whose cause is exactly signal.reason, plus the failed retirement owner and retry state. An un-aborted close failure should keep its normal error. Do not restore or replace the previous target while its cleanup remains unconfirmed. ### Analysis or suspected cause The [failed-retirement branch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L583-L603) returns closed before the following abort check can build cancellation. The existing [cleanup tracker](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L90-L133) can preserve an abort observed during cleanup without losing the physical close failure. The [open_binary registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerSessionTools.ts#L184-L199) passes its request signal to session.open. This identifies a distinct composition omission rather than a failure of the provider close owner. ### Additional context #5921 concerns restoring a profileless native workflow after a failed switch; #2657 concerns restored runtime availability after successful retirement; #5968 concerns snapshot-read operation attribution. #4628 and #5059 concern an existing primary during later error composition; this branch never creates a cancellation primary. PR #1260 retains failed clients for retry, which works in this reproduction, and PR #4238 retains session lineage and one-shot/composite observations. Neither covers cancellation composition on this failed previous-client retirement branch. #4638 and closed PR #4711 concern other producers. The relevant source is unchanged on checked upstream main d8a9673fc44fde4bd30ff98c241886e6683580ca. That newer revision was not executed. Other cancellation-reason reports cover separate producer boundaries: [#5935](https://github.com/morluto/rea/issues/5935), [#5944](https://github.com/morluto/rea/issues/5944), [#5959](https://github.com/morluto/rea/issues/5959), [#5960](https://github.com/morluto/rea/issues/5960), [#5964](https://github.com/morluto/rea/issues/5964), [#5985](https://github.com/morluto/rea/issues/5985), [#5987](https://github.com/morluto/rea/issues/5987), [#5998](https://github.com/morluto/rea/issues/5998), [#5999](https://github.com/morluto/rea/issues/5999), [#6002](https://github.com/morluto/rea/issues/6002), [#6032](https://github.com/morluto/rea/issues/6032), [#6037](https://github.com/morluto/rea/issues/6037), [#6042](https://github.com/morluto/rea/issues/6042), [#6100](https://github.com/morluto/rea/issues/6100), [#6101](https://github.com/morluto/rea/issues/6101), [#6110](https://github.com/morluto/rea/issues/6110), [#6113](https://github.com/morluto/rea/issues/6113). These are related provenance defects; this report concerns cancellation composition when previous-client retirement fails.
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#3746
No description provided.