Skip to content

feat(exec): drain guest output before attach - #1022

Merged
DorianZheng merged 12 commits into
boxlite-ai:mainfrom
BatmanByte:codex/init-stdio-drain
Jul 30, 2026
Merged

feat(exec): drain guest output before attach#1022
DorianZheng merged 12 commits into
boxlite-ai:mainfrom
BatmanByte:codex/init-stdio-drain

Conversation

@BatmanByte

@BatmanByte BatmanByte commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Drain each guest execution stdout and stderr as soon as it is created. This removes the dependency on a host Attach consumer for the guest process to make progress.

Guest stdio uses the Tokio I/O reactor rather than tokio fs. Idle pipes no longer occupy blocking-pool workers. Stdin uses the same reactor-backed path because PTY stdin and stdout descriptors share the non-blocking setting.

The guest retains at most 1 MiB of output for a late Attach. When that ring overwrites old bytes, Attach emits an OutputDropped control event with separate stdout and stderr byte counts. The host renders the notice and resets only the affected UTF-8 decoder.

Scope

This PR intentionally contains only guest exec stdio, the shared gRPC protocol, and the local gRPC consumer needed to render it. It does not change REST wait behavior, the runner, API, CLI, or host output queueing.

Verification

  • make test:integration:rust FILTER=main_command_exits_after_large_output_without_attach
  • make test:integration:rust FILTER=late_attach_reports_output_dropped
  • make test:integration:rust FILTER=test_zygote_concurrent_stdin_pipes
  • make clippy

The integration commands ran outside the sandbox against real macOS Hypervisor VMs.

make fmt:check is currently blocked before Rust checking by 186 pre-existing Prettier violations in untouched generated apps client files; make fmt:check:rust passed.

@BatmanByte
BatmanByte requested a review from a team July 23, 2026 08:42
@boxlite-agent

boxlite-agent Bot commented Jul 23, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

review model timed out after 39 minutes without a /publish callback
repo: boxlite-ai/boxlite
pr: 1022
head: 213ef8b7052478a1f7caf78d62b12983028a68a9
box: pr-review-boxlite-1022-ms74jsva
last stage: model started
stage detail: claude
stage updated: 2026-07-30T06:20:31.838Z

powered by BoxLite

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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
📝 Walkthrough

Walkthrough

Execution output now uses bounded channels and guest-side sequenced buffering. Dropped output is reported explicitly, WebSocket streaming is separated from completion reporting, and wait endpoints provide execution status across REST, runner, and CLI servers.

Changes

Execution output and completion flow

Layer / File(s) Summary
Host bounded output pipeline
src/boxlite/src/litebox/exec.rs, src/boxlite/src/portal/interfaces/exec.rs
Output channels and decoders support bounded asynchronous delivery, backpressure, cancellation, UTF-8 boundary handling, and dropped-frame flushing.
Guest output buffering and attach wiring
src/shared/proto/boxlite/v1/service.proto, src/guest/src/service/exec/*
Guest output is drained into a sequenced buffer, with evicted data represented by OutputDropped; attach returns the buffered stream directly.
REST attach streaming and wait completion
src/boxlite/src/rest/litebox.rs, src/boxlite/src/rest/client.rs, apps/api/src/boxlite-rest/*, apps/runner/pkg/api/*, openapi/box.openapi.yaml
WebSocket output uses bounded channels, while terminal results are retrieved through a dedicated HTTP wait endpoint with retry, proxy, and runner routing.
CLI wait endpoint and completion signaling
src/cli/src/commands/serve/*
The CLI server exposes execution waiting, shares status serialization, and observes completion through a watch signal.
Output buffering integration tests
src/boxlite/tests/run_main_command.rs
Tests cover large output without an attach consumer and dropped output after late attachment.

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

Sequence Diagram(s)

sequenceDiagram
  participant ExecProtocol
  participant OutputManager
  participant WebSocketAttach
  participant WaitEndpoint
  participant Execution
  ExecProtocol->>OutputManager: deliver decoded output
  WebSocketAttach->>OutputManager: attach to buffered stream
  OutputManager-->>WebSocketAttach: ordered output or OutputDropped
  WaitEndpoint->>Execution: await terminal completion
  Execution-->>WaitEndpoint: execution status and exit code
Loading

Possibly related PRs

Suggested labels: bug, rust, e2e-test

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is missing the required Changes section and incorrectly claims the PR excludes REST/runner/CLI changes that are actually included. Add a Changes bullet list and revise the Scope to cover the REST wait/proxy, runner, API, CLI, and OpenAPI updates reflected in the diff.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title is concise and accurately captures the main change: draining guest output before attach.
✨ 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.

@BatmanByte
BatmanByte force-pushed the codex/init-stdio-drain branch from b9f4787 to b0ee85c Compare July 23, 2026 08:49

@coderabbitai coderabbitai Bot left a comment

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.

Caution

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

⚠️ Outside diff range comments (1)
src/boxlite/src/portal/interfaces/exec.rs (1)

338-388: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

route_output().await ignores shutdown_token — a stalled consumer blocks shutdown indefinitely.

The select! at lines 338-353 only guards stream.message(); the per-message Self::route_output(...).await at line 358 (and the terminal-path flushes/sends at lines 371-384) run outside it. Since mpsc::Sender::send().await on the now-bounded channel blocks until capacity frees up, a consumer that is alive but not polling (hasn't dropped its ExecStdout/ExecStderr) makes this task ignore shutdown_token.cancelled() for as long as the channel stays full — exactly the scenario the non-blocking try_flush() on the cancellation branch was designed to avoid.

🔧 Race the per-message routing against cancellation too
                         match output.transpose() {
                             Some(Ok(output)) => {
                                 message_count += 1;
-                                Self::route_output(output, &mut stdout, &mut stderr).await;
+                                tokio::select! {
+                                    biased;
+                                    _ = shutdown_token.cancelled() => {
+                                        stdout.try_flush();
+                                        stderr.try_flush();
+                                        break;
+                                    }
+                                    _ = Self::route_output(output, &mut stdout, &mut stderr) => {}
+                                }
                             }

The same gap applies to the error/EOF terminal flushes (lines 371-384), though its impact there is smaller since the loop is about to exit anyway.

🤖 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/boxlite/src/portal/interfaces/exec.rs` around lines 338 - 388, Update the
attach-stream loop around route_output and the terminal error/EOF handling so
every potentially blocking flush or send is raced against
shutdown_token.cancelled(). Ensure cancellation exits promptly without awaiting
a bounded-channel operation indefinitely, while preserving normal output
ordering and existing cleanup behavior when no shutdown occurs.
🧹 Nitpick comments (1)
src/guest/src/service/exec/output.rs (1)

16-19: 🧹 Nitpick | 🔵 Trivial

Unbounded ring accumulation across completed executions.

Each OutputManager retains its 1 MiB ring after the process exits (held by Inner.output and any live attach-stream clone), so total guest memory grows with the number of completed-but-uncleaned executions. The PR already notes TTL/cleanup as future work; consider bounding this with an eviction/TTL on completed executions before it becomes a guest-OOM vector under high exec churn.

🤖 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 16 - 19, Bound completed
execution state retained by OutputManager so its 1 MiB ring is not kept
indefinitely after process exit. Add eviction or TTL cleanup for completed
executions, including releasing Inner.output and any attach-stream-held clones
when cleanup occurs, while preserving access for active executions.
🤖 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.

Outside diff comments:
In `@src/boxlite/src/portal/interfaces/exec.rs`:
- Around line 338-388: Update the attach-stream loop around route_output and the
terminal error/EOF handling so every potentially blocking flush or send is raced
against shutdown_token.cancelled(). Ensure cancellation exits promptly without
awaiting a bounded-channel operation indefinitely, while preserving normal
output ordering and existing cleanup behavior when no shutdown occurs.

---

Nitpick comments:
In `@src/guest/src/service/exec/output.rs`:
- Around line 16-19: Bound completed execution state retained by OutputManager
so its 1 MiB ring is not kept indefinitely after process exit. Add eviction or
TTL cleanup for completed executions, including releasing Inner.output and any
attach-stream-held clones when cleanup occurs, while preserving access for
active executions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 104bcb7e-857c-4403-a78a-aac86bcf1d0f

📥 Commits

Reviewing files that changed from the base of the PR and between a5da5b4 and b0ee85c.

📒 Files selected for processing (9)
  • src/boxlite/src/litebox/exec.rs
  • src/boxlite/src/litebox/mod.rs
  • src/boxlite/src/portal/interfaces/exec.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/tests/run_main_command.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/output.rs
  • src/guest/src/service/exec/state.rs
  • src/shared/proto/boxlite/v1/service.proto

@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e8b966028

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

Comment thread src/boxlite/src/rest/litebox.rs Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
src/cli/src/commands/serve/handlers/executions.rs (1)

110-140: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

wait/status responses never surface error_message, unlike the REST server and Go runner.

execution_status_body only emits execution_id, status, and exit_code. This isn't just a JSON-construction gap — ActiveExecution's wait task (mod.rs) only stores result.exit_code from Execution::wait(), discarding result.error_message entirely, so there's nothing to surface even if this function were extended. Both the Go runner (ExecutionInfoResponse.ErrorMessage) and the REST client's ExecutionStatusResponse.error_message carry this field — e.g. the container-init-death diagnosis attached when a process is SIGKILLed via PID-namespace teardown. The boxlite serve backend is now the one implementation of this shared "wait" contract that can't report it.

Consider storing error_message alongside exit_code on ActiveExecution (populated from the same wait() call) and including it in execution_status_body when present.

🤖 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/cli/src/commands/serve/handlers/executions.rs` around lines 110 - 140,
Extend ActiveExecution to retain result.error_message alongside the exit code
when its wait task processes Execution::wait(). Update execution_status_body and
the related wait/status response path to include error_message when present,
preserving the existing execution_id, status, and exit_code fields and omitting
the new field when absent.
🧹 Nitpick comments (2)
src/boxlite/src/rest/litebox.rs (1)

73-90: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Spawned WS pump task has no lifecycle handle; bounded output channel can block it forever.

tokio::spawn(...) return values are discarded in both wire_attach and exec(). Combined with the new bounded stdout_tx/stderr_tx channels, attach_ws_pump's stdout_tx.send(text).await (line 802/806) blocks the entire pump loop — including reading the eventual exit frame and sending keepalive pings — whenever the channel is full. The added test wait_returns_when_unread_output_fills_the_websocket_queue demonstrates exactly this and has to attach.abort() manually to clean up; production callers have no equivalent hook. A caller that keeps an Execution alive (e.g., just to call wait()/signal()) without draining stdout()/stderr() to completion will leave the WS connection and task stuck open until the Execution itself is dropped.

Worth confirming this is the intended tradeoff (bounded memory vs. potentially long-lived stuck connections for non-draining consumers), and if so, consider retaining the JoinHandle so it can be aborted once wait_execution resolves (or documenting the drain requirement on Execution).

Also applies to: 154-173

🤖 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/boxlite/src/rest/litebox.rs` around lines 73 - 90, Retain the JoinHandle
returned by the spawned attach_ws_pump task in both wire_attach and exec, and
tie its lifecycle to wait_execution so the pump is aborted once execution
completion resolves, including when output channels are not drained. Ensure the
resulting Execution retains or exposes the necessary handle and cleanup occurs
without changing normal output consumption behavior.
apps/api/src/boxlite-rest/boxlite-proxy.controller.ts (1)

131-151: 🩺 Stability & Availability | 🔵 Trivial

Confirm nothing upstream cuts this connection before the 25h proxyTimeout.

proxyTimeout only bounds the proxy→runner leg. If the Node http server (main.ts, not in this changeset) or any load balancer/reverse proxy in front of this service has a shorter idle/socket timeout, a long-running wait could still be truncated well before 25h, which would surface to SDK callers as a spurious network failure on the very endpoint meant to be authoritative for completion.

Also applies to: 255-269

🤖 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 `@apps/api/src/boxlite-rest/boxlite-proxy.controller.ts` around lines 131 -
151, The long-running proxyExecWait request may be terminated by shorter
upstream Node HTTP server or load-balancer idle/socket timeouts despite its
25-hour proxyTimeout. Inspect and update the relevant server and deployment
proxy timeout configuration so the connection remains valid for at least the
full wait duration, covering the equivalent configuration for the other affected
endpoint as well.
🤖 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/boxlite/src/rest/litebox.rs`:
- Around line 974-1015: Update wait_execution to retry transient transport
errors from get_with_timeout using bounded backoff consistent with
WS_RECONNECT_BUDGET before producing an ExecResult failure. Preserve immediate
handling of terminal statuses and non-terminal responses, and send the synthetic
error result only after the retry budget is exhausted.

---

Outside diff comments:
In `@src/cli/src/commands/serve/handlers/executions.rs`:
- Around line 110-140: Extend ActiveExecution to retain result.error_message
alongside the exit code when its wait task processes Execution::wait(). Update
execution_status_body and the related wait/status response path to include
error_message when present, preserving the existing execution_id, status, and
exit_code fields and omitting the new field when absent.

---

Nitpick comments:
In `@apps/api/src/boxlite-rest/boxlite-proxy.controller.ts`:
- Around line 131-151: The long-running proxyExecWait request may be terminated
by shorter upstream Node HTTP server or load-balancer idle/socket timeouts
despite its 25-hour proxyTimeout. Inspect and update the relevant server and
deployment proxy timeout configuration so the connection remains valid for at
least the full wait duration, covering the equivalent configuration for the
other affected endpoint as well.

In `@src/boxlite/src/rest/litebox.rs`:
- Around line 73-90: Retain the JoinHandle returned by the spawned
attach_ws_pump task in both wire_attach and exec, and tie its lifecycle to
wait_execution so the pump is aborted once execution completion resolves,
including when output channels are not drained. Ensure the resulting Execution
retains or exposes the necessary handle and cleanup occurs without changing
normal output consumption behavior.
🪄 Autofix (Beta)

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: a936e2d1-b314-4d66-b16f-c402e6108a55

📥 Commits

Reviewing files that changed from the base of the PR and between 1e8b966 and 6f87c18.

📒 Files selected for processing (11)
  • apps/api/src/boxlite-rest/boxlite-proxy.controller.ts
  • apps/runner/pkg/api/controllers/boxlite_exec.go
  • apps/runner/pkg/api/controllers/boxlite_exec_test.go
  • apps/runner/pkg/api/server.go
  • openapi/box.openapi.yaml
  • src/boxlite/src/rest/client.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/src/rest/types.rs
  • src/cli/src/commands/serve/README.md
  • src/cli/src/commands/serve/handlers/executions.rs
  • src/cli/src/commands/serve/mod.rs

Comment thread src/boxlite/src/rest/litebox.rs Outdated

Copy link
Copy Markdown
Contributor Author

Review follow-up:

  • The long-lived REST wait / proxy timeout finding is addressed in f28b16c. Each server poll is bounded to 45 minutes, the API proxy has a 50-minute limit, and the SDK has a 55-minute request limit. running responses renew the poll; transient transport failures retry with bounded exponential backoff.
  • The CLI error_message parity finding is valid but intentionally left for a separate follow-up. This change remains limited to making REST wait work across the existing one-hour ALB idle timeout.
  • I did not abort the REST WebSocket pump when wait() resolves. That would cut output for callers that read stdout/stderr after waiting. A separate output-consumer lifecycle/cancellation policy is needed for that resource-retention tradeoff.

Tests include REST wait retry coverage and the runner wait-timeout controller test.

@coderabbitai coderabbitai Bot left a comment

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.

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 `@openapi/box.openapi.yaml`:
- Around line 644-647: Update the endpoint’s 200-response description in the
OpenAPI definition to document both outcomes: a terminal execution result and a
non-terminal status: "running" response when the 45-minute wait expires. Replace
the “Terminal execution result” wording while preserving the existing timeout
and retry behavior details.
🪄 Autofix (Beta)

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: 3709c682-a0e9-424e-a3a5-cc86ec7fa2b0

📥 Commits

Reviewing files that changed from the base of the PR and between 6f87c18 and f28b16c.

📒 Files selected for processing (7)
  • apps/api/src/boxlite-rest/boxlite-proxy.controller.ts
  • apps/runner/pkg/api/controllers/boxlite_exec.go
  • apps/runner/pkg/api/controllers/boxlite_exec_test.go
  • openapi/box.openapi.yaml
  • src/boxlite/src/rest/litebox.rs
  • src/cli/src/commands/serve/README.md
  • src/cli/src/commands/serve/handlers/executions.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/api/src/boxlite-rest/boxlite-proxy.controller.ts
  • src/cli/src/commands/serve/README.md
  • src/cli/src/commands/serve/handlers/executions.rs
  • src/boxlite/src/rest/litebox.rs

Comment thread openapi/box.openapi.yaml Outdated
@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f28b16c618

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

Comment thread src/boxlite/src/rest/litebox.rs Outdated
@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

@BatmanByte BatmanByte changed the title feat(exec): buffer output before attach feat(exec): buffer output before attach and separate execution wait Jul 24, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7b2ce1a8c

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

Comment thread src/boxlite/src/rest/litebox.rs Outdated

@DorianZheng DorianZheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

most of these trace to two changes in this PR — the unbounded→bounded output channels, and the narrowed wait-retry predicate:

  • one task routes both streams through blocking bounded sends, so reading only one of stdout/stderr can stall the other (and on the ws pump a full channel also blocks stdin + the keepalive ping).
  • wait_execution retries only Network, so a transient 503/timeout/429 becomes a cached exit_code:-1.

the rest are resource retention (guest ring + leaked wait tasks/timers), the auto-pause vs long-poll interaction, and a few decoder/observability items.

Comment thread src/boxlite/src/portal/interfaces/exec.rs Outdated
Comment thread src/boxlite/src/rest/litebox.rs Outdated
Comment thread src/boxlite/src/rest/litebox.rs Outdated
Comment thread apps/api/src/boxlite-rest/boxlite-proxy.controller.ts Outdated
Comment thread src/guest/src/service/exec/output.rs
Comment thread src/guest/src/service/exec/output.rs
Comment thread src/boxlite/src/portal/interfaces/exec.rs Outdated
Comment thread src/boxlite/src/rest/litebox.rs Outdated
Comment thread src/boxlite/src/portal/interfaces/exec.rs Outdated
Comment thread src/boxlite/src/rest/litebox.rs Outdated
@BatmanByte
BatmanByte force-pushed the codex/init-stdio-drain branch from c7b2ce1 to b68c092 Compare July 25, 2026 04:44
@BatmanByte BatmanByte changed the title feat(exec): buffer output before attach and separate execution wait feat(exec): drain guest output before attach Jul 25, 2026
@BatmanByte
BatmanByte force-pushed the codex/init-stdio-drain branch 2 times, most recently from ed4679a to 890ff64 Compare July 25, 2026 07:44
@BatmanByte
BatmanByte requested a review from DorianZheng July 25, 2026 11:17
@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8685690f26

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

Comment thread src/guest/src/service/exec/exec_handle.rs Outdated
Comment thread src/guest/src/service/exec/executor.rs Outdated
Comment thread src/guest/src/service/exec/output.rs Outdated
Comment thread src/boxlite/tests/run_main_command.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 381f0ee94e

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

Comment on lines +125 to +129
.entries
.get(index)
.expect("ring sequence must exist")
.output
.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Release entries after the sole attachment consumes them

When attached commands produce substantial output, this clones each entry and advances only the attachment cursor without removing the entry or decrementing buffered_bytes. Because ExecutionRegistry only inserts execution states and has no removal path, every completed execution can permanently retain roughly 1 MiB in the guest, so repeated output-heavy execs can exhaust the VM's memory. Remove consumed entries once the single attachment receives them, while retaining data only before attachment.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed. This PR adds bounded per-execution retention but does not yet define the terminal lifecycle for that retained output, so repeated completed executions can accumulate memory. I am not applying the suggested local pop-on-send change here: it would only help attached executions and would silently change the late-Attach replay guarantee while leaving unattached completed executions retained.

I will address this in a focused follow-up with a complete lifecycle: retain the ring while execution is active and for a bounded terminal grace window; release entries already handed to the sole Attach stream; clear terminal rings after the window when no attachment is active; and preserve terminal Wait results independently from output retention. The follow-up will also define what a late Attach observes after expiry, rather than reusing OutputDropped byte counts whose current meaning is ring overwrite. This needs tests for attached, never-attached, terminal, and reconnect cases.

Comment thread src/guest/src/service/exec/exec_handle.rs Outdated
Comment thread src/shared/proto/boxlite/v1/service.proto Outdated
@BatmanByte
BatmanByte requested a review from a team as a code owner July 29, 2026 11:49
@BatmanByte

Copy link
Copy Markdown
Contributor Author

@codex review

Start draining an execution's stdout and stderr as soon as its guest-side state is created. Retain a bounded one-megabyte replay buffer so commands are not blocked when no client attaches, and report overwritten output explicitly when a client attaches late.

The dropped control event carries stdout and stderr byte counts so the host only resets the decoder that lost bytes. This keeps an intact stream from receiving a spurious replacement character.
Track the stream cursor separately from ring retention so OutputDropped only reports bytes that have not been handed to the Attach stream. Clean up and reap a spawned process whenever its I/O handle setup or PTY handoff fails, including the container-backed paths.

Replace the late-attach test's timing sleep with a guest-side completion marker. The marker is created only after the main command's pipe writes have completed, making the overflow assertion independent of host scheduling.
Replace the timing-based wait in the AsyncFd regression test with a signal emitted after the output stream first registers pending. The blocking-pool probe now starts only after the reader has been polled, avoiding scheduler-dependent false passes.
New guests report per-stream offsets and final byte counts so Attach can detect both mid-stream and terminal output loss. Keep the former dropped event as a deprecated decode-only wire variant for hosts attaching to existing guests.\n\nEmit each stream's terminal frame once its own final entry has been sent, rather than waiting for its sibling pipe. Also reap an init if its stdio cannot register with Tokio's reactor, matching the other spawn paths.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: e72bcc6535

ℹ️ 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
BatmanByte force-pushed the codex/init-stdio-drain branch 3 times, most recently from ef4d6ab to bf63a81 Compare July 29, 2026 12:58
OutputDropped was introduced only by this unmerged PR and has no released guest compatibility requirement. Remove the deprecated field and host decoder so the Attach protocol is exclusively per-stream offsets plus final byte counts.
@BatmanByte
BatmanByte force-pushed the codex/init-stdio-drain branch from bf63a81 to d165e21 Compare July 29, 2026 13:18

@boxlite-agent boxlite-agent 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.

📦 BoxLite review — 1 issue

Comment on lines +77 to +83
fn read_fd(fd: &OwnedFd, buffer: &mut [u8]) -> io::Result<usize> {
nix::unistd::read(fd.as_raw_fd(), buffer).map_err(Into::into)
}

fn write_fd(fd: &OwnedFd, buffer: &[u8]) -> io::Result<usize> {
nix::unistd::write(fd, buffer).map_err(Into::into)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ read_fd/write_fd never retry EINTR
nix::unistd::read/write errors map straight through; tokio's AsyncFd::async_io only retries the closure on WouldBlock, so an EINTR (plausible here given the guest's SIGCHLD/reap_fence signal use) hits Err(_) => break in the output stream and silently ends it early, or fails an in-flight stdin write — the prior tokio::fs::File path retried EINTR automatically via std's Read/Write impls.

Suggested change
fn read_fd(fd: &OwnedFd, buffer: &mut [u8]) -> io::Result<usize> {
nix::unistd::read(fd.as_raw_fd(), buffer).map_err(Into::into)
}
fn write_fd(fd: &OwnedFd, buffer: &[u8]) -> io::Result<usize> {
nix::unistd::write(fd, buffer).map_err(Into::into)
}
fn read_fd(fd: &OwnedFd, buffer: &mut [u8]) -> io::Result<usize> {
loop {
match nix::unistd::read(fd.as_raw_fd(), buffer) {
Err(nix::errno::Errno::EINTR) => continue,
result => return result.map_err(Into::into),
}
}
}
fn write_fd(fd: &OwnedFd, buffer: &[u8]) -> io::Result<usize> {
loop {
match nix::unistd::write(fd, buffer) {
Err(nix::errno::Errno::EINTR) => continue,
result => return result.map_err(Into::into),
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. read_fd/write_fd now retry EINTR. A real output read error is propagated through OutputManager, ExecutionState, and Attach as gRPC INTERNAL instead of being converted into a successful EOF. PTY-master EIO remains the one explicit EOF case.

@DorianZheng DorianZheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the earlier compile break — fallible ExecHandle::new against the infallible #[cfg(test)] callers — is resolved on this push (.expect(...) at the four sites), so that's cleared.

output buffering is the piece worth a deliberate decision. draining at exec-creation into a bounded ring is right for the no-consumer case, but eviction is unconditional (inline at output.rs:221), so a consumer that is attached and reading, yet slower than a >1 MiB burst, silently loses the evicted bytes — the downstream mpsc(100) backpressures replay, not the fd drain, so the producer is never slowed. previously a full bounded channel blocked the reader, and an attached consumer couldn't lose data. the call: backpressure while a consumer is attached and caught up, or accept a lossy attach with a reliable gap notice.

the same evicting stream feeds ssh: spawn_output_pump (src/guest/src/service/ssh/bridge.rs:597, outside this diff) forwards stdout/stderr raw with no offset/gap handling, so an interactive session faster than its client loses bytes with no notice at all.

remaining points are inline. cleanup only, no behavior change: the drain_tasks StdMutex<Vec<JoinHandle>> (output.rs:20), the near-duplicate spawn_stdout/spawn_stderr (:176/:187, drain is already generic), and the dead stream.enabled = true in push (:215, a drain only exists for an already-enabled stream).

stream.last_sequence = Some(sequence);
let output = data_output(source, data, offset);

while state.buffered_bytes + byte_len > BUFFER_CAPACITY_BYTES {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

eviction here is unconditional — it never checks whether a consumer is attached. an attached, actively-reading consumer that falls behind a >1 MiB burst loses the evicted bytes: replay skips the hole via next_sequence = oldest_sequence (:132). the mpsc(100) in ExecutionState::attach sits downstream of replay (ring -> grpc) and backpressures only that, so the fd -> ring drain, and thus the producing process, is never slowed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. The 1 MiB ring remains a deliberate bounded-retention policy, so loss is allowed but no longer silent: every gRPC stdout/stderr frame carries that stream absolute offset, and its empty terminal frame carries final total_bytes. Consumers detect either a mid-stream offset jump or a tail gap at EOF. SSH tracks the same counters and emits the exact per-stream marker on the affected SSH output stream.

match reader.async_io(Interest::READABLE, |fd| read_fd(fd, &mut buf)).await {
Ok(0) => break, // EOF
Ok(n) => yield buf[..n].to_vec(),
Err(_) => break,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

any non-WouldBlock read error breaks the loop and is indistinguishable from clean eof. the end-frame then reports the truncated length as the total, so the host sees no offset gap and treats a truncated stream as complete. (the stdin write_all above turns a transient error into a permanent forward failure the same way.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. Non-PTY read failures now become gRPC INTERNAL through Attach, so no terminal total_bytes can certify a truncated stream. The PTY EIO-on-slave-close case is handled separately as its documented EOF condition.

if let Some(lost_bytes) =
stdout.receive(chunk.offset, chunk.data, chunk.total_bytes)
{
Self::report_gap(stderr, "stdout", lost_bytes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

a stdout gap is reported on the stderr channel here (report_gap(stderr, "stdout", ...)). a consumer capturing stdout alone loses data with no signal, and a consumer parsing stderr gets [boxlite] ... spliced into real stderr. the stderr-gap branch just below is correct; only the stdout branch is cross-wired.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. Host gap markers now go to the affected decoded stream, so stdout-only consumers observe stdout loss and intact stderr is not modified. The same rule is applied to the in-process SSH bridge.

"Guest output buffer dropped older output"
);
let _ = stderr.stream.tx.send(format!(
"[boxlite] {source} output dropped {lost_bytes} bytes\n"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

trailing bare \n: in raw/pty mode (opost cleared) this advances a row without a carriage return — staircase + column desync in a tui. \r\n, or routing the notice through the opost-aware path, avoids it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. Gap markers now use CRLF in both the Host and SSH output paths, so raw PTY consumers do not advance without returning to column zero.

self.stream.flush();
}
self.expected_offset = offset + data.len() as u64;
self.stream.send_bytes(data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

send_bytes(data) forwards the surviving post-gap bytes before the caller runs report_gap, so the "dropped N bytes" marker trails the recovered output and reads as a mid-stream loss rather than one at the gap point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. OutputTracker now separates validation from delivery: it flushes the affected decoder, emits the gap marker, then forwards surviving bytes. Markers use CRLF; the SSH bridge follows the same ordering and line ending.

if state.attached {
return Err(Status::already_exists("Already attached"));
}
state.attached = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

attached is set once and never cleared, so if the single attach stream drops (a transient disconnect) it can't reconnect — attach returns AlreadyAttached for the rest of the execution — even though the ring still holds recent output and the new offset/total_bytes fields are exactly what resume-from-offset needs. as written those wire fields aren't used for reconnect, so either they're dead weight or this latch blocks the feature they're for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentionally deferred. #1022 keeps Attach as one non-resumable consumer, so clearing the latch alone would permit a second stream without defining the resume cursor, replay start point, duplicate policy, or terminal retention. The new per-stream offset/total fields make that follow-up possible, but resumable Attach needs a request cursor plus tests for disconnect, replay, expiry, and concurrent reconnect; it should land as a separate protocol design.

Comment thread src/guest/src/service/exec/state.rs Outdated
pub async fn attach(
&self,
exec_id: &str,
_exec_id: &str,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_exec_id is unused and the per-stream info!(... forwarding task ended) logs that existed before are gone, so the attach path no longer carries the execution id or logs task lifecycle — harder to correlate a stuck or silently-dropping attach against the wait/exit records.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc31d49. ExecutionState::attach now retains exec_id and logs execution output forwarding ended; OutputManager also logs an output-reader failure with its stream. The duplicate stdout/stderr drain spawners were consolidated.

.entries
.get(index)
.expect("ring sequence must exist")
.output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

two things on this replay step:

  • it deep-clones the ExecOutput (its Vec<u8>) while holding the mutex, and push keeps buffering into the ring after a consumer attaches, so a live stream is copied twice for its whole life. Arc<[u8]>/Bytes payloads would make this a refcount bump.
  • the Next::Wait exit at :165 (updates.changed().await.is_err()) is unreachable: this closure holds a manager clone, hence a watch::Sender, so changed() never errors. Next::Done is the only way out, and it needs a drain to finish naturally; if drains are aborted (shutdown) without the attach consumer also being torn down, this stream hangs. today the release path aborts the consumer too, so it's latent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partially fixed in dc31d49: the unreachable watch::Receiver error branch is removed; the manager owner makes sender closure impossible. I am not changing the Vec clone here: avoiding it needs a Bytes/Arc payload change across the generated protobuf and all Attach consumers, which is a separate wire/API change rather than a local cleanup.

@BatmanByte
BatmanByte force-pushed the codex/init-stdio-drain branch from 971fe72 to dc31d49 Compare July 29, 2026 16:10

@boxlite-agent boxlite-agent 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.

📦 BoxLite review — 1 issue

Comment thread src/guest/src/service/exec/output.rs Outdated
Comment on lines +136 to +157
if let Some(failure) = &state.failure {
Next::Error(Status::internal(failure.clone()))
} else {
if next_sequence < state.oldest_sequence {
next_sequence = state.oldest_sequence;
}
if state.stdout.ready_to_end(next_sequence) && !stdout_end_sent {
stdout_end_sent = true;
Next::Item(end_output(OutputSource::Stdout, state.stdout.total_bytes))
} else if state.stderr.ready_to_end(next_sequence) && !stderr_end_sent {
stderr_end_sent = true;
Next::Item(end_output(OutputSource::Stderr, state.stderr.total_bytes))
} else if next_sequence < state.next_sequence {
let index = (next_sequence - state.oldest_sequence) as usize;
let output = state
.entries
.get(index)
.expect("ring sequence must exist")
.output
.clone();
next_sequence += 1;
Next::Item(output)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ reader failure discards buffered undelivered output
in the attach stream loop, if let Some(failure) = &state.failure is checked before the buffered-entries branch, so once either reader hits an I/O error the attach stream immediately yields Err and ends, even if correctly-captured output from the other (or same) stream is still sitting unread in entries; that data is never delivered to the client.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b5d053e. ReaderFailure now records the next internal sequence at failure time. Attach replays every retained entry before that boundary, then yields INTERNAL; output captured after the failure is intentionally not presented as a complete stream. The regression test asserts buffered stdout is delivered before the error.

@DorianZheng DorianZheng added the e2e-local Triggers the local (in-process) E2E suite on the self-hosted runner label Jul 30, 2026
@DorianZheng

Copy link
Copy Markdown
Member

https://github.com/boxlite-ai/boxlite/actions/runs/30514470192/job/90781243192?pr=1022

failed, please take a look and fix it before merge

@DorianZheng DorianZheng added e2e-local Triggers the local (in-process) E2E suite on the self-hosted runner and removed e2e-local Triggers the local (in-process) E2E suite on the self-hosted runner labels Jul 30, 2026
@DorianZheng
DorianZheng merged commit 8d58793 into boxlite-ai:main Jul 30, 2026
39 of 40 checks passed
G4614 pushed a commit to G4614/boxlite that referenced this pull request Aug 3, 2026
## Summary

Drain each guest execution stdout and stderr as soon as it is created.
This removes the dependency on a host Attach consumer for the guest
process to make progress.

Guest stdio uses the Tokio I/O reactor rather than tokio fs. Idle pipes
no longer occupy blocking-pool workers. Stdin uses the same
reactor-backed path because PTY stdin and stdout descriptors share the
non-blocking setting.

The guest retains at most 1 MiB of output for a late Attach. When that
ring overwrites old bytes, Attach emits an OutputDropped control event
with separate stdout and stderr byte counts. The host renders the notice
and resets only the affected UTF-8 decoder.

## Scope

This PR intentionally contains only guest exec stdio, the shared gRPC
protocol, and the local gRPC consumer needed to render it. It does not
change REST wait behavior, the runner, API, CLI, or host output
queueing.

## Verification

- make test:integration:rust
FILTER=main_command_exits_after_large_output_without_attach
- make test:integration:rust FILTER=late_attach_reports_output_dropped
- make test:integration:rust FILTER=test_zygote_concurrent_stdin_pipes
- make clippy

The integration commands ran outside the sandbox against real macOS
Hypervisor VMs.

make fmt:check is currently blocked before Rust checking by 186
pre-existing Prettier violations in untouched generated apps client
files; make fmt:check:rust passed.

---------

Co-authored-by: BatmanByte <300328404+BatmanByte@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

e2e-local Triggers the local (in-process) E2E suite on the self-hosted runner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants