Skip to content

fix(run): run COMMAND as the container init (docker semantics)#988

Merged
DorianZheng merged 15 commits into
mainfrom
fix/run-command-docker-semantics
Jul 22, 2026
Merged

fix(run): run COMMAND as the container init (docker semantics)#988
DorianZheng merged 15 commits into
mainfrom
fix/run-command-docker-semantics

Conversation

@DorianZheng

@DorianZheng DorianZheng commented Jul 15, 2026

Copy link
Copy Markdown
Member

What

boxlite run IMAGE COMMAND now means what it means everywhere else: COMMAND replaces the image CMD, ENTRYPOINT is prepended, and the result is the container's init (PID 1). The box lives exactly as long as that command.

Why

COMMAND was a docker exec in docker run clothing — it ran as a tenant beside an init built from the image's own ENTRYPOINT+CMD.

When the image default was short-lived the consequences were silent and severe. oven/bun prints help and exits; that init's death tore down the pid namespace and SIGKILLed the user's server (connections went to RST) while boxlite ps still reported Running. The zombie init then made the next exec panic inside procfs. Confirmed side-by-side against podman 5.6.1, which keeps the container alive exactly as long as the user's command.

What changed

Semantics — COMMAND → BoxOptions.cmd in run and create (create gains a trailing COMMAND, stored and run on start); the stray sh fallback is deleted. run -t gives the main command a real PTY (OCI process.terminal over the console socket).

AttachLiteBox::attach() addresses the box's main command, attach_exec(id) an exec session (moby's ContainerAttach / ContainerExecAttach split). Python keeps docker-py's attach(execution_id=None).

Session naming (kata parity) — the host declares init's session id in ContainerInitRequest.execution_id = container_id, mirroring kata's CreateContainerRequest{ExecId: c.id}. The guest registers the id it is handed rather than both sides hard-coding the convention.

Ordering — foreground run does create → attach → start (Container.Init with defer_start, then Container.Start). Started first, a command that finishes instantly can be gone before the attach lands and its output and exit code die with the VM.

Lifecycle — a box stops when its main command exits: the guest powers the VM off and a host-side watcher marks the box Stopped with the exit code. All three death paths — stop(), the watcher, and runtime shutdown — record the code through one helper. exec / cp / metrics against a finished job refuse rather than silently restarting it (that would re-run the command); a box with no command of its own still boots implicitly, which the cloud's auto-stop → next-SDK-call restart depends on. A handle whose VM has died refuses at the shared live_state() funnel instead of serving the corpse.

Exit codecontainers/{cid}/exit.json (boxlite_shared::layout::ExitRecord), per-run and per-container, surfaced on inspect .State.ExitCode and on the REST wire.

REST / serve — a container-level /v1/boxes/{id}/attach route so cloud run works (docker's POST /containers/{id}/attach); the orphan reaper never kills the box's main session.

Compatibility

  • SDK/daemon boxes (create-without-command, exec-many) are unchanged — init is still the image default and execs join it.
  • Breaking, intentionally: run IMAGE quick-cmd then exec/cp later. The box is now Stopped with the command's exit code and the operation is refused — same as docker, which treats exec on a non-running container as an error, not an implicit start. Before, exec hit a zombie (procfs panic) or joined a still-alive image default.
  • Images whose default blocks (nginx, node) lose nothing; images whose default exits go from silently broken to working.
  • cp to/from a finished job is refused rather than silently rebooting it — a deliberate deviation from docker cp, because a boxlite container's filesystem lives inside the VM's disk and reaching it means booting, which runs init. Documented with its follow-up (a mount-without-run capability). Boxes without a main command are unaffected; volumes remain the way to get a job's output out.

Verification

Two-sided per CLAUDE.md — production changes reverted, tests observed failing for the right reason, then restored. The three run_init_semantics reproducers use a full-tree revert; the PTY, reaper, spent-handle, poison-flag and shutdown-exit-code tests use a targeted revert of the single line that causes each bug (stated as such in the design doc rather than called a full two-side).

suite result
boxlite integration 236 / 236
CLI integration 322 / 326
boxlite lib unit 749 / 750
boxlite-cli bin unit 144 / 144

The four CLI failures and the one lib failure are pre-existing, reproduced at the branch's fork point with every change stashed:

  • two gvproxy port-conflict tests encode Linux socket semantics — they hold 127.0.0.1:P and expect gvproxy's 0.0.0.0:P bind to fail, which it does on Linux (EADDRINUSE) but not on macOS/BSD (SO_REUSEADDR permits the wildcard bind).
  • auth whoami_updates_stale_profile_path_prefixwhoami doesn't write the refreshed path_prefix back.
  • stress_disk bystander_writes_keep_progressing — a throughput threshold, not disk space.
  • jailer::builder::builder_preset_shortcuts_pick_known_profiles — asserts a Linux-only seccomp_enabled on macOS; jailer/ is untouched by this branch.

Passed the repo's CLAUDE.md preflight audit.

Follow-ups (designed, not in this PR)

  • docs/investigations/init-build-via-zygote.md — init's build() still clone3s on the multi-threaded guest, the last site outside the zygote's fork-safety guarantee.
  • docs/investigations/sdk-run-semantics-api.md — the SDK surface (BoxOptions.cmd/tty on Node/Python, box.attach(), box.wait(), rt.run()).
  • serve boots a cold box under the registry-wide write lock; a per-box open lock would stop it stalling unrelated handlers.
  • mount-a-container's-filesystem-without-running-it, to close the cp-on-a-finished-job deviation.

https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi

Summary by CodeRabbit

  • New Features
    • boxlite run now follows Docker-like COMMAND/ENTRYPOINT behavior, running the command as PID 1.
    • -t configures PTY behavior for the main command at box creation time.
    • Main-session attach is supported, including a dedicated /attach flow (and reattach with an optional execution id).
    • create can store a command to run later via explicit start.
  • Bug Fixes
    • Exit codes are preserved and surfaced consistently across CLI inspect, REST responses, and attach/exec lifecycles.
    • Improved lifecycle handling prevents unintended restarts after completion; refined stop/auto-remove behavior.
  • Documentation
    • Updated CLI/REST documentation to reflect command semantics, attach routes, and exit-code behavior.

`boxlite run IMAGE COMMAND` exec'd COMMAND as a tenant beside a throwaway init
built from the image's own ENTRYPOINT+CMD. When that default was short-lived
(oven/bun prints help and exits), its death tore down the pid namespace and
SIGKILLed the user's command — connections went to RST while the box still
reported Running — and the zombie init made the next `exec` panic in procfs.

COMMAND now replaces the image CMD (ENTRYPOINT prepended) and the result *is*
the container's init, as docker defines it. The box's lifetime is its main
command's lifetime.

Core:
- run/create: COMMAND -> BoxOptions.cmd; `create` gains a trailing COMMAND. The
  stray `sh` fallback is gone.
- The host declares init's session id (ContainerInitRequest.execution_id =
  container_id), mirroring kata's CreateContainerRequest{ExecId: c.id}; the
  guest registers the id it is handed instead of re-deriving it.
- LiteBox::attach() addresses the main command, attach_exec(id) an exec session
  (moby's ContainerAttach/ContainerExecAttach split). Python keeps docker-py's
  attach(execution_id=None).
- `run -t` gives the main command a PTY (OCI process.terminal via the console
  socket), not just an exec.

Ordering and lifecycle:
- Foreground `run` creates the container, attaches, then starts it (docker's
  create -> attach -> start). Started first, a command that finishes instantly
  can be gone before the attach lands.
- A box stops when its main command exits: the guest powers the VM off and a
  host-side watcher records the exit. All three death paths (stop, the watcher,
  runtime shutdown) report the code through one helper. exit.json
  (boxlite_shared::layout::ExitRecord) carries it, per-run and per-container.
- exec/attach/cp/metrics on a finished *job* refuse rather than silently
  restarting it (that would re-run the command); a box with no command of its
  own still boots implicitly, which the cloud's auto-restart depends on.
- A handle whose VM has died refuses at the shared live_state() funnel instead
  of serving the corpse.

REST/serve: a container-level /boxes/{id}/attach route so cloud `run` works;
the reaper never kills the main session; exit code on the REST wire.

Verified two-sided per CLAUDE.md (full CLI + boxlite integration suites green
apart from four failures that reproduce at the branch point with none of this
applied). Passed the CLAUDE.md preflight audit.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
@coderabbitai

coderabbitai Bot commented Jul 15, 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
📝 Walkthrough

Walkthrough

The change separates container creation from init startup, adds main-command attachment and PTY handling, records init exit codes, prevents unsafe reruns, introduces unified lifecycle watching and guest process reaping, and updates CLI, REST, runtime, SDK, and integration-test behavior.

Changes

Init lifecycle and command semantics

Layer / File(s) Summary
Lifecycle contracts and persisted exit state
src/shared/*, src/boxlite/src/runtime/*, docs/investigations/*, sdks/*
Defines deferred startup, TTY configuration, main-session attachment, command overrides, SDK attachment shapes, and persisted exit.json exit records.
Guest container creation and startup
src/guest/src/container/*, src/guest/src/service/container.rs, src/shared/proto/*
Creates containers without running init, supports pipe or PTY stdio, registers the init session, and exposes a separate start operation.
Runtime boot, start, watching, and exit recovery
src/boxlite/src/litebox/*, src/boxlite/src/runtime/rt_impl.rs
Separates boot from init start, prevents unsafe implicit reruns, watches shim and health state, and propagates recorded exit codes.
Main-session REST attachment
src/boxlite/src/rest/*, src/cli/src/commands/serve/*
Adds main-session WebSocket attachment, execution-id handshake propagation, typed reconnect errors, session classification, and cache/reaper handling.
CLI behavior and validation
src/cli/src/commands/*, src/cli/src/cli.rs, src/cli/tests/*, src/boxlite/tests/*
Maps run and create commands to init semantics, propagates TTY and user options, exposes exit codes, guards copy operations, and adds lifecycle, reaping, and REST coverage.
Guest process reaping
src/guest/src/reaper.rs, src/guest/src/container/zygote.rs, src/guest/src/service/exec/*, src/guest/src/storage/*
Adds SIGCHLD-based exit delivery, removes zygote wait IPC, caches process results, and fences direct child waits against reaper races.

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

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant BoxImpl
  participant GuestService
  participant MainSession
  CLI->>BoxImpl: create container with command and tty
  BoxImpl->>GuestService: Init without running init
  GuestService->>MainSession: register main init session
  CLI->>BoxImpl: attach main session
  BoxImpl->>MainSession: stream init session
  CLI->>BoxImpl: start container init
  BoxImpl->>GuestService: Start container
  GuestService->>MainSession: run and watch init
  MainSession-->>BoxImpl: output and exit code
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: Docker-style COMMAND becomes the container init.
Description check ✅ Passed The description covers summary, changes, verification, and compatibility/risk details, matching the template's intent despite different headings.
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
  • Commit unit tests in branch fix/run-command-docker-semantics

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.

@cla-assistant

cla-assistant Bot commented Jul 15, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


tester seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

1 similar comment
@cla-assistant

cla-assistant Bot commented Jul 15, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


tester seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

tester added 6 commits July 15, 2026 11:19
…runs

The container-init protocol had `ContainerInitRequest.defer_start`: a boolean
that made one RPC do two workflows — create-and-start when false, create-only
when true. That is the CLAUDE.md anti-pattern, and it diverged from the
reference protocol this change set otherwise follows, whose create and start
are two unconditional RPCs with no such flag.

Make the wire match: `Container.Init` always creates the container and never
runs init; `Container.Start` always runs it. The host sequences the two — it
calls Start right after Init for an ordinary boot, and attaches in between for
a foreground `run` (`start_attached`). The decision of *when* to start stays
host-side runtime logic; it no longer travels as a flag on the wire.

- proto: `defer_start` field removed; Init/Start docs say what each does.
- guest: `Container.Init` no longer branches on the flag; `run_container_init`
  is reached only via the `Container.Start` RPC.
- host: `ContainerInterface::init` takes `tty: bool` directly (the
  `InitProcessSetup{tty, defer_start}` grouping struct is deleted); the
  guest-init task sends `Container.Start` after `init`, unless deferring.

Behavior is unchanged (create → [attach] → start); only the RPC shape and the
side the decision lives on move. Also scrubs reference-project name-drops from
the comments this PR introduced — the parity citation lives in the design doc.

Full rust integration 236/236; CLI 322/326 (the 4 failures are pre-existing at
the fork point); fmt + clippy clean. Passed the CLAUDE.md preflight audit.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
Close two coverage gaps in the run-command work that were unit-only or
missing.

- `run --url` had no end-to-end test — only route-registered / client-wired
  unit checks. `run_rest_e2e.rs` spins up `boxlite serve` on a free port and
  runs `run --url alpine sh -c 'echo …; exit 7'` through the whole wire, so a
  command's output and exit code must actually round-trip. That path's
  breakage (start-then-attach racing a fast command) was a release blocker;
  it is now guarded by something that exercises the socket. Teardown drains
  the server's boxes before killing it, since teardown over REST is async and
  serve stops its boxes only on SIGINT.
- `--user` and `-w` now apply to init (COMMAND is init); assert it with
  `run --user 1000 alpine id -u` -> 1000 and `run -w /tmp alpine pwd` -> /tmp,
  which read init's own process spec back from the kernel.

Full CLI suite 325/329 (+3 new; the 4 failures are pre-existing at the fork
point). Test-only, no production change. Passed the CLAUDE.md preflight audit.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
…ach + start

Booting a box and running its container's init were one pipeline step, so
holding init at the gate for a client to attach first needed a threaded flag
(defer_container_start) and a fused method (start_attached). Split them: boot
now only creates the container (Container.Init); running it (Container.Start)
is a separate, idempotent host step.

- start()  = boot + run-container (single-flighted via a container_start
             OnceCell; admits Running so a box booted by a bare attach() still
             gets its init run)
- attach() = boot-only + subscribe (never runs the command, so it drops the
             re-run guard exec/cp keep; refuses a stopped box)
- live_state() (exec/cp/metrics) = boot + run-container, behaviour unchanged
- run foreground = attach() then start(); over REST, /attach boots+subscribes
  and /start runs, with start_box keeping a Running handle so run --url's
  attach-then-start does not drop its live VM

Removes start_attached (BoxBackend trait, BoxImpl, RestBox, LiteBox facade) and
defer_container_start (flag + pipeline threading). An output-buffer bus was
considered and rejected: it lives in the guest and dies with the VM, so it does
not remove the ordering requirement. Net -54 LoC of machinery.

Verified: run_init_semantics 8/8, run_main_command (incl. new
attach_refuses_a_stopped_box, two-sided against the gate), run_over_rest e2e,
CLI exec 18/18, CLI run 53/53, working-dir/timeout/user.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
The main-command attach and the exec reattach were two BoxBackend methods.
Fold them into one `attach(execution_id: Option<&str>)`: `None` = the box's
main command session, `Some(id)` = reattach to that exec — docker's
ContainerAttach and ContainerExecAttach behind one verb. Behaviour unchanged:

- BoxImpl (local): None → main-attach; Some(_) → Unsupported (an in-process
  exec never drops its stream, so there is nothing to reattach to by id).
- RestBox: None → /boxes/{id}/attach; Some(id) → /boxes/{id}/executions/{id}/attach.
- LiteBox facade + Python `attach(execution_id=None)` collapse to a pass-through.
- Callers moved to attach(None): run.rs, serve, tests.

Adds coverage for both arms — the exec-reattach path had zero test callers:
- REST unit (mock WS server): Some(id) → exec route + streams; None → missing
  session-id header → Unsupported; Some(id) → 404 SessionReaped / 409
  AlreadyExists (harness gained reject_upgrade_status to inject the rejection).
- local (real runtime): attach(Some(id)) → Unsupported (two-sided verified
  against the guard).

Verified: 4 REST attach unit tests, run_main_command attach suite, run_over_rest
e2e; clean compile across boxlite/cli/python/node/guest.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
… into BoxWatcher

A box ran two per-box tasks that raced to write BoxState on shim death: the
always-on exit watcher and, when a HEALTHCHECK was configured, a separate
health-check task. Collapse them into one BoxWatcher (litebox/watcher.rs) whose
run() is a single select!: shutdown -> stand down; wait_for_exit -> record
Stopped + exit_code (+ health_status Unhealthy if health-checked); and a
health-tick arm present only with an Option<HealthProbe>. With None it
degenerates to a pure exit watcher. Since shim-exit and health-tick are sibling
select arms, the health path no longer detects shim death itself -- is_process_alive
and force_status are gone, and the kill(pid,0) zombie bug with them: a tick
fires only while the shim is alive.

arm_exit_watcher / spawn_exit_watcher / spawn_health_check and the
exit_watcher_armed + health_check_task fields are removed. One
`watcher: OnceLock<JoinHandle>` replaces them (like `live` for the VM):
arm_watcher get_or_inits it -- race-free "one watcher per box" across the many
handouts the runtime makes -- and stop() aborts via get(). init_live_state
fetches the guest and arms the full watcher UNDER the state write-lock that
publishes Running+pid, so a concurrent handout's arm_watcher(None) cannot slip
an exit-only watcher in ahead of the probe.

Behavior change: a reattached box (one a fresh runtime recovers already Running)
is watched exit-only -- its HEALTHCHECK no longer runs in the recovering
process; freshly started/restarted boxes are unaffected. Adopted boxes are
exit-only by design, so init_live_state builds no probe for them (no stranded
probe, no discarded guest fetch).

Tests: box_watcher_records_stopped_with_exit_code_when_the_shim_dies and
box_watcher_auto_removes_the_box_after_its_shim_dies (stand-in shim, no VM);
plus command_is_pid1, a_self_stopped, and both health_check.rs tests on VMs.
Compiles clean across boxlite/cli/python/node/guest.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
Resolve PR #988 conflicts against main networking and --rootfs work: keep both ensure_container_started and network() in box_impl.rs; fold docker-semantics create_box attach+start into main --rootfs in run.rs; drop the orphaned parse_command_args and its dead sh-defaulting tests; keep attach(Option) tests alongside ws_client_keepalive; merge the run/create README prose. Verified cargo check clean for boxlite default/rest/tests, boxlite-cli, boxlite-python; BoxWatcher and attach unit tests pass.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
@DorianZheng
DorianZheng marked this pull request as ready for review July 16, 2026 03:11
@DorianZheng
DorianZheng requested a review from a team as a code owner July 16, 2026 03:11
@boxlite-agent

boxlite-agent Bot commented Jul 16, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"type":"result","subtype":"success","is_error":true,"api_error_status":403,"duration_ms":418,"duration_api_ms":0,"num_turns":1,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","stop_reason":"stop_sequence","session_id":"22d9a0b6-70d0-47df-8e55-14f3e94300a0","total_cost_usd":0,"usage":{"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","uuid":"3f2ad8a8-4950-483b-a4ef-7d161596adfc"}

stderr:
<empty>

powered by BoxLite

@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/boxlite/src/litebox/box_impl.rs
@DorianZheng DorianZheng added the e2e-local Triggers the local (in-process) E2E suite on the self-hosted runner label Jul 16, 2026

@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: 14

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/container/lifecycle.rs (1)

99-108: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update lifecycle messaging for create-only, TTY-aware init.

The creation path no longer starts init and may use a PTY, but its public documentation and logs still describe the old behavior.

  • src/guest/src/container/lifecycle.rs#L99-L108: document deferred startup, run_init(), and the tty argument.
  • src/guest/src/service/container.rs#L280-L309: report the selected I/O mode and “created/ready for attach and start.”
🤖 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/container/lifecycle.rs` around lines 99 - 108, The container
lifecycle documentation for start must describe deferred init startup, reference
run_init(), and document the tty argument; update
src/guest/src/container/lifecycle.rs lines 99-108 accordingly. In
src/guest/src/service/container.rs lines 280-309, update creation logs to report
the selected I/O mode and state that the container was created and is ready for
attach and start.
🧹 Nitpick comments (1)
src/shared/proto/boxlite/v1/service.proto (1)

236-241: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider renaming ContainerInitError or creating a specific ContainerStartError.

Reusing ContainerInitError for the ContainerStartResponse error variant is functionally correct but slightly confusing semantically. Consider introducing a specific ContainerStartError message or renaming ContainerInitError to a generic ContainerError to better reflect its usage across different RPC responses.

🤖 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/shared/proto/boxlite/v1/service.proto` around lines 236 - 241, Clarify
the error message naming used by ContainerStartResponse: either rename
ContainerInitError to a generic ContainerError if it is shared across RPC
responses, or introduce a dedicated ContainerStartError for this response and
update the oneof reference and all affected usages consistently.
🤖 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/litebox/box_impl.rs`:
- Around line 441-488: Update exit_code_from_file_when_portal_has_none to wait
for the asynchronously written ExitRecord before forwarding a negative portal
result. Await the relevant shim/init completion or retry ExitRecord::read with a
bounded timeout, ensuring the recovered record is used when available while
preserving the existing result forwarding behavior.
- Around line 930-945: Update ensure_container_started so only the caller that
successfully performs the get_or_try_init initialization returns true; callers
that observe an already-initialized cell, including those that waited for
another initializer, must return false. Preserve the existing container start
operation and error propagation.

In `@src/boxlite/src/litebox/watcher.rs`:
- Around line 140-158: Update the watcher loop and related run flow around
on_health_tick so shim.wait_for_exit() and shutdown.cancelled() remain
selectable while a health probe is in progress. Make the probe await cancellable
or run it through a nested select, and mark health probing disabled after an
unhealthy result instead of returning from run; continue observing and recording
later shim exits.
- Around line 161-189: Update on_shim_exit to use the active-state predicate for
the stop transition instead of checking only BoxStatus::Running, ensuring paused
and other active boxes are marked Stopped when the shim exits while preserving
the existing exit-code recovery behavior.

In `@src/boxlite/src/rest/client.rs`:
- Around line 326-328: Update the WebSocket connection flow around connect_async
to wrap the handshake await in tokio::time::timeout using the established
timeout duration. Handle timeout expiry by returning BoxliteError::Network,
while preserving map_ws_error handling for connection failures and the existing
behavior for both attach paths.

In `@src/boxlite/src/rest/litebox.rs`:
- Line 1809: Run rustfmt on the changed code in src/boxlite/src/rest/litebox.rs
at lines 1809-1809 and src/cli/src/commands/serve/mod.rs at lines 1035-1035,
applying all formatter-reported changes in both files.

In `@src/boxlite/src/runtime/rt_impl.rs`:
- Around line 23-33: The recovery flow must arm exit watchers for every verified
running box before any handle is requested, rather than relying on
litebox_from_impl. Update the recovery logic around the running-box verification
path to call arm_watcher for each adopted running box, and add a regression test
that recovers a running box without runtime.get(), kills its shim, and verifies
persisted Stopped state.

In `@src/boxlite/tests/run_main_command.rs`:
- Around line 38-41: Apply rustfmt’s standard formatting to the chained attach
call assigned to execution in the test, preserving its behavior and existing
error expectation.

In `@src/cli/src/commands/cp.rs`:
- Around line 51-61: Ensure the copy flow around handle.copy_into and the
corresponding copy operation always cleans up a box that was started implicitly,
including when the copy returns an error before reaching the existing stop call.
Update the copy API or surrounding control flow to own or report temporary
startup state, then stop only those temporarily started boxes while preserving
already-running boxes and propagating the original copy error.

In `@src/cli/src/commands/serve/mod.rs`:
- Around line 912-917: Update the cached-handle checks in
src/cli/src/commands/serve/mod.rs#L912-L917 and
src/cli/src/commands/serve/handlers/boxes.rs#L95-L100 to use status.is_active()
instead of comparing exclusively with BoxStatus::Running. Reuse cached handles
and avoid eviction for both Running and Paused VMs; evict only inactive
statuses.
- Around line 1031-1039: The register_main_session flow must not hold
state.executions across the potentially blocking litebox.attach(None) cold boot.
Add or reuse a per-box opening lock to serialize session creation, recheck for
an existing execution after acquiring it, perform attach outside the global
registry lock, then acquire state.executions.write() only for the final
lookup/insertion while preserving atomic registration behavior.

In `@src/guest/src/service/container.rs`:
- Around line 389-397: Update the exit-record handling in the container teardown
flow so `ExitRecord::write` must succeed durably before VM poweroff proceeds.
Retry the write or propagate its error instead of only issuing the existing
warning, and ensure the shutdown path does not power off while persistence
remains failed.

In `@src/guest/src/service/exec/mod.rs`:
- Around line 423-428: Validate the exec request’s container_id before passing
it to SharedGuestLayout::container() in the exit-file handling flow. Reject
absolute, traversal, or otherwise path-like values and only construct the
exit-file path after successful strict ContainerID validation, preserving normal
valid-container behavior and preventing registry/path access with the raw
string.

In `@src/guest/src/service/guest.rs`:
- Around line 130-142: Update the shutdown watcher handling in the timeout match
around watcher so Ok(Err(e)) and Err(_) return an RPC error instead of logging
and continuing. Preserve the successful completion path only for Ok(Ok(())),
ensuring teardown is not reported complete unless the exit record write ordering
is established.

---

Outside diff comments:
In `@src/guest/src/container/lifecycle.rs`:
- Around line 99-108: The container lifecycle documentation for start must
describe deferred init startup, reference run_init(), and document the tty
argument; update src/guest/src/container/lifecycle.rs lines 99-108 accordingly.
In src/guest/src/service/container.rs lines 280-309, update creation logs to
report the selected I/O mode and state that the container was created and is
ready for attach and start.

---

Nitpick comments:
In `@src/shared/proto/boxlite/v1/service.proto`:
- Around line 236-241: Clarify the error message naming used by
ContainerStartResponse: either rename ContainerInitError to a generic
ContainerError if it is shared across RPC responses, or introduce a dedicated
ContainerStartError for this response and update the oneof reference and all
affected usages consistently.
🪄 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: 08a880cb-3e74-459c-852a-78103de09518

📥 Commits

Reviewing files that changed from the base of the PR and between 54c348f and 5fce6dc.

📒 Files selected for processing (52)
  • docs/investigations/collapse-start-attached.md
  • docs/investigations/init-build-via-zygote.md
  • docs/investigations/run-command-semantics-fix.md
  • docs/investigations/sdk-run-semantics-api.md
  • docs/reference/cli/README.md
  • sdks/node/src/options.rs
  • sdks/python/src/box_handle.rs
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/init/mod.rs
  • src/boxlite/src/litebox/init/tasks/container_rootfs.rs
  • src/boxlite/src/litebox/init/tasks/guest_init.rs
  • src/boxlite/src/litebox/mod.rs
  • src/boxlite/src/litebox/state.rs
  • src/boxlite/src/litebox/watcher.rs
  • src/boxlite/src/portal/interfaces/container.rs
  • src/boxlite/src/portal/interfaces/exec.rs
  • src/boxlite/src/rest/client.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/src/rest/types.rs
  • src/boxlite/src/runtime/backend.rs
  • src/boxlite/src/runtime/layout.rs
  • src/boxlite/src/runtime/options.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/runtime/types.rs
  • src/boxlite/tests/run_main_command.rs
  • src/cli/README.md
  • src/cli/src/cli.rs
  • src/cli/src/commands/cp.rs
  • src/cli/src/commands/create.rs
  • src/cli/src/commands/inspect.rs
  • src/cli/src/commands/run.rs
  • src/cli/src/commands/serve/README.md
  • src/cli/src/commands/serve/handlers/boxes.rs
  • src/cli/src/commands/serve/handlers/executions.rs
  • src/cli/src/commands/serve/mod.rs
  • src/cli/src/commands/serve/types.rs
  • src/cli/tests/run.rs
  • src/cli/tests/run_init_semantics.rs
  • src/cli/tests/run_rest_e2e.rs
  • src/guest/src/container/command.rs
  • src/guest/src/container/lifecycle.rs
  • src/guest/src/container/spec.rs
  • src/guest/src/container/start.rs
  • src/guest/src/container/stdio.rs
  • src/guest/src/service/container.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/registry.rs
  • src/guest/src/service/exec/state.rs
  • src/guest/src/service/guest.rs
  • src/guest/src/service/server.rs
  • src/shared/proto/boxlite/v1/service.proto
  • src/shared/src/layout.rs

Comment thread src/boxlite/src/litebox/box_impl.rs
Comment thread src/boxlite/src/litebox/box_impl.rs
Comment thread src/boxlite/src/litebox/watcher.rs
Comment thread src/boxlite/src/litebox/watcher.rs
Comment thread src/boxlite/src/rest/client.rs Outdated
Comment thread src/cli/src/commands/serve/mod.rs
Comment thread src/cli/src/commands/serve/mod.rs Outdated
Comment thread src/guest/src/service/container.rs Outdated
Comment thread src/guest/src/service/exec/mod.rs
Comment thread src/guest/src/service/guest.rs
@G4614

G4614 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

can

BoxLite bot and CodeRabbit review findings, plus the post-merge rustfmt that was failing CI. ensure_container_started now sets a closure-local flag so only the single-flight winner reports the start (it returned Ok(true) to every concurrent caller, duplicating BoxStarted events). box_impl exit recovery bounded-polls for the guest async exit-file write, so a signal death reports docker 128+n instead of a raw negative. guest exec rejects a non-single-component container_id before joining it into a path (a client can override BOXLITE_EXECUTOR). cli cp restores a stopped box that a failed copy implicitly booted. serve, boxes and watcher use is_active (Running or Paused) where the VM-alive check is meant. watcher keeps shim-exit selectable while a health probe runs and stops probing, not the whole watcher, once unhealthy. rustfmt applied to the files the merge left unformatted.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi

@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/boxlite/src/litebox/box_impl.rs`:
- Around line 959-971: Update the get_or_try_init closure in the container
startup flow to use async move, so it owns and can mutate the started_here flag
across the awaited operations while preserving the existing initialization and
return 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: 8798af3c-574e-4c0b-a14e-9f7e162771f3

📥 Commits

Reviewing files that changed from the base of the PR and between 5fce6dc and d2e8065.

📒 Files selected for processing (8)
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/watcher.rs
  • src/boxlite/src/rest/litebox.rs
  • src/boxlite/tests/run_main_command.rs
  • src/cli/src/commands/cp.rs
  • src/cli/src/commands/serve/handlers/boxes.rs
  • src/cli/src/commands/serve/mod.rs
  • src/guest/src/service/exec/mod.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/cli/src/commands/cp.rs
  • src/boxlite/tests/run_main_command.rs
  • src/boxlite/src/litebox/watcher.rs
  • src/cli/src/commands/serve/handlers/boxes.rs
  • src/cli/src/commands/serve/mod.rs
  • src/boxlite/src/rest/litebox.rs

Comment thread src/boxlite/src/litebox/box_impl.rs
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.00000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/shared/src/layout.rs 88.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

Replace the per-pid blocking waitpid and the zygote Wait IPC with a single
SIGCHLD-driven waitpid(-1) reaper in the guest agent. Exec tenants are now
cloned CLONE_PARENT (youki as_sibling) so they reparent to guest main like
the container init already did, and both wait via REAPER.wait(pid). Delete
WaitVia, wait_via_zygote, and the zygote's Wait / do_wait / Zygote::wait.

A process-wide waitpid(-1) races callers that wait for their own child:
Command::output() in the storage paths lost its child to the reaper and
failed with ECHILD (10 of 20 calls on a booted box). Add a reap fence — the
sweep takes the write side, self-waiting callers hold the read side — and
take it around every self-wait in the guest: mkfs, resize2fs, chown -R, and
the userns helper's child from before its fork until it is reaped — on that
helper's failure path the child is already dead when the parent looks, so a
fence taken only at the wait would arrive too late.

Hold an exit until its owner asks for it. Callers claim a pid with
expect_waiter() once the spawn returns it, so an exit arriving before any
Wait is kept; only ownerless strays age out via REAPED_TTL. The claim carries
the instant read before the spawn, which is what separates our own exit — a
short command can exit before its pid travels back from the zygote — from a
previous owner's exit under a recycled pid. Without the hold, a detached
exec, which sends no Wait until its caller chooses to, had its exit pruned
and its later Wait hung.

Tests: reaper unit tests covering the reaped-before-wait race, the ECHILD
theft, the unclaimed-exit hold, both claim/reap orderings, TTL pruning,
signal delivery and concurrent waiters; a concurrent distinct-exit-codes
integration test; a self-terminate (SIGTERM -> 143) test.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi

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

Caution

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

⚠️ Outside diff range comments (1)
docs/investigations/init-build-via-zygote.md (1)

113-114: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the verification counts and status.

This says the full suite is green with 56 CLI integration tests, while the PR report lists 322/326 CLI integration tests and pre-existing failures. Identify 56 as an init-focused subset, or update the document with the actual suite results.

🤖 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 `@docs/investigations/init-build-via-zygote.md` around lines 113 - 114, Update
the verification statement in the investigation document to accurately
distinguish the 56 init-focused CLI integration tests from the full CLI suite,
or replace it with the actual 322/326 results and note the pre-existing
failures. Keep the guest unit-test and clippy status accurate.
🤖 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 `@docs/investigations/init-build-via-zygote.md`:
- Around line 113-114: Update the verification statement in the investigation
document to accurately distinguish the 56 init-focused CLI integration tests
from the full CLI suite, or replace it with the actual 322/326 results and note
the pre-existing failures. Keep the guest unit-test and clippy status accurate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: faedc0f5-0f3a-4c28-822a-190754f7a91b

📥 Commits

Reviewing files that changed from the base of the PR and between d2e8065 and af7ee73.

📒 Files selected for processing (13)
  • docs/investigations/init-build-via-zygote.md
  • src/boxlite/tests/zygote_integration.rs
  • src/cli/tests/exec.rs
  • src/guest/Cargo.toml
  • src/guest/src/container/zygote.rs
  • src/guest/src/main.rs
  • src/guest/src/reaper.rs
  • src/guest/src/service/container.rs
  • src/guest/src/service/exec/mod.rs
  • src/guest/src/service/exec/state.rs
  • src/guest/src/storage/block_device.rs
  • src/guest/src/storage/idmap.rs
  • src/guest/src/storage/perms.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/guest/src/service/container.rs

@DorianZheng

Copy link
Copy Markdown
Member Author

@boxlite-agent review

tester added 2 commits July 20, 2026 17:01
The reaper exposed wait(pid) and callers parked on it, which forced two
wait layers and two defects. wait() removed the cached exit and deliver()
removed the waiter, so an exit was handed out exactly once and a second
Wait RPC for the same execution could not be answered. And waiters.insert
discarded the Option it returns, dropping any sender already registered
for that pid, so a second wait on a pid someone was still waiting on ended
the first wait with ExitStatus::Code(-1) — a status no process returned.

Replace it with ExitSlot, a watch channel the execution owns. The reaper
deposits and never reads; holders park on get(). Being level-triggered
makes ordering irrelevant: an exit landing before its owner registers is
already there when the owner arrives, and every later caller reads the
same value. ExecutionState::wait_process is now the single place any
caller waits, and is infallible.

Delete Reaper::wait, the waiters map and the oneshot. expected and reaped
collapse into one slots map. REAPED_TTL stays, but only to age out strays
nobody holds. Pruning cannot strand a caller: register clears the stray
mark before handing out a receiver, so a slot still marked stray has no
holder.

A slot carries two timestamps and they are not interchangeable. stray_since
answers whether it may age out; settled_at answers whether the status is
ours. Because the execution registry never evicts, a claimed slot outlives
its execution holding a status, and pruning skips it — so only settled_at
can tell that corpse from a fresh spawn's own exit once the kernel recycles
the pid. register drops any slot that settled before the spawn, and deliver
detaches an already-settled slot rather than overwrite it, so a recycled
pid cannot hand the previous owner the new process's code.

No peer reaper answers wait-by-pid: containerd broadcasts and filters at
the receiver, kata deposits onto the Process and drops the sender, and
tini, dumb-init and catatonit compare against one known pid. Where a
repeat read is needed it always lives in a store separate from the reaper.

Reproducers cover both defects and the recycle guard; rationale and prior
art in docs/investigations/reaper-exit-slot.md.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
Container init creation was the one clone3 left outside the zygote: it
ran libcontainer's build() on the multi-threaded tokio process, exposed
to the musl __malloc_lock fork deadlock, and its internal
waitpid(intermediate) raced the guest reaper's waitpid(-1) sweep
unfenced.

Add ZygoteRequest::BuildInit carrying {container_id, state_root,
bundle_path, console_socket}; stdio pipe fds ride the existing
SCM_RIGHTS channel. create_container_with_stdio becomes a thin blocking
client of it, and the dead direct-build create_container is deleted. The
response reuses BuildResult and returns init's pid; each request kind
gets its own response tag so a mismatched reply is a protocol error,
never a misattributed pid.

Init must stay guest main's child: libcontainer clones init CLONE_PARENT
off its intermediate, so init parents to whichever process calls
build(). Built in the zygote that would strand init's exit — nobody
reaps zygote children — so the build sets as_sibling(true), parenting
the whole chain to guest main exactly as tenants do; the intermediate
dies as a reaper stray and libcontainer tolerates the ECHILD by design.
The design doc's reparenting invariant is corrected accordingly.

Failed builds now close their SCM_RIGHTS fds before any fallible step —
previously an early builder error leaked all three into the long-lived
zygote (tenant path included).

Tests: zygote unit tests cover the new protocol, a mixed tenant/init
build concurrency race, failure propagation, a zygote death mid-request,
and fd-close-on-failure; integration adds a console-marker mechanism
proof, a restart re-record check, and a failed init build surfacing and
recovering end to end.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi

@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 `@docs/investigations/reaper-exit-slot.md`:
- Around line 153-157: Convert the indented PID-reuse example in the
reaper-exit-slot documentation to a fenced text code block, preserving all
example lines and their formatting so markdownlint MD046 passes.
🪄 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: a8e40483-22ca-40e3-a7aa-5b566baf59e5

📥 Commits

Reviewing files that changed from the base of the PR and between 81129b4 and a6d20f9.

📒 Files selected for processing (7)
  • docs/investigations/init-build-via-zygote.md
  • docs/investigations/reaper-exit-slot.md
  • src/boxlite/tests/zygote_integration.rs
  • src/cli/tests/run_init_semantics.rs
  • src/guest/Cargo.toml
  • src/guest/src/container/start.rs
  • src/guest/src/container/zygote.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/guest/Cargo.toml
  • docs/investigations/init-build-via-zygote.md

Comment thread docs/investigations/reaper-exit-slot.md Outdated
tester added 2 commits July 21, 2026 18:01
The container service spawned a watcher task per init that parked on the
exit slot, wrote the exit record, and powered the VM off; its JoinHandle
was kept in an init_watchers map solely so the Shutdown RPC could join it
and know the record was on disk before the host reads the exit file.

Fold that follow-up into the reaper: a claim may now register an
ExitAction (Reaper::on_exit) that the reaper fires exactly once when it
delivers the pid's exit — after its lock is released, so an action can be
slow or call back into the reaper without blocking other deliveries. An
exit that beats the registration fires the action immediately, and the
recycle guards cannot discard a parked action: deliver takes it while
settling, so a settled slot never holds one.

The join contract is replaced, not dropped. Shutdown now derives the
record from init's exit slot and writes it inline before answering the
host; the action writes the same bytes from the same level-triggered
slot, so the duplicate write is idempotent and only the ordering is new.
The init_watchers JoinHandle map becomes an init_exits slot map, and the
128+n signal convention moves into ExitStatus::shell_code, shared by
both write sites.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
A box whose main command exits must power its VM off, but on x86_64 the
microVM kernel has no pm_power_off backend (no ACPI):
reboot(POWER_OFF) degrades to "Power off not available: System halted
instead" and the VM stays up forever. Every observation path then
reports the box Running — the whole self-stop family of e2e failures,
present since the feature first ran on Linux CI.

Switch the init-exit action to reboot(RESTART), the one command every
VMM backend turns into a process exit: the kernel's reboot=k path ends
in an i8042 CPU reset on x86 and a PSCI SYSTEM_RESET on aarch64, both
trapped as a shutdown. It is also exactly what the VMM's own bundled
init issues when its workload exits. Verified on the x86 CI runner:
a self-stopping box now reaches "stopped 7" with the VMM gone, where
before it sat "running" behind a halted VM.

Also fold in the review fixes riding this branch:
- run_main_command: plant a file where the boxes directory belongs
  instead of chmod 0500 — mode bits do not bind root, and CI runs the
  suite as root, so the old injector never fired there.
- rest client: bound the WebSocket handshake with a 30s timeout mapped
  to a network error; a stalled connect blocked attach forever.
- docs: fence the PID-reuse example (markdownlint MD046).

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
tester added 2 commits July 21, 2026 20:52
A box created with a cmd is the user's main command, and exec refuses
to boot such a box as a side effect ("starting the box would run your
main command"). The cmd-override test predates that gate and exec'd a
configured box directly, failing with exactly that refusal on CI. Start
the box explicitly first — the same contract every caller now follows.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
`boxlite create` then `boxlite start` is docker's create → start: the
box runs its main command in the background and the launching CLI gets
out of the way. But a created box inherited detach=false, so when the
`start` process exited its runtime's drop-time auto-stop and the shim's
parent-death watchdog tore the VM down — on x86 that kill outran a fast
`exit N`, so the exit code was never recorded and the box reported 0.
On aarch64 the command won the race, which is why it only failed on
Linux CI. `run` foreground escapes by waiting; `run -d` escapes via
detach=true.

Set detach on the `create` command itself (where config is written once
— the drop-time auto-stop reads persisted config, so a per-boot flag
could not reach it): a created box is a background box. `run` and the
SDK default are untouched. auto_remove is forced off alongside, the same
rule `run -d` enforces and `sanitize` requires (auto_remove ⊥ detach),
since a detached box has no foreground watcher to remove it.

Proven on the x86 CI runner: `create`+`start` of `exit 23` went from
stopped/0 to stopped/23. run_init_semantics 11/11 and the create/exec/
rm/inspect/list/info CLI suites stay green.

Claude-Session: https://claude.ai/code/session_01KYXByfJsxsWktqA2TjqKxi
@DorianZheng
DorianZheng merged commit 76dc354 into main Jul 22, 2026
61 of 63 checks passed
@DorianZheng
DorianZheng deleted the fix/run-command-docker-semantics branch July 22, 2026 01:27
DorianZheng added a commit that referenced this pull request Jul 23, 2026
## Problem
PR #988 split the guest's fused `Container.Init` into `Init` (create) +
`Start` (run init). The guest agent is baked into the versioned
guest-rootfs and reused per-box across restarts, so a box created before
#988 boots its old agent — which has no `Container.Start`. The post-#988
host calls it, tonic returns `Unimplemented`, and it surfaced as
`gRPC/tonic error … Unimplemented … (code=16)`, breaking restart/exec of
pre-#988 boxes.

## Fix
`ContainerInterface::start` now reads the gRPC status before `?`
flattens it and treats `Unimplemented` as "already started": on a
pre-#988 agent the fused `Init` (still called every boot) already ran
init, so `Start` is a redundant no-op. Real errors still propagate.

Also removes the spurious `boxlite/gvproxy` feature from `sdks/c`
(introduced in #994), which linked the shim-side `libgvproxy-sys` CGO
library into the C SDK — the SDK/CLI must not, per the existing
`net::tests` guard. Matches Python/Node/CLI, which expose the same
tunnel API with `rest` only.

## Test
An in-process tonic stub guest returning `Unimplemented` reproduces the
exact failure through `ContainerInterface::start`; the degrade turns it
green while a genuine `Start` error still surfaces. Full boxlite +
boxlite-shared unit suites pass (852 + 47).

Deferred: the durable fix (re-inject/recreate the guest so restarts run
the current agent, kata/Daytona-style) is not included here.

https://claude.ai/code/session_01Vu4qDgCnMjnLPpNsroxeAP


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Container startup now succeeds when the service reports that the
container start operation is unsupported, treating it as already
started.
  * Genuine container startup errors continue to be reported normally.
* **Compatibility**
* Updated SDK configuration to use the REST feature without the optional
networking feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: tester <t@t.test>
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