Skip to content

fix(debugger): let REA exit when a debugger could not be stopped - #8652

Merged
kaoru0822-kitauji merged 5 commits into
mainfrom
fix/8642b-cli-debugger-one-shot-exit
Nov 2, 2026
Merged

kaoru0822-kitauji merged 5 commits into
mainfrom
fix/8642b-cli-debugger-one-shot-exit

Conversation

@kaoru0822-kitauji

@kaoru0822-kitauji kaoru0822-kitauji commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What broke: When a one-shot debugger command could not confirm its debugger stopped, rea debug-with-rizin and rea debug-with-gdb printed cleanup_incomplete and then never exited. A stdio MCP server whose client disconnected hung the same way.
The fix: closeOwnedDebuggerSessions, which both managers finish through, now releases retained sessions from the event loop, as supervised providers already do, and names their PIDs. The process exits 1, or 130/143 on a signal for the one-shot commands, and REA does not stop the debugger. The one-shot result and the MCP shutdown report both name the process.
The risk: A retained debugger now outlives REA with its pipes closed. Real Rizin and GDB were not run, so what they do on that EOF is unverified. Every exit path names the process to check.

Summary

No issue is linked because none exists for this general behavior. It was first seen while working on #8642, whose reporter saw rea debug-with-rizin /usr/bin/cat dr --inferior-tty <pty> --format json print its result and stay hung at 30 s. #8647 removes that one trigger. This PR covers any incomplete debugger cleanup, including the refusal #8647 adds.

An agent or script waiting on the CLI blocked forever, with no sign that the result was already final. Now it gets the same result, a nonzero exit, and the process to check.

Problem and expected behavior

Smallest trigger: a debugger that does not confirm detach, for example one that answers olj with malformed JSON or stops answering. Observed on f95bdbce7 through scripts/rea.mjs with a fake launcher: the result was printed, process.exitCode was 1, and the process was still running 8 s later. process.getActiveResourcesInfo() listed ProcessWrap and the child's pipes.

The violated invariant is the one ProviderProcessSupervisor.unref already states: incomplete cleanup does not prevent host exit. Debugger sessions never reached it, because they stop through stopOwnedDebuggerProcess with ownsProcessLifetime: false.

Change and scope

  • src/process/ownedDebuggerSession.ts: when closeOwnedDebuggerSessions retains a session, it unreferences that session's process and pipes. A retry restores them first. It now throws OwnedDebuggerCleanupError, an AggregateError that also carries the retained PIDs. Stopped peers are not touched.
  • src/process/ownedDebuggerCleanupError.ts, new: that error type and the one reader that finds its PIDs through composed failures, since runtime owners wrap it in further aggregates before anything reports it. It has no dependencies because src/main/shutdown.ts loads on the deferred startup path.
  • src/cli/reverseEngineeringCommands.ts: closeDebuggerCommandSession adds retained_debugger_pids and a remediation naming the process. The session UUID in resources means nothing once the process has ended.
  • src/main/shutdown.ts, src/server/createServer.ts: the MCP shutdown report writes the same remediation to stderr and adds retained_debugger_pids to its error log, its debug failure_cause, and the server's onclose log. Before this, those logs said only "REA provider cleanup failed", so an exiting server named nothing.
  • src/gdb/GdbSessionManager.ts: a request issued during cleanup no longer holds the host until its 120 s command deadline. Without this, an unanswered detach kept GDB's process alive after the result, with only a Timeout left active. Cleanup already bounds that wait at 1000 ms. Rizin's cleanup requests are already bounded, so its manager is unchanged.

Ownership. The AGENTS.md rule to retain ownership of failed cleanup still holds: the session stays in the map and retryable for as long as the owner lives, and an MCP session's ordinary close keeps its references. A one-shot process's life ends with its result, so it reports what it could not release. It sends no signal and does not claim cleanup.

Not included:

  • Stopping the debugger on exit. Detach could not be verified, so stopping it could affect the target.
  • A write blocked on a full stdin pipe would still hold the loop, because libuv counts pending requests. This is source-derived and not tested. Cleanup writes are small, so it would take a user command larger than the pipe buffer sent to a debugger that is not reading.

Overlap with open PRs, from reading their diffs; none is depended on:

Scope: ordinary fix within the existing debugger boundary; no maintainer scope decision is required.

Contract and boundary impact

  • Semantic owner and earliest changed stage: closeOwnedDebuggerSessions owns the final release; closeDebuggerCommandSession owns the one-shot report. Consumers are both debugger commands and MCP shutdown. No superseded path to remove.
  • Contribution scope fit and linked maintainer decision: not applicable; no new capability or authority.
  • CLI and MCP/tool-catalog contract: no schema or catalog change. The CLI gains retained_debugger_pids and a more specific remediation.action, and MCP shutdown gains the same process naming on stderr and in logs. Exit changes from never to 1, 130 or 143 for the CLI, and to 1 for an MCP server, including on a signal.
  • Provider, bridge, target-format, or platform compatibility: persistent debugger sessions stay Linux-only.
  • Evidence, artifact, provenance, or reconstruction contract: unchanged; the completed observation stays in partial_observation.
  • Target selection, authority, upstream behavior, and cleanup details: no new signal, detach or kill. The debugger's pipes close when REA exits, which did not happen while it hung.
  • Generated metadata, package, or installation impact: none.

Expected effects (check known effects; use documentation-only on its own).
Unchecked items do not establish absence; leave uncertainties visible.

  • Read existing files or captured data.
  • Launch or attach to a process.
  • Inject instrumentation or run scripts/code.
  • Change process memory, registers, files, or configuration.
  • Contact network services.
  • Recover protected code or work around protections.
  • Documentation or usability only; no target interaction.
  • Not sure yet; investigation or maintainer guidance needed.

Evidence and regression coverage

All of this was executed unless marked source-derived.

  • Tests added or updated:

    • New tests/boundary/cli/debuggerOneShotExit.test.ts spawns the compiled scripts/rea.mjs with fake Rizin and GDB launchers and a real PTY. The fakes model each request REA sends and use real reply shapes. A request they do not model fails the test. For both debuggers it covers:
      • malformed and unanswered cleanup, with exit 1, the retained PID matching the fake's own, and the fake still alive, unsignaled, and seeing stdin end
      • a clean control, with exit 0 and the debugger gone
      • SIGINT during final cleanup, synchronized on the fake's log, with exit 130
      • stdio MCP client disconnect, with exit 1 and the PID on stderr and in both error logs; also SIGTERM for Rizin
    • tests/boundary/process/ownedDebuggerSession.test.ts gains mixed outcomes, retry ordering, and finding PIDs through composed and cyclic failures.
    • tests/boundary/mcp/shutdown.test.ts gains the report at stderr, error and debug level for a nested failure.
    • The reverseEngineeringCommands incomplete case now asserts the PID.
  • Base reproduction:

    • First version, with the three source files at f95bdbce7: 8 of the 10 CLI tests failed by exceeding their 20 s deadline, the 2 clean controls passed, and the 2 module tests and the extended CLI case failed.
    • Final tests, with the five changed source files at origin/main: all 4 selected retained-debugger cases fail on the same deadline (malformed and MCP disconnect, both debuggers), both clean controls pass, and the new shutdown test fails.
  • Mutants, each failing the tests that should catch it:

    • no GDB timer change: 2 GDB tests
    • no retry ref(): 1 module test
    • no stderr line: 3 MCP tests and the shutdown test
    • no PID in the shutdown error log: 3 MCP tests
    • no PID in the onclose log: 3 MCP tests
    • no PID in failure_cause: the shutdown test
    • a fake that stops modeling dr or -gdb-set auto-load off: both clean controls, with "unmodeled" in the failure
  • Native Linux: on 1f317ff78, all 8 Test shard jobs passed on Ubuntu. Shard 8/8 ran debuggerOneShotExit.test.ts (10 tests, none skipped) and shard 5/8 ran ownedDebuggerSession.test.ts (42 tests). That was before the changes above, so the current tests are confirmed on Linux only by the CI run on this head. The launchers are Node scripts with no shell, so they cannot hit a dash versus bash difference.

  • Runs on this Mac: all local runs were on macOS. Persistent debuggers are Linux-gated, so the CLI tests open that gate with an --import preload. Each mode stops before any /proc read, so the test does not depend on /proc. Windows skips the file because it needs a PTY.

  • Scratch counterexamples, not committed, through the same entrypoint and all ending on their own:

    Case Exit Note
    Rizin user command hangs, 2 s deadline 1 4.5 s
    SIGINT during a hung user command, Rizin and GDB 130
    SIGTERM during final cleanup, Rizin and GDB 143
    GDB rejects startup 1 capability_unavailable, debugger stopped by SIGTERM
    Reader closes the output pipe 141
    Eager MCP runtime, client disconnect and SIGINT 1 PID named, fake alive
    rea mcp --http, Rizin SIGTERM and GDB SIGINT 1 PID named, fake alive
  • User-visible output, trimmed:

    {"code":"cleanup_incomplete","retryable":false,
     "remediation":{"action":"REA exits without confirming that the debugger stopped, and no later REA command can retry this cleanup. Check process 14876 and its target, and stop them if they are still running."},
     "details":{"cleanup":"incomplete","resources":["45d558a0-…"],
      "diagnostics":{"reason":"Owned Rizin debugger inferior detach could not be verified: Rizin returned malformed JSON for `olj`.","retained_debugger_pids":[14876]},
      "partial_observation":{"operation":"rizin_debug_command", "…":"…"}}}

    When the command itself also failed or was interrupted, the field is at details.diagnostics.cleanup_error.details.diagnostics.retained_debugger_pids. remediation.action names the process in each retained case I ran.

    MCP shutdown, stderr trimmed. This report already runs once per runtime owner, so the lines can repeat; I left that as it was.

    {"level":50,"error":"REA provider cleanup failed","retained_debugger_pids":[33053],"msg":"Provider cleanup failed during server shutdown"}
    REA could not close cleanly. End the REA process before starting it again.
    REA exits without confirming that the debugger stopped, and no later REA command can retry this cleanup. Check process 33053 and its target, and stop them if they are still running.
    {"level":50,"retained_debugger_pids":[33053],"msg":"REA could not close cleanly. End the REA process before starting it again."}
    
  • Remaining proof gaps:

    • Real Rizin and GDB were not run; they are not on this machine. The reporter's cat case was not rerun.
    • What a real debugger or its target does when its pipes close is unknown, and this PR makes no claim about it.
    • Native Linux behavior rests on CI.
    • MCP HTTP and eager-runtime shutdown ran only as the scratch cases above; no committed test covers them. They use the same reportMcpShutdownFailure, which shutdown.test.ts covers directly.

Applicable lifecycle regressions: cleanup failure after success, with the observation and remaining resources retained; cancellation during final cleanup; mixed child outcomes; cleanup retry at module level. CLI retry does not exist, since the process ends.

For evidence-bearing changes:

  • Observed, derived, and inferred claims remain distinguishable.
  • Artifact identity, source provenance, and failed attempts remain preserved.
  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

All on the merged head 236d2263f, which includes origin/main at 2ac6d3efe:

  • After the last test-only commit 245860975: debuggerOneShotExit 11/11 in 3 runs, shutdown 8/8, ownedDebuggerSession 44/44, check:fast and format:check passed. The guidance checks now compare with retainedDebuggerRemediation and separately check that the PID is named: a formatter that drops the PID fails them, and a harmless rewording passes.

The counts below are from the earlier run on that merged head:

  • vitest --project cli-boundary tests/boundary/cli/debuggerOneShotExit.test.ts, 5 runs: 11/11 each.
  • gdbStartupCleanupCli, reverseEngineeringCommands, commandEvidenceDelivery, cliToolCoverage: 44 passed.
  • ownedDebuggerSession, providerProcess, mcpHttpShutdown: 72 passed.
  • GDB and Rizin provider groups in tests/boundary/providers: 319 passed, 24 skipped.
  • shutdown, optionalProviderShutdown, mainErrors, serverLifecycleMcp and the MCP debugger suites: 83 passed.
  • npm run test:focused -- tests/boundary/mcp/deferredRuntime.test.ts: 28 passed. It needs a prepared catalog, so it ran separately; a plain vitest run stops with ENOENT on .cache/mcp-tool-catalog.json.
  • mainStartupCancellation: 10 passed.
  • tests/composition/reverseEngineering*: 125 passed.
  • ciChangeScope, reverseToolsMutationConfig, productionReachabilityReport: 367 passed.
  • npm run check:fast, npm run format:check, npm run docs:check, npm run metadata:check: all passed.

One earlier failure, now explained: cancels and drains GDB starts before closeAll resolves failed once under load with "fake GDB did not start". This PR does not cause it, for two reasons:

  • It fails on an unchanged path. The test throws at its pid-file wait of about 1.1 s, before it calls closeAll. start issues no request with cleanupStarted set, so neither event-loop change has run. The 10 s hook timeout that followed is the test's own stopGate, never released once it has thrown.
  • It reproduces without this PR. Under 30 busy processes on 10 CPUs, with the two source files it imports at f95bdbce7, it failed that way in 2 of 8 runs. With the fix it failed in 0 of 8. Those counts do not show a difference between the two, only that base fails the same way.

It is an existing load-sensitive wait, which I left alone.

No cost measurement: analysis work is unchanged, and the success path adds one ref() per session.

Compatibility, safety, and release

  • Breaking changes or migration steps: none. A caller that relied on the process staying alive was relying on a hang.
  • Real Hopper/Ghidra, browser, or OS coverage: macOS natively; Linux through CI only; no real debugger.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: PIDs are reported as observations and can be reused, so the wording is "check". REA sends no new signal. The debugger is not terminated, which can leave it running under the caller's user, as the hang already did.

Author readiness

Human owner: @kaoru0822-kitauji

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

Declarations entered by Claude Code on the owner's standing written instruction.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

🤖 Generated with Claude Code

`rea debug-with-gdb` and `rea debug-with-rizin` printed cleanup_incomplete
and set exit status 1, but the retained child and its pipes kept the event
loop alive, so the process never exited. A stdio MCP server whose client
disconnected hung the same way.

closeOwnedDebuggerSessions is the owner both managers finish through. It now
unreferences retained sessions, as supervised providers already do after
incomplete cleanup, restores the references on retry, and names the retained
process IDs. The one-shot commands report those IDs, since no later command
can retry. Debugger ownership, signals and detach behavior are unchanged.

GDB cleanup requests also no longer hold the host until their 120 s command
deadline, because cleanup already bounds its own wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kaoru0822-kitauji kaoru0822-kitauji added bug Something isn't working track:bugs Small-bug loop track PRs labels Nov 2, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T10:17:22.035167Z 2458609 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge, with the previous finding fixed and no new actionable issues found.

Summary

This PR lets REA exit after debugger cleanup fails while reporting the retained processes.

  • REA can exit when an owned debugger cannot be stopped.
  • One-shot debugger results name the process REA could not stop.
  • MCP shutdown reports the debugger process it could not stop.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["CLI final cleanup or MCP shutdown"] --> B["Restore process and pipe references"]
  B --> C["Try to close each debugger"]
  C --> D{"Cleanup confirmed?"}
  D -->|Yes| E["Remove the stopped session"]
  D -->|No| F["Keep ownership and release event-loop references"]
  F --> G["Report retained PIDs"]
  G --> H["REA can exit with a nonzero status"]
Loading

Reviews (4) · Last reviewed commit: "test(debugger): compare retained-debugge..." · Reviewed by Greptile

Comment thread src/process/ownedDebuggerSession.ts Resolved
kaoru0822-kitauji and others added 3 commits November 2, 2026 08:34
An MCP server that exits with a retained debugger logged only "REA provider
cleanup failed" and "could not close cleanly", so nothing named the process
left for the caller to check. The PIDs were on OwnedDebuggerCleanupError, but
each shutdown reporter read only the outer aggregate's message, and the debug
failure_cause description dropped the field too.

The failure type now lives in its own dependency-free module with one reader
that walks composed failures. The shutdown report, the server's onclose log,
the debug description and the one-shot CLI all use it, and the shutdown report
writes the same remediation the CLI returns.

The fake debuggers now model each request exactly, with real reply shapes, and
a request they do not model fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// Nothing else reports these once the server has exited.
await expect
.poll(stderr, { timeout: EXIT_DEADLINE_MS })
.toContain(`Check process ${String(pid)} and its target`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wording edits break tests

The new checks here and in shutdown.test.ts hard-code “Check process … and its target”. docs/testing.md requires guidance checks to use the owning formatter or constant, alongside independent checks of the facts. Compare with retainedDebuggerRemediation([pid]) while keeping the separate process-ID and exit checks. This avoids failures on harmless wording changes. This repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/boundary/cli/debuggerOneShotExit.test.ts
Line: 398

Comment:
**Wording edits break tests**

The new checks here and in `shutdown.test.ts` hard-code “Check process … and its target”. `docs/testing.md` requires guidance checks to use the owning formatter or constant, alongside independent checks of the facts. Compare with `retainedDebuggerRemediation([pid])` while keeping the separate process-ID and exit checks. This avoids failures on harmless wording changes. This repository requirement must be satisfied before merging.

**Context Used:** AGENTS.md ([source](https://github.com/morluto/rea/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 245860975, a test-only change. docs/testing.md asks guidance checks to compare with the owning formatter, alongside independent checks of the facts.

Root cause: the CLI-level MCP test and shutdown.test.ts matched the sentence "Check process … and its target", so a harmless rewording would have failed them.

Fix:

  • Those two tests and the one-shot remediation.action check now compare with retainedDebuggerRemediation([pid]), exported from src/process/ownedDebuggerCleanupError.ts. Delivery adds no wrapping: stderr is that string plus a newline, and the CLI's remediation.action is that string exactly.
  • A formatter comparison alone would still pass if the formatter dropped the process, so each of those tests also checks that the guidance names the PID as a whole word. A new module test does the same for several PIDs.
  • The separate exit status, retained_debugger_pids and fake-process checks are unchanged.

Validation:

  • With the formatter changed to drop the PID, and the runtime rebuilt, 4 CLI and MCP tests, the shutdown test and the module test all fail.
  • With the wording changed and the PID kept, those same tests pass.
  • With the real formatter: debuggerOneShotExit 11/11 in 3 runs, shutdown 8/8, ownedDebuggerSession 44/44, and check:fast and format:check pass.

Main had not moved, so there is no merge in this push. No runtime code changed, and the source file is back to its committed state. Leaving this thread open for review.

The new checks froze the wording "Check process ... and its target".
docs/testing.md asks guidance checks to compare with the owning formatter,
alongside independent checks of the facts. They now do, and each still proves
the process ID is named, because a formatter comparison alone would pass if the
formatter dropped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kaoru0822-kitauji
kaoru0822-kitauji merged commit 144843c into main Nov 2, 2026
49 of 51 checks passed
@kaoru0822-kitauji
kaoru0822-kitauji deleted the fix/8642b-cli-debugger-one-shot-exit branch November 2, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working track:bugs Small-bug loop track PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant