[GH-ISSUE #6661] [Bug] A queued GDB console follower prevents an otherwise valid session close #4041

Closed
opened 2026-10-31 00:46:17 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

Process capture or runtime observation

Observed behavior

When a session has one live-inferior inventory and a verified TTY, enqueueing a console command immediately after starting close makes close return AnalysisCapabilityUnavailableError with cleanup incomplete and retain the session. The follower is correctly rejected and is never dispatched, but its queue reservation makes the closing operation treat MI as unsynchronized. Without that follower the same fixture closes successfully. Retrying close after the follower is rejected succeeds.

This is an executed production-owner boundary probe using an inert synthetic MI provider and the existing output-sink verification test seam. It does not establish real-inferior detach behavior.

Reproduction

Executed complete isolated procedure, with the scratch filename normalized. From the source checkout with its locked dependencies installed, use its pinned Node as node, save the following as /tmp/rea-gdb-close-repro.mts, then run:

REA_CHECKOUT="$PWD" node --import ./node_modules/tsx/dist/loader.mjs /tmp/rea-gdb-close-repro.mts
import { pathToFileURL } from 'node:url';
import { resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdtemp, writeFile, rm, chmod } from 'node:fs/promises';
const { GdbSessionManager } = await import(pathToFileURL(resolve(process.env.REA_CHECKOUT!, 'src/gdb/GdbSessionManager.ts')).href);
const directory = await mkdtemp(join(tmpdir(),'rea-gdb-queue-repro-'));
const provider=join(directory,'fake-gdb.mjs');
await writeFile(provider, `#!${process.execPath}
let active=true;
process.stdout.write('(gdb)\\n');
process.stdin.setEncoding('utf8');
let input='';
process.stdin.on('data',chunk=>{
  input+=chunk;
  for(;;){
    const boundary=input.indexOf('\\n'); if(boundary<0)break;
    const line=input.slice(0,boundary);input=input.slice(boundary+1);
    const match=/^(\\d+)(.*)$/.exec(line); if(!match)continue;
    const [,token,command]=match;
    process.stdout.write('~'+JSON.stringify('received '+command+'\\n')+'\\n');
    if(command==='-inferior-tty-show')process.stdout.write(token+'^done,inferior_tty_terminal="/dev/pts/42"\\n');
    else if(command.includes('list-thread-groups'))process.stdout.write(token+(active?'^done,groups=[{id="i1",pid="123"}]\\n':'^done,groups=[]\\n'));
    else {if(command==='-target-detach')active=false;process.stdout.write(token+'^done\\n');}
  }
});
`);
await chmod(provider,0o700);
try {
  for(const queued of [false,true]){
    const manager=new GdbSessionManager({environment:{REA_GDB_COMMAND:provider},verifyInferiorOutputSink:async()=>undefined});
    const started=await manager.start(undefined,{inferiorTty:'/dev/pts/42'});
    if(!started.ok)throw started.error;
    const id=started.value.session_id;
    const closing=manager.close(id);
    const following=queued?manager.execute(id,'show version'):undefined;
    const closed=await closing;
    const follower=following?await following:undefined;
    console.log(JSON.stringify({queued,close:closed.ok?closed:{ok:false,tag:closed.error._tag,message:closed.error.message,cleanupIncomplete:closed.error.cleanupIncomplete},follower:follower?(follower.ok?follower:{ok:false,tag:follower.error._tag}):null,retained:manager.status(id)!==undefined,records:manager.status(id)?.recent_mi_records}));
    if(!closed.ok)console.log(JSON.stringify({queued,retry:await manager.close(id)}));
    await manager.closeAll();
  }
} finally {await rm(directory,{recursive:true,force:true});}

The queued=false control succeeded once; the queued=true variant failed once; its retry succeeded once. The provider only emits synthetic inventory/TTY records. PID 123 and /dev/pts/42 are fixture data; no such target is attached, signaled, or inspected. The only process started and stopped is the local synthetic provider, and temporary files are removed.

Expected behavior

The close operation should complete the same verification, detach, and debugger cleanup as the no-follower control. A console request serialized behind close can be rejected after close without invalidating the active close's protocol synchronization. The close contract defines automatic detach for one live group with a verified output TTY. The queued-behind-close regression requires the blocked follower to be rejected while close succeeds; the live-inferior close regression separately covers that verification/detach path.

Impact and scope

Observed a false cleanup failure and a retained GDB session caused by one blocked follower, with no concurrent MI execution. The observed failure is recoverable by retrying close after that follower settles. It can interrupt a caller's normal close sequence and leave the debugger owned until retry. The fixture proves the queue/state branch with a control; real GDB, real TTY descriptor verification, transport timing, and frequency in deployed MCP clients were not exercised for this case.

REA version

Source checkout at 23def04096869b85c4d3c14a73ed09168483c9f6 (package.json version 6.7.0; this main revision is newer than the published release). No packaged-release reproduction is claimed.

Runtime and platform

Node.js v24.22.1; Linux x86_64. A disposable Node-based synthetic MI provider was used instead of GDB; no real inferior or TTY was used.

Target and artifact context

Fresh manager/session with synthetic one-live-group inventory and synthetic configured TTY; existing verifyInferiorOutputSink seam returns successfully. Default close and owned-debugger cleanup code remain in use.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed output:

queued=false: close={ok:true,value:{target_state:"unknown"}}, retained=false
queued=true: close.ok=false, tag=AnalysisCapabilityUnavailableError, cleanupIncomplete=true
message: Provider gdb cannot execute close_gdb_session: The owned GDB process could not be confirmed stopped: Owned GDB inferior detach could not be verified: GDB MI state is not synchronized; inferior state is unknown.
follower: {ok:false,tag:"AnalysisInputError"}
retained=true
retry: {ok:true,value:{target_state:"unknown"}}

The retained MI transcript ends after the first successful -list-thread-groups --recurse 1 reply containing one live group. It contains neither a dispatched follower nor -target-detach. The control and retry both finish cleanup. No source edits, full-suite tests, or fixed-build verification were performed.

Typed outcome or recovery expected

The admitted close succeeds after the same fixture verification as the control and removes the session; the queued follower remains rejected without reaching the provider. Restoration must preserve refusal when an MI request is actually active or inventory/TTY verification fails. The demonstrated operational recovery is to await the rejected follower and retry close.

Analysis or suspected cause

Source-derived explanation: queue admission counts the queued follower before its turn begins. close then seals the session for cleanup and enters detach. The detach guard rejects pendingCommandCount > 1 although the follower has not sent an MI command and cannot run concurrently. Both the initial detach and cleanup retry inside this close encounter that count before the serialized operation releases its turn.

Additional context

Open and closed searches found no exact matching report. Closed #6370 concerns queued commands executing after closing starts. This fixture confirms the follower is blocked; the distinct failure is that its reservation prevents the active close from completing. No installed-provider or public MCP round-trip claim is made.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Adding one serialized follower to an otherwise successful synthetic live-inferior close produces a false cleanup failure and retains the session. The failure is bounded and directly recoverable.
  • Workaround status: available
  • Workaround basis: In the executed failing variant, awaiting the rejected follower and retrying close succeeded and removed the session; the no-follower control also succeeded.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6661 ### 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 Process capture or runtime observation ### Observed behavior When a session has one live-inferior inventory and a verified TTY, enqueueing a console command immediately after starting `close` makes close return `AnalysisCapabilityUnavailableError` with cleanup incomplete and retain the session. The follower is correctly rejected and is never dispatched, but its queue reservation makes the closing operation treat MI as unsynchronized. Without that follower the same fixture closes successfully. Retrying close after the follower is rejected succeeds. This is an executed production-owner boundary probe using an inert synthetic MI provider and the existing output-sink verification test seam. It does not establish real-inferior detach behavior. ### Reproduction Executed complete isolated procedure, with the scratch filename normalized. From the source checkout with its locked dependencies installed, use its pinned Node as `node`, save the following as `/tmp/rea-gdb-close-repro.mts`, then run: ```sh REA_CHECKOUT="$PWD" node --import ./node_modules/tsx/dist/loader.mjs /tmp/rea-gdb-close-repro.mts ``` ```ts import { pathToFileURL } from 'node:url'; import { resolve, join } from 'node:path'; import { tmpdir } from 'node:os'; import { mkdtemp, writeFile, rm, chmod } from 'node:fs/promises'; const { GdbSessionManager } = await import(pathToFileURL(resolve(process.env.REA_CHECKOUT!, 'src/gdb/GdbSessionManager.ts')).href); const directory = await mkdtemp(join(tmpdir(),'rea-gdb-queue-repro-')); const provider=join(directory,'fake-gdb.mjs'); await writeFile(provider, `#!${process.execPath} let active=true; process.stdout.write('(gdb)\\n'); process.stdin.setEncoding('utf8'); let input=''; process.stdin.on('data',chunk=>{ input+=chunk; for(;;){ const boundary=input.indexOf('\\n'); if(boundary<0)break; const line=input.slice(0,boundary);input=input.slice(boundary+1); const match=/^(\\d+)(.*)$/.exec(line); if(!match)continue; const [,token,command]=match; process.stdout.write('~'+JSON.stringify('received '+command+'\\n')+'\\n'); if(command==='-inferior-tty-show')process.stdout.write(token+'^done,inferior_tty_terminal="/dev/pts/42"\\n'); else if(command.includes('list-thread-groups'))process.stdout.write(token+(active?'^done,groups=[{id="i1",pid="123"}]\\n':'^done,groups=[]\\n')); else {if(command==='-target-detach')active=false;process.stdout.write(token+'^done\\n');} } }); `); await chmod(provider,0o700); try { for(const queued of [false,true]){ const manager=new GdbSessionManager({environment:{REA_GDB_COMMAND:provider},verifyInferiorOutputSink:async()=>undefined}); const started=await manager.start(undefined,{inferiorTty:'/dev/pts/42'}); if(!started.ok)throw started.error; const id=started.value.session_id; const closing=manager.close(id); const following=queued?manager.execute(id,'show version'):undefined; const closed=await closing; const follower=following?await following:undefined; console.log(JSON.stringify({queued,close:closed.ok?closed:{ok:false,tag:closed.error._tag,message:closed.error.message,cleanupIncomplete:closed.error.cleanupIncomplete},follower:follower?(follower.ok?follower:{ok:false,tag:follower.error._tag}):null,retained:manager.status(id)!==undefined,records:manager.status(id)?.recent_mi_records})); if(!closed.ok)console.log(JSON.stringify({queued,retry:await manager.close(id)})); await manager.closeAll(); } } finally {await rm(directory,{recursive:true,force:true});} ``` The `queued=false` control succeeded once; the `queued=true` variant failed once; its retry succeeded once. The provider only emits synthetic inventory/TTY records. PID `123` and `/dev/pts/42` are fixture data; no such target is attached, signaled, or inspected. The only process started and stopped is the local synthetic provider, and temporary files are removed. ### Expected behavior The close operation should complete the same verification, detach, and debugger cleanup as the no-follower control. A console request serialized behind close can be rejected after close without invalidating the active close's protocol synchronization. The [close contract](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/src/contracts/gdbToolContracts.ts#L91-L99) defines automatic detach for one live group with a verified output TTY. The [queued-behind-close regression](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/tests/boundary/providers/GdbSessionManagerCoverage.test.ts#L655-L689) requires the blocked follower to be rejected while close succeeds; the [live-inferior close regression](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/tests/boundary/providers/GdbSessionManagerCoverage.test.ts#L2239-L2280) separately covers that verification/detach path. ### Impact and scope Observed a false cleanup failure and a retained GDB session caused by one blocked follower, with no concurrent MI execution. The observed failure is recoverable by retrying close after that follower settles. It can interrupt a caller's normal close sequence and leave the debugger owned until retry. The fixture proves the queue/state branch with a control; real GDB, real TTY descriptor verification, transport timing, and frequency in deployed MCP clients were not exercised for this case. ### REA version Source checkout at `23def04096869b85c4d3c14a73ed09168483c9f6` (`package.json` version `6.7.0`; this main revision is newer than the published release). No packaged-release reproduction is claimed. ### Runtime and platform Node.js `v24.22.1`; Linux x86_64. A disposable Node-based synthetic MI provider was used instead of GDB; no real inferior or TTY was used. ### Target and artifact context Fresh manager/session with synthetic one-live-group inventory and synthetic configured TTY; existing `verifyInferiorOutputSink` seam returns successfully. Default close and owned-debugger cleanup code remain in use. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed output: ```text queued=false: close={ok:true,value:{target_state:"unknown"}}, retained=false queued=true: close.ok=false, tag=AnalysisCapabilityUnavailableError, cleanupIncomplete=true message: Provider gdb cannot execute close_gdb_session: The owned GDB process could not be confirmed stopped: Owned GDB inferior detach could not be verified: GDB MI state is not synchronized; inferior state is unknown. follower: {ok:false,tag:"AnalysisInputError"} retained=true retry: {ok:true,value:{target_state:"unknown"}} ``` The retained MI transcript ends after the first successful `-list-thread-groups --recurse 1` reply containing one live group. It contains neither a dispatched follower nor `-target-detach`. The control and retry both finish cleanup. No source edits, full-suite tests, or fixed-build verification were performed. ### Typed outcome or recovery expected The admitted close succeeds after the same fixture verification as the control and removes the session; the queued follower remains rejected without reaching the provider. Restoration must preserve refusal when an MI request is actually active or inventory/TTY verification fails. The demonstrated operational recovery is to await the rejected follower and retry close. ### Analysis or suspected cause Source-derived explanation: [queue admission](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/src/gdb/GdbSessionManager.ts#L1517-L1550) counts the queued follower before its turn begins. [close](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/src/gdb/GdbSessionManager.ts#L1419-L1443) then seals the session for cleanup and enters detach. The [detach guard](https://github.com/morluto/rea/blob/23def04096869b85c4d3c14a73ed09168483c9f6/src/gdb/GdbSessionManager.ts#L1818-L1835) rejects `pendingCommandCount > 1` although the follower has not sent an MI command and cannot run concurrently. Both the initial detach and cleanup retry inside this close encounter that count before the serialized operation releases its turn. ### Additional context Open and closed searches found no exact matching report. Closed [#6370](https://github.com/morluto/rea/issues/6370) concerns queued commands executing after closing starts. This fixture confirms the follower is blocked; the distinct failure is that its reservation prevents the active close from completing. No installed-provider or public MCP round-trip claim is made. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Adding one serialized follower to an otherwise successful synthetic live-inferior close produces a false cleanup failure and retains the session. The failure is bounded and directly recoverable. - Workaround status: available - Workaround basis: In the executed failing variant, awaiting the rejected follower and retrying close succeeded and removed the session; the no-follower control also succeeded.
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#4041
No description provided.