Skip to content

feat(exec): retain terminal sessions after exit - #1146

Open
BatmanByte wants to merge 1 commit into
boxlite-ai:mainfrom
BatmanByte:codex/terminal-session-cleanup-pr2-main
Open

feat(exec): retain terminal sessions after exit#1146
BatmanByte wants to merge 1 commit into
boxlite-ai:mainfrom
BatmanByte:codex/terminal-session-cleanup-pr2-main

Conversation

@BatmanByte

@BatmanByte BatmanByte commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

A finished execution loses two things it should still be able to answer for. Its output is gone the moment the stream ends — a late Attach gets an empty stream over pipes already at EOF, not the bytes the execution produced. And its exit is only classifiable once, because the container-death diagnosis drains init's pipes, so a repeat Wait gets a different answer than the first. Meanwhile the entry itself is never removed at all: only the SSH bridges call release_ephemeral, so every SDK exec the guest ever ran stays in the map.

This gives an execution three lifecycle states. It stays readable for a bounded window after it exits, then decays to a small record, then goes away.

Call graph

Before

attach_execution   (GuestServer · src/guest/src/service/exec/mod.rs:66)
  └─ get           (ExecutionRegistry · registry.rs:44)   ← BUG: nothing removes SDK entries; the map grows for the life of the guest
       └─ attach   (ExecutionState · state.rs:293)        ← BUG: streams the live pipes, already at EOF — the produced output is unreachable

wait_execution     (GuestServer · mod.rs:94)
  └─ wait_exit     (ExecutionState · state.rs:253)
       └─ check_container_death  (state.rs:144)           ← BUG: diagnose_exit drains init's pipes, so the second Wait answers differently

After

observe_terminal            (ExecutionRegistry · registry.rs:447)   — one task per SDK exec
  └─ wait_exit              (ExecutionState · state.rs:322)
  └─ cancel_timeout_task    (state.rs:293)                          — the deadline is moot once the process is gone
  └─ wait_terminal_output_summary (state.rs:258)                    — drains, then seals the buffer
  └─ retain                 (registry.rs:287)                       — Live → Retained
       ↓ retain grace · tombstone TTL · entry cap · byte cap · LRU
  └─ prune_inner            (registry.rs:371)                       — Retained → Tombstone → removed

attach_execution            (mod.rs:67)
  └─ lookup                 (registry.rs:172)                       — Live | Retained | Tombstone | absent
       ├─ Live      → attach          (state.rs:369)
       ├─ Retained  → attach_retained (state.rs:376)                — replays the buffered bytes
       └─ Tombstone → terminal_output_receiver(snapshot.output)     — summary only

wait_execution              (mod.rs:118)
  └─ wait_exit              (state.rs:322)
       └─ terminal_exit OnceCell → classify_exit (state.rs:329)     — classified once; every later Wait reads that same result

Changes

  • ExecutionRegistry entries become LiveRetainedTombstone. Retained keeps the buffered output readable; a tombstone keeps only the classified exit and a truncated diagnosis. Retention is bounded on grace, TTL, entry count and retained bytes, with LRU eviction, so the registry can no longer grow without limit.
  • ExecutionState classifies its exit once into a OnceCell, which is what makes a repeat Wait return the same container-death diagnosis instead of an emptied one.
  • Terminal output is drained into a summary and then sealed, so a retained session replays bytes rather than re-reading dead pipes. A displaced forwarder is joined instead of leaked.
  • A reservation is taken before spawn and published after, closing the window where an execution that fails to register escapes as an orphan; one that cannot be published is SIGKILLed and torn down.
  • The timeout watcher returns its handle so a session that exits first cancels it, rather than leaving a task parked on a deadline.
  • Execution IDs are issued by the guest; a caller-supplied id is rejected on the normal exec path.

How to verify

make test:unit:guest

303 tests pass. The retention behavior is covered by terminal_observer_retains_a_completed_live_execution, retained_entry_exposes_its_terminal_snapshot_before_tombstoning, and a_tombstone_keeps_its_terminal_snapshot_repeatable; the bounds by the grace/TTL/LRU/byte-cap eviction tests in registry.rs; the repeatability fix by repeated_wait_caches_the_init_exit_diagnosis, which counts diagnose_exit calls and fails on main because the second Wait re-drains init's pipes.

Risks / rollout

Retained output is memory-bounded and expires. After eviction, terminal RPCs answer from the tombstone and then return NotFound — a caller that waits longer than the tombstone TTL sees a not-found where it previously saw a stale live entry.

Rebased onto #1185, which was split out of this branch and merged first. That PR's claim/token design was replaced during review by a ProcessInstance identity check, so this branch was re-ported onto the merged design rather than rebased hunk-by-hunk; it no longer touches reaper.rs or exec_handle.rs at all.

One gap is left open deliberately: release_ephemeral returning the reaper slot has no unit test. That path goes through the global REAPER, which main also leaves untested, and making it testable means re-adding API surface this branch does not otherwise need.

Summary by CodeRabbit

  • New Features

    • Improved execution lifecycle handling, including live, completed, and expired execution states.
    • Completed executions can retain terminal output for later attachment and replay.
    • Added clearer execution status and terminal output reporting.
    • Caller-provided execution IDs are validated consistently.
  • Bug Fixes

    • Prevented failed session or execution initialization from leaving resources behind.
    • Improved cleanup of timed-out, completed, and cancelled executions.
    • Added graceful handling when process handles or output readers are unavailable.
    • Prevented conflicting simultaneous attachments to the same terminal.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c9ce42d7-3947-4fb5-bb8c-30f915731496

📥 Commits

Reviewing files that changed from the base of the PR and between fc46370 and 31bfe5d.

📒 Files selected for processing (1)
  • src/guest/src/service/container.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/guest/src/service/container.rs

📝 Walkthrough

Walkthrough

The execution subsystem now tracks live, retained, and tombstone states. It supports terminal output summaries, exclusive attachment leases, ID validation, reservation-based startup, timeout cancellation, lifecycle pruning, shutdown cleanup, and explicit initialization registration failures.

Changes

Execution lifecycle

Layer / File(s) Summary
Reaper claims and timeout cleanup
src/guest/src/service/container.rs, src/guest/src/service/exec/timeout.rs, src/guest/src/service/exec/mod.rs
The reaper registers process exit slots at process spawn. Failed init registration aborts the unpublished session and releases reaper resources. Timeout watchers return cancellable task handles.
Terminal output and execution state
src/guest/src/service/exec/output.rs, src/guest/src/service/exec/state.rs
Output managers provide exclusive consumer leases, sealing, terminal summaries, retained attachment, and terminal events. Execution state tracks output and timeout tasks, cached exits, snapshots, and cleanup.
Lifecycle-aware execution registry
src/guest/src/service/exec/registry.rs
The registry supports reservations, live entries, retained snapshots, tombstones, limits, pruning, terminal observation, shutdown gating, and resource release.
Execution startup and service routing
src/guest/src/service/exec/mod.rs
Startup validates IDs and publishes reserved state. Operations route live, retained, and tombstone executions to live handles, retained output, snapshots, or unavailable-handle responses.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ExecutionService
  participant ExecutionRegistry
  participant ExecutionState
  participant OutputManager
  Client->>ExecutionService: start execution
  ExecutionService->>ExecutionRegistry: reserve execution ID
  ExecutionService->>ExecutionState: spawn and configure execution
  ExecutionService->>ExecutionRegistry: publish execution state
  ExecutionRegistry->>ExecutionState: observe terminal state
  ExecutionState->>OutputManager: collect terminal output summary
  Client->>ExecutionService: attach or wait
  ExecutionService->>ExecutionRegistry: look up lifecycle state
  ExecutionRegistry-->>ExecutionService: return live, retained, or tombstone state
Loading

Possibly related PRs

Suggested reviewers: dorianzheng

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description includes the required summary, call graph, changes, verification, and risks sections with specific implementation and test details.
Title check ✅ Passed The title clearly and concisely describes the main change: retaining terminal execution sessions after exit.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@BatmanByte
BatmanByte marked this pull request as ready for review August 5, 2026 07:29
@BatmanByte
BatmanByte requested a review from a team as a code owner August 5, 2026 07:29
@boxlite-agent

boxlite-agent Bot commented Aug 5, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"2f4efcb4-6df5-4c6f-aeab-26b2b2ca9d8f","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":403,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","type":"result","duration_ms":364,"uuid":"f81efecd-3a77-459c-895c-a3f0d5e202d6"}

stderr:
<empty>

powered by BoxLite

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/guest/src/service/exec/state.rs (1)

416-462: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A displaced output_task can trip the assert and leak its handle.

ConsumerLease::drop in src/guest/src/service/exec/output.rs Lines 55-60 releases the lease when the AttachStream is dropped. The AttachStream is owned by the forwarder task spawned at Line 437, so it drops as that task body ends, before JoinHandle::is_finished() reports true.

In that window a second attach_output call can:

  1. run join_finished_output_task, which returns None because the previous handle is not finished yet,
  2. pass the released check,
  3. claim the now-free consumer lease at Line 431 or Line 432,
  4. reach Line 451 with inner.output_task still Some(..).

debug_assert!(inner.output_task.is_none()) then panics in debug builds. In release builds Line 452 overwrites the previous handle, so that handle is dropped without being awaited.

Take the existing handle instead of asserting it is absent, and await it after the lock is released.

🐛 Proposed fix to displace the previous handle safely
-        let task_to_abort = {
+        let (task_to_abort, displaced) = {
             let mut inner = self.inner.lock().await;
             if inner.released {
-                Some(task)
+                (Some(task), None)
             } else {
-                debug_assert!(inner.output_task.is_none());
-                inner.output_task = Some(task);
-                None
+                let displaced = inner.output_task.replace(task);
+                (None, displaced)
             }
         };
+        if let Some(displaced) = displaced {
+            // The lease was released as the previous forwarder ended; let it finish.
+            let _ = displaced.await;
+        }
         if let Some(task) = task_to_abort {
             task.abort();
             let _ = task.await;
             return Err(ExecutionError::HandleUnavailable);
         }
         Ok(rx)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guest/src/service/exec/state.rs` around lines 416 - 462, Update
attach_output to safely displace any existing inner.output_task instead of
asserting it is absent. While holding the lock, replace the previous handle with
the newly spawned task, then release the lock and await the displaced handle
before returning or completing the attachment, preserving the released-state
cleanup and HandleUnavailable behavior.
🧹 Nitpick comments (4)
src/guest/src/service/exec/output.rs (1)

253-294: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the duplicated summary construction.

terminal_summary and sealed_terminal_summary build the identical OutputTerminalSummary from state; only the precondition differs. A private helper on OutputState removes the duplication and keeps the two preconditions visible.

♻️ Proposed refactor
     pub(crate) async fn terminal_summary(&self) -> Option<OutputTerminalSummary> {
         let state = self.inner.lock().await;
         if !state.stdout.finished || !state.stderr.finished {
             return None;
         }
-
-        Some(OutputTerminalSummary {
-            stdout: OutputStreamSummary {
-                enabled: state.stdout.enabled,
-                total_bytes: state.stdout.total_bytes,
-            },
-            stderr: OutputStreamSummary {
-                enabled: state.stderr.enabled,
-                total_bytes: state.stderr.total_bytes,
-            },
-            reader_failure: state
-                .failure
-                .as_ref()
-                .map(|failure| failure.message.clone()),
-        })
+        Some(state.summary())
     }
 
     pub(crate) async fn sealed_terminal_summary(&self) -> Option<OutputTerminalSummary> {
         let state = self.inner.lock().await;
         if !state.sealed {
             return None;
         }
-        Some(OutputTerminalSummary {
-            stdout: OutputStreamSummary {
-                enabled: state.stdout.enabled,
-                total_bytes: state.stdout.total_bytes,
-            },
-            stderr: OutputStreamSummary {
-                enabled: state.stderr.enabled,
-                total_bytes: state.stderr.total_bytes,
-            },
-            reader_failure: state
-                .failure
-                .as_ref()
-                .map(|failure| failure.message.clone()),
-        })
+        Some(state.summary())
     }

Add to impl OutputState:

fn summary(&self) -> OutputTerminalSummary {
    OutputTerminalSummary {
        stdout: OutputStreamSummary {
            enabled: self.stdout.enabled,
            total_bytes: self.stdout.total_bytes,
        },
        stderr: OutputStreamSummary {
            enabled: self.stderr.enabled,
            total_bytes: self.stderr.total_bytes,
        },
        reader_failure: self.failure.as_ref().map(|failure| failure.message.clone()),
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guest/src/service/exec/output.rs` around lines 253 - 294, Add a private
OutputState::summary helper containing the shared OutputTerminalSummary
construction, then update terminal_summary and sealed_terminal_summary to retain
their existing precondition checks and return state.summary().
src/guest/src/service/exec/mod.rs (1)

306-321: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Couple the channel capacity to the event count.

The capacity 2 at Line 309 is correct only because output::terminal_events emits at most one event per stream. That bound lives in src/guest/src/service/exec/output.rs Lines 450-459. If a third terminal event is ever added, try_send returns Full and the expect at Line 316 panics on a request path.

Build the events first and size the channel from their count.

♻️ Proposed refactor
 fn terminal_output_receiver(
     summary: output::OutputTerminalSummary,
 ) -> mpsc::Receiver<Result<ExecOutput, Status>> {
-    let (tx, rx) = mpsc::channel(2);
     if let Some(failure) = summary.reader_failure {
+        let (tx, rx) = mpsc::channel(1);
         tx.try_send(Err(Status::internal(failure)))
             .expect("terminal attach receiver must be live");
-    } else {
-        for event in output::terminal_events(&summary) {
-            tx.try_send(Ok(event))
-                .expect("terminal attach receiver must be live");
-        }
+        return rx;
+    }
+    let events = output::terminal_events(&summary);
+    let (tx, rx) = mpsc::channel(events.len().max(1));
+    for event in events {
+        tx.try_send(Ok(event))
+            .expect("terminal attach receiver must be live");
     }
     rx
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guest/src/service/exec/mod.rs` around lines 306 - 321, Update
terminal_output_receiver to build the terminal events before creating the
channel, then size the channel capacity from the resulting event count; preserve
the reader_failure branch and existing try_send behavior while ensuring all
events can be queued without expect panicking due to a full channel.
src/guest/src/service/exec/registry.rs (2)

367-371: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the duplicate test-only prune wrappers.

prune_at and prune_for_test are both #[cfg(test)], and prune_for_test only forwards to prune_at. Keep one name.

♻️ Proposed refactor
     #[cfg(test)]
-    async fn prune_at(&self, now: Instant) {
+    async fn prune_for_test(&self, now: Instant) {
         Self::prune_inner(&self.inner, now).await;
     }

Then remove the separate prune_for_test wrapper at Lines 409-412.

Also applies to: 409-412

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guest/src/service/exec/registry.rs` around lines 367 - 371, Collapse the
duplicate test-only wrappers by keeping a single #[cfg(test)] method, preferably
prune_at, and removing prune_for_test and its forwarding call. Update all test
call sites to use the retained method name.

323-357: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid cloning every retained entry on the common no-eviction path.

Lines 323-342 clone the id, the ExecutionState, and the whole TerminalSnapshot for every retained entry on each call, while the registry mutex is held. TerminalSnapshot carries two diagnostic String values, and MAX_RETAINED_ENTRIES is 64, so up to 64 snapshot clones run per completed execution. In the common case the limits are not exceeded and every clone is discarded.

Collect only (id, retained_bytes, last_access) for the sort, then remove and clone the full entry for the ids that are actually evicted.

♻️ Proposed refactor sketch
-                let mut retained: Vec<_> = inner
-                    .entries
-                    .iter()
-                    .filter_map(|(id, entry)| match entry {
-                        ExecutionEntry::Retained {
-                            state,
-                            snapshot,
-                            retained_bytes,
-                            last_access,
-                            ..
-                        } => Some((
-                            id.clone(),
-                            state.clone(),
-                            snapshot.clone(),
-                            *retained_bytes,
-                            *last_access,
-                        )),
-                        _ => None,
-                    })
-                    .collect();
-                retained.sort_by_key(|(_, _, _, _, last_access)| *last_access);
-                let mut total_bytes: usize =
-                    retained.iter().map(|(_, _, _, bytes, _)| *bytes).sum();
+                let mut retained: Vec<_> = inner
+                    .entries
+                    .iter()
+                    .filter_map(|(id, entry)| match entry {
+                        ExecutionEntry::Retained {
+                            retained_bytes,
+                            last_access,
+                            ..
+                        } => Some((id.clone(), *retained_bytes, *last_access)),
+                        _ => None,
+                    })
+                    .collect();
+                retained.sort_by_key(|(_, _, last_access)| *last_access);
+                let mut total_bytes: usize = retained.iter().map(|(_, bytes, _)| *bytes).sum();
                 let mut evicted = Vec::new();
                 while retained.len() > MAX_RETAINED_ENTRIES || total_bytes > MAX_RETAINED_BYTES {
-                    let (id, state, snapshot, bytes, _) = retained.remove(0);
+                    let (id, bytes, _) = retained.remove(0);
                     total_bytes -= bytes;
+                    let Some(ExecutionEntry::Retained { state, snapshot, .. }) =
+                        inner.entries.remove(&id)
+                    else {
+                        continue;
+                    };
                     let access = next_access(&mut inner);
                     inner.entries.insert(
                         id,
                         tombstone_entry(snapshot, Instant::now() + TOMBSTONE_TTL, access),
                     );
                     evicted.push(state);
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/guest/src/service/exec/registry.rs` around lines 323 - 357, Update the
retained-entry eviction logic to collect only each entry’s id, retained_bytes,
and last_access for sorting, avoiding clones of ExecutionState and
TerminalSnapshot on the no-eviction path. After determining eviction candidates
in the existing while loop, remove each selected entry from inner.entries and
clone the state and snapshot only for entries actually evicted, then create the
tombstone and preserve the existing byte and tombstone-limit behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/guest/src/reaper.rs`:
- Around line 1133-1149: Serialize all three real-child tests with the shared
test guard to prevent concurrent interaction with REAP_LOCK: in
src/guest/src/reaper.rs lines 1133-1149, acquire the guard before reap_fence()
in signal_leader_terminates_the_live_claimed_process; in
src/guest/src/service/exec/state.rs lines 689-719, wrap child.wait() in
reap_fence() and execute it via spawn_blocking; in
src/guest/src/service/exec/timeout.rs lines 90-123, add the same guard while
retaining its existing reap_fence() and spawn_blocking usage.

In `@src/guest/src/service/exec/mod.rs`:
- Around line 441-463: In the execution registry branch following the
reservation publish path, replace the nested `else { if ... }` around
`server.registry.register` with an `else if` or equivalent single conditional
expression. Preserve the existing shutdown error response and
`state.abort_unpublished().await` behavior when registration fails.

In `@src/guest/src/service/exec/registry.rs`:
- Around line 1129-1162: Update
shutdown_releases_live_and_retained_state_resources to avoid assigning
fabricated live PIDs that shutdown_all may signal on the host. Use
guaranteed-nonexistent PIDs for the settled_state handles, or invoke
release_remaining_states directly while preserving the assertions that both
states release their resources and registry entries.
- Around line 520-557: Update shutdown_all to route both SIGTERM and SIGKILL
through each execution state’s ExitClaimOwner and Reaper::signal_leader_if_live,
matching the behavior used by TimeoutTarget::signal_if_live. Remove raw
state.get_pid() and kill(pid, None) probing for signal decisions, while
preserving the existing wait loop and graceful-exit handling.
- Around line 448-470: Update observe_terminal so wait_terminal_output_summary
is bounded by the execution’s existing deadline or timeout mechanism after the
leader exits, while preserving the normal completed-summary path. Ensure a stuck
stdout/stderr reader cannot leave the ExecutionEntry in Live indefinitely and
that the flow still reaches retain with the available output, producing Retained
or Tombstone as appropriate.

In `@src/guest/src/service/exec/state.rs`:
- Around line 278-293: Update wait_terminal_output_summary to await
output.seal() unconditionally, bind its boolean result, and apply debug_assert!
to that bound result so release builds still seal the OutputManager.

---

Outside diff comments:
In `@src/guest/src/service/exec/state.rs`:
- Around line 416-462: Update attach_output to safely displace any existing
inner.output_task instead of asserting it is absent. While holding the lock,
replace the previous handle with the newly spawned task, then release the lock
and await the displaced handle before returning or completing the attachment,
preserving the released-state cleanup and HandleUnavailable behavior.

---

Nitpick comments:
In `@src/guest/src/service/exec/mod.rs`:
- Around line 306-321: Update terminal_output_receiver to build the terminal
events before creating the channel, then size the channel capacity from the
resulting event count; preserve the reader_failure branch and existing try_send
behavior while ensuring all events can be queued without expect panicking due to
a full channel.

In `@src/guest/src/service/exec/output.rs`:
- Around line 253-294: Add a private OutputState::summary helper containing the
shared OutputTerminalSummary construction, then update terminal_summary and
sealed_terminal_summary to retain their existing precondition checks and return
state.summary().

In `@src/guest/src/service/exec/registry.rs`:
- Around line 367-371: Collapse the duplicate test-only wrappers by keeping a
single #[cfg(test)] method, preferably prune_at, and removing prune_for_test and
its forwarding call. Update all test call sites to use the retained method name.
- Around line 323-357: Update the retained-entry eviction logic to collect only
each entry’s id, retained_bytes, and last_access for sorting, avoiding clones of
ExecutionState and TerminalSnapshot on the no-eviction path. After determining
eviction candidates in the existing while loop, remove each selected entry from
inner.entries and clone the state and snapshot only for entries actually
evicted, then create the tombstone and preserve the existing byte and
tombstone-limit behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a51d5bb4-bde3-477d-a31c-d445aa547607

📥 Commits

Reviewing files that changed from the base of the PR and between 2700865 and 2632109.

📒 Files selected for processing (7)
  • src/guest/src/reaper.rs
  • src/guest/src/service/container.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/output.rs
  • src/guest/src/service/exec/registry.rs
  • src/guest/src/service/exec/state.rs
  • src/guest/src/service/exec/timeout.rs

Comment thread src/guest/src/reaper.rs Outdated
Comment thread src/guest/src/service/exec/mod.rs
Comment thread src/guest/src/service/exec/registry.rs Outdated
Comment thread src/guest/src/service/exec/registry.rs
Comment thread src/guest/src/service/exec/registry.rs
Comment thread src/guest/src/service/exec/state.rs
@BatmanByte

Copy link
Copy Markdown
Contributor Author

Review disposition

All six inline findings plus the outside-diff one are handled in 9db68d1 / 2ace576. Per-thread replies are inline; the two findings with no inline thread are covered here.

Displaced output_task (outside diff range, state.rs 416-462) — fixed. The most valuable catch in this review, and the one nearest to being missed, since GitHub could only post it in the review body rather than as a thread. attach_output no longer asserts the slot is empty:

let displaced = inner.output_task.replace(task);
// ... lock released ...
if let Some(displaced) = displaced { let _ = displaced.await; }

The join is bounded, which is why it is safe inside the handler: attach() and attach_retained() share one exclusive consumer_lease, so reaching that point at all means the previous stream has already dropped and its forwarder is in its final poll. No reproducer accompanies it — the window opens and closes inside that poll — and the commit body says so rather than implying it is test-covered.

OutputTerminalSummary duplication (output.rs 253-294) — not taken. The duplication is real, but the differing precondition is the point of the two methods, and extracting a helper would widen an already large diff for no behavioural gain. Left for a follow-up.

Deliberately out of scope: the unbounded terminal-summary wait (registry.rs:470) — reasoning on that thread.

Stated plainly, three parts carry no test: the seal() fix (debug_assert! runs under the test profile, so no test can discriminate it), the displaced-forwarder join, and Kill with process_group == true still reaching kill(-pid) through the handle's own group-leader check.

Verification: 9db68d1 went green on all three Clippy platforms and all three Rust Tests platforms; guest unit tests are 305/305 on Linux, with the two new shutdown/kill tests demonstrated red-then-green against a full revert of the production changes. 2ace576 is a one-line #[allow(dead_code)]#[cfg(test)] gate.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/guest/src/service/container.rs`:
- Around line 486-499: Update the failed registration branch in the container
initialization flow to pass state.clone() into registry.register, then await
state.abort_unpublished() before returning the error response. Preserve
reaper.release_slot(&exit) and the existing response behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7295d341-b2e1-47aa-bc8b-716d563366f9

📥 Commits

Reviewing files that changed from the base of the PR and between 038938c and fc46370.

📒 Files selected for processing (6)
  • src/guest/src/service/container.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/output.rs
  • src/guest/src/service/exec/registry.rs
  • src/guest/src/service/exec/state.rs
  • src/guest/src/service/exec/timeout.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/guest/src/service/exec/output.rs
  • src/guest/src/service/exec/state.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/registry.rs

Comment thread src/guest/src/service/container.rs
An execution's registry entry disappeared as soon as its output stream
ended, so a Wait or Attach arriving afterwards found nothing and the caller
could not tell "never existed" from "already finished".

The registry now holds three states per execution. Live behaves as before.
Once an execution's exit and output summary are both in, it becomes
Retained and its buffered output stays readable. After the retain grace it
degrades to a Tombstone holding only the classified exit and a truncated
diagnosis. Retention is bounded on grace, TTL, entry count and retained
bytes, with LRU eviction, so a long-lived box cannot grow the registry
without limit.

Reservations close the window between spawn and register: an execution that
cannot be published is SIGKILLed and torn down instead of escaping as an
orphan.

The timeout watcher now returns its handle so a session that already exited
cancels it rather than leaving a task parked on a deadline.
@BatmanByte
BatmanByte force-pushed the codex/terminal-session-cleanup-pr2-main branch from fc46370 to 31bfe5d Compare August 12, 2026 08:33
@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 31bfe5dc0a

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@BatmanByte

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant