[GH-ISSUE #7378] [Bug] debug-with-gdb returns successful Evidence when finalization fails or is interrupted #4384

Closed
opened 2026-10-31 00:49:24 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

CLI

Observed behavior

The registered handler for debug-with-gdb "info files" --inferior-tty /dev/pts/7 --detail complete --json returns complete gdb_console Evidence after both final cleanup attempts report incomplete, while process.exitCode becomes 1. SIGINT dispatched during the final close likewise leaves the successful Evidence unchanged while the exit status becomes 130. Neither result has a typed error code or partial-observation envelope. The retained session ID does remain in parameters.session_id.

This was observed through production CLI registration, cancellation handling, GdbSessionManager and Evidence delivery with an inert synthetic MI peer and an injected stop result. It was not a real GDB run, packaged CLI subprocess, or OS-signal timing test.

Reproduction

From a fresh checkout at the revision below, use Node 24.22.1 and install/build with npm ci && npm run build:cached. The prepared verification checkout already had matching locked dependencies, so only the build was rerun. Execute this three-case probe; its assertions characterize the defect and the successful cleanup control. The temporary filename below is the public-safe spelling of the executed test filename. The final probe ran once.

export PATH="$(mise where node@24.22.1)/bin:$PATH"
WORK=$(mktemp -d)
export HOME="$WORK/home"
export XDG_CONFIG_HOME="$HOME/config" XDG_DATA_HOME="$HOME/data" XDG_CACHE_HOME="$HOME/cache" XDG_STATE_HOME="$HOME/state" TMPDIR="$HOME/tmp"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$TMPDIR"
cat > tests/boundary/cli/gdbFinalizationProbe.test.ts <<'EOF'
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import pino from 'pino';
import { expect, it } from 'vitest';
import { registerReverseEngineeringCommands } from '../../../src/cli/reverseEngineeringCommands.js';
import type { CliInstance } from '../../../src/cli/types.js';
import { GdbSessionManager } from '../../../src/gdb/GdbSessionManager.js';

class FakeGdb extends EventEmitter {
  pid = process.pid + 1;
  stdout = new PassThrough(); stderr = new PassThrough(); stdin = new PassThrough();
  exitCode: number | null = null;
  signalCode: NodeJS.Signals | null = null;
  kill(signal: NodeJS.Signals) { this.exitCode = 0; this.signalCode = signal;
    this.stdout.end(); this.stderr.end(); this.stdin.destroy();
    this.emit('exit', 0, signal); this.emit('close', 0, signal); return true; }
}

it('observes final cleanup failure, late interruption, and a successful control', async () => {
  for (const mode of ['incomplete', 'interrupt', 'control'] as const) {
    const child = new FakeGdb();
    child.stdout.write('(gdb)\n');
    child.stdin.on('data', (chunk) => {
      for (const line of String(chunk).split('\n')) {
        const m = /^(\d+)(.*)$/u.exec(line);
        if (!m) continue;
        const payload = m[2].includes('list-thread-groups') ? ',groups=[]' :
          m[2].includes('inferior-tty-show') ? ',inferior_tty_terminal="/dev/pts/7"' : '';
        child.stdout.write(`${m[1]}^done${payload}\n`);
      }
    });
    let calls = 0;
    let recovering = false;
    let retainedId: string | undefined;
    const manager = new GdbSessionManager({
      platform: 'linux',
      spawnProcess: async (options) => ({ process: child, ownership: {
        runId: options.runId, leaderPid: child.pid, processGroupId: child.pid, expectedParentPid: process.pid,
      } }),
      stopProcess: async () => {
        calls++;
        if (mode === 'interrupt' && calls === 1) process.emit('SIGINT');
        if (mode === 'incomplete' && !recovering) return { status: 'incomplete', reason: 'synthetic stop remains pending' };
        child.kill('SIGTERM'); return { status: 'stopped' };
      },
    });
    const originalStart = manager.start.bind(manager);
    manager.start = async (...args) => {
      const result = await originalStart(...args);
      if (result.ok) retainedId = result.value.session_id;
      return result;
    };
    let handler: ((input: unknown) => Promise<any>) | undefined;
    const cli = { command(name: string, definition: {run: typeof handler}) {
      if (name === 'debug-with-gdb') handler = definition.run; return cli;
    }} as unknown as CliInstance;
    registerReverseEngineeringCommands(cli, pino({ enabled: false }), {}, () => manager);
    const previous = process.exitCode;
    try {
      process.exitCode = 0;
      const result = await handler!({ args: { command: 'info files' }, options: { inferiorTty: '/dev/pts/7', detail: 'complete' } });
      const retained = retainedId !== undefined && manager.status(retainedId) !== undefined;
      const observation = { mode, stops: calls, exitCode: process.exitCode, operation: result.operation,
        completion: result.normalized_result?.completion_status, code: result.code ?? null,
        cleanup: result.cleanup_incomplete ?? null, partialObservation: result.details?.partial_observation ?? null, retained, sessionIdentityInEvidence: JSON.stringify(result).includes(retainedId!) };
      console.log(JSON.stringify(observation));
      expect(observation).toEqual({ mode, stops: mode === 'incomplete' ? 2 : 1,
        exitCode: mode === 'incomplete' ? 1 : mode === 'interrupt' ? 130 : 0,
        operation: 'gdb_console', completion: 'complete', code: null, cleanup: null, partialObservation: null,
        retained: mode === 'incomplete', sessionIdentityInEvidence: true });
    } finally {
      recovering = true;
      await manager.closeAll();
      process.exitCode = previous;
    }
  }
});
EOF
npx vitest run tests/boundary/cli/gdbFinalizationProbe.test.ts
rm tests/boundary/cli/gdbFinalizationProbe.test.ts

Expected behavior

After awaited finalization, return typed cancellation with the completed Evidence as details.partial_observation when interrupted, or typed cleanup_incomplete with unresolved resources and recovery information when cleanup cannot be confirmed. Preserve the completed observation and keep ordinary successful cleanup successful. docs/tool-design.md:181-190,201-223 requires these finalization checks and retained observations; docs/testing.md:528 specifies the late-cancellation and non-aborted control.

Impact and scope

P2: structured consumers see successful Evidence despite unsuccessful or interrupted finalization. Checking the process exit status distinguishes these cases, but does not restore the missing typed finalization state. Evidence bytes and session identity survive; no target damage or data loss was observed. Real-provider frequency and natural cleanup-failure timing are unknown. This report covers the one-shot GDB CLI finalizer.

REA version

Exact node scripts/rea.mjs --version output: 6.8.0. Source main: 82d3ab7aa8a41e702a876004be3e73d61ed70443; rebuilt before verification.

Runtime and platform

Node v24.22.1; macOS 27.0.1; darwin arm64; locked Vitest 5.0.3. The manager explicitly selects its supported Linux admission path over a synthetic provider; no Linux host or native GDB compatibility claim is made.

Target and artifact context

No target artifact or inferior. In-memory PassThrough streams emit valid GDB/MI prompt, configuration, command-completion and empty-inferior records. The TTY is a fixture string and is never opened. Teardown changes the stop fixture to success and closes retained manager state.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed: one focused test passed, executing these three sequential cases. Null code in the instrumentation means the result has no error code; the field is not a literal null in the product result.

{"mode":"incomplete","stops":2,"exitCode":1,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":true,"sessionIdentityInEvidence":true}
{"mode":"interrupt","stops":1,"exitCode":130,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":false,"sessionIdentityInEvidence":true}
{"mode":"control","stops":1,"exitCode":0,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":false,"sessionIdentityInEvidence":true}

Typed outcome or recovery expected

Cleanup failure should expose unresolved session resources and the completed Evidence through the canonical typed error. Interruption after successful cleanup should project AnalysisCancelledError, preserve its abort reason and partial observation, and keep exit status 130. The uncancelled control should remain complete Evidence with status 0.

Analysis or suspected cause

Source-derived: src/cli/reverseEngineeringCommands.ts:313-382 saves successful Evidence before its manual close/finally sequence, then only logs cleanup failures and assigns exit status. src/cli/analysisEvidenceDelivery.ts:177-183 returns that success without consulting the signal when no output bundle is requested. The caller receives the saved success even though finalization has since failed or observed cancellation. src/gdb/GdbSessionManager.ts:1243 explains why the session ID is still present in Evidence.

Additional context

Related closed reports concern separate owners: #6072 covers Frida CLI final cleanup, #6111 covers objdump/fixed-command Rizin, and #3935 covers other one-shot families losing observations behind cleanup errors. None covers this GDB debugger-manager finalizer. Proposed restoration check: rerun the same fault/control matrix expecting typed cleanup failure and cancellation instead of successful Evidence; no fix has been tested.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The production-handler/manager probe returns successful Evidence with exit status 1 after unresolved cleanup and 130 after a handled finalization interrupt, while its uncancelled control exits 0. The discrepancy is bounded; Evidence and session identity remain intact.
  • Workaround status: available
  • Workaround basis: The observed process exit status distinguishes both unsuccessful cases from the successful control, allowing CLI wrappers to reject the misleading success-shaped record. This does not recover the absent typed finalization metadata.
Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7378 ## 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 CLI ## Observed behavior The registered handler for `debug-with-gdb "info files" --inferior-tty /dev/pts/7 --detail complete --json` returns complete `gdb_console` Evidence after both final cleanup attempts report incomplete, while `process.exitCode` becomes 1. SIGINT dispatched during the final close likewise leaves the successful Evidence unchanged while the exit status becomes 130. Neither result has a typed error code or partial-observation envelope. The retained session ID does remain in `parameters.session_id`. This was observed through production CLI registration, cancellation handling, GdbSessionManager and Evidence delivery with an inert synthetic MI peer and an injected stop result. It was not a real GDB run, packaged CLI subprocess, or OS-signal timing test. ## Reproduction From a fresh checkout at the revision below, use Node 24.22.1 and install/build with `npm ci && npm run build:cached`. The prepared verification checkout already had matching locked dependencies, so only the build was rerun. Execute this three-case probe; its assertions characterize the defect and the successful cleanup control. The temporary filename below is the public-safe spelling of the executed test filename. The final probe ran once. ```sh export PATH="$(mise where node@24.22.1)/bin:$PATH" WORK=$(mktemp -d) export HOME="$WORK/home" export XDG_CONFIG_HOME="$HOME/config" XDG_DATA_HOME="$HOME/data" XDG_CACHE_HOME="$HOME/cache" XDG_STATE_HOME="$HOME/state" TMPDIR="$HOME/tmp" mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$TMPDIR" cat > tests/boundary/cli/gdbFinalizationProbe.test.ts <<'EOF' import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import pino from 'pino'; import { expect, it } from 'vitest'; import { registerReverseEngineeringCommands } from '../../../src/cli/reverseEngineeringCommands.js'; import type { CliInstance } from '../../../src/cli/types.js'; import { GdbSessionManager } from '../../../src/gdb/GdbSessionManager.js'; class FakeGdb extends EventEmitter { pid = process.pid + 1; stdout = new PassThrough(); stderr = new PassThrough(); stdin = new PassThrough(); exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; kill(signal: NodeJS.Signals) { this.exitCode = 0; this.signalCode = signal; this.stdout.end(); this.stderr.end(); this.stdin.destroy(); this.emit('exit', 0, signal); this.emit('close', 0, signal); return true; } } it('observes final cleanup failure, late interruption, and a successful control', async () => { for (const mode of ['incomplete', 'interrupt', 'control'] as const) { const child = new FakeGdb(); child.stdout.write('(gdb)\n'); child.stdin.on('data', (chunk) => { for (const line of String(chunk).split('\n')) { const m = /^(\d+)(.*)$/u.exec(line); if (!m) continue; const payload = m[2].includes('list-thread-groups') ? ',groups=[]' : m[2].includes('inferior-tty-show') ? ',inferior_tty_terminal="/dev/pts/7"' : ''; child.stdout.write(`${m[1]}^done${payload}\n`); } }); let calls = 0; let recovering = false; let retainedId: string | undefined; const manager = new GdbSessionManager({ platform: 'linux', spawnProcess: async (options) => ({ process: child, ownership: { runId: options.runId, leaderPid: child.pid, processGroupId: child.pid, expectedParentPid: process.pid, } }), stopProcess: async () => { calls++; if (mode === 'interrupt' && calls === 1) process.emit('SIGINT'); if (mode === 'incomplete' && !recovering) return { status: 'incomplete', reason: 'synthetic stop remains pending' }; child.kill('SIGTERM'); return { status: 'stopped' }; }, }); const originalStart = manager.start.bind(manager); manager.start = async (...args) => { const result = await originalStart(...args); if (result.ok) retainedId = result.value.session_id; return result; }; let handler: ((input: unknown) => Promise<any>) | undefined; const cli = { command(name: string, definition: {run: typeof handler}) { if (name === 'debug-with-gdb') handler = definition.run; return cli; }} as unknown as CliInstance; registerReverseEngineeringCommands(cli, pino({ enabled: false }), {}, () => manager); const previous = process.exitCode; try { process.exitCode = 0; const result = await handler!({ args: { command: 'info files' }, options: { inferiorTty: '/dev/pts/7', detail: 'complete' } }); const retained = retainedId !== undefined && manager.status(retainedId) !== undefined; const observation = { mode, stops: calls, exitCode: process.exitCode, operation: result.operation, completion: result.normalized_result?.completion_status, code: result.code ?? null, cleanup: result.cleanup_incomplete ?? null, partialObservation: result.details?.partial_observation ?? null, retained, sessionIdentityInEvidence: JSON.stringify(result).includes(retainedId!) }; console.log(JSON.stringify(observation)); expect(observation).toEqual({ mode, stops: mode === 'incomplete' ? 2 : 1, exitCode: mode === 'incomplete' ? 1 : mode === 'interrupt' ? 130 : 0, operation: 'gdb_console', completion: 'complete', code: null, cleanup: null, partialObservation: null, retained: mode === 'incomplete', sessionIdentityInEvidence: true }); } finally { recovering = true; await manager.closeAll(); process.exitCode = previous; } } }); EOF npx vitest run tests/boundary/cli/gdbFinalizationProbe.test.ts rm tests/boundary/cli/gdbFinalizationProbe.test.ts ``` ## Expected behavior After awaited finalization, return typed cancellation with the completed Evidence as `details.partial_observation` when interrupted, or typed `cleanup_incomplete` with unresolved resources and recovery information when cleanup cannot be confirmed. Preserve the completed observation and keep ordinary successful cleanup successful. `docs/tool-design.md:181-190,201-223` requires these finalization checks and retained observations; `docs/testing.md:528` specifies the late-cancellation and non-aborted control. ## Impact and scope P2: structured consumers see successful Evidence despite unsuccessful or interrupted finalization. Checking the process exit status distinguishes these cases, but does not restore the missing typed finalization state. Evidence bytes and session identity survive; no target damage or data loss was observed. Real-provider frequency and natural cleanup-failure timing are unknown. This report covers the one-shot GDB CLI finalizer. ## REA version Exact `node scripts/rea.mjs --version` output: `6.8.0`. Source main: `82d3ab7aa8a41e702a876004be3e73d61ed70443`; rebuilt before verification. ## Runtime and platform Node `v24.22.1`; macOS `27.0.1`; `darwin arm64`; locked Vitest `5.0.3`. The manager explicitly selects its supported Linux admission path over a synthetic provider; no Linux host or native GDB compatibility claim is made. ## Target and artifact context No target artifact or inferior. In-memory PassThrough streams emit valid GDB/MI prompt, configuration, command-completion and empty-inferior records. The TTY is a fixture string and is never opened. Teardown changes the stop fixture to success and closes retained manager state. ## Interface CLI ## Earliest failure stage Cleanup, cancellation, or lifecycle ## Relevant output or evidence Observed: one focused test passed, executing these three sequential cases. Null `code` in the instrumentation means the result has no error code; the field is not a literal null in the product result. ```jsonl {"mode":"incomplete","stops":2,"exitCode":1,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":true,"sessionIdentityInEvidence":true} {"mode":"interrupt","stops":1,"exitCode":130,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":false,"sessionIdentityInEvidence":true} {"mode":"control","stops":1,"exitCode":0,"operation":"gdb_console","completion":"complete","code":null,"cleanup":null,"partialObservation":null,"retained":false,"sessionIdentityInEvidence":true} ``` ## Typed outcome or recovery expected Cleanup failure should expose unresolved session resources and the completed Evidence through the canonical typed error. Interruption after successful cleanup should project `AnalysisCancelledError`, preserve its abort reason and partial observation, and keep exit status 130. The uncancelled control should remain complete Evidence with status 0. ## Analysis or suspected cause Source-derived: `src/cli/reverseEngineeringCommands.ts:313-382` saves successful Evidence before its manual close/finally sequence, then only logs cleanup failures and assigns exit status. `src/cli/analysisEvidenceDelivery.ts:177-183` returns that success without consulting the signal when no output bundle is requested. The caller receives the saved success even though finalization has since failed or observed cancellation. `src/gdb/GdbSessionManager.ts:1243` explains why the session ID is still present in Evidence. ## Additional context Related closed reports concern separate owners: [#6072](https://github.com/morluto/rea/issues/6072) covers Frida CLI final cleanup, [#6111](https://github.com/morluto/rea/issues/6111) covers objdump/fixed-command Rizin, and [#3935](https://github.com/morluto/rea/issues/3935) covers other one-shot families losing observations behind cleanup errors. None covers this GDB debugger-manager finalizer. Proposed restoration check: rerun the same fault/control matrix expecting typed cleanup failure and cancellation instead of successful Evidence; no fix has been tested. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The production-handler/manager probe returns successful Evidence with exit status 1 after unresolved cleanup and 130 after a handled finalization interrupt, while its uncancelled control exits 0. The discrepancy is bounded; Evidence and session identity remain intact. - Workaround status: available - Workaround basis: The observed process exit status distinguishes both unsuccessful cases from the successful control, allowing CLI wrappers to reject the misleading success-shaped record. This does not recover the absent typed finalization metadata.
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#4384
No description provided.