Skip to content

fix(runner): evict spent box handles, map every core status - #1138

Merged
DorianZheng merged 3 commits into
mainfrom
worktree-jaunty-coalescing-lemon
Aug 4, 2026
Merged

DorianZheng merged 3 commits into
mainfrom
worktree-jaunty-coalescing-lemon

Conversation

@DorianZheng

@DorianZheng DorianZheng commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

A box can stop itself — its main command exits and the guest powers the VM
off. The handle the runner cached while that box was up is spent from that
moment: it still holds the dead VM and can never boot another. Nothing cleared
the entry on a self-stop, so every later Start, exec or copy on that box was
answered with the corpse for the life of the runner process, surfacing on the
dashboard as Box <id> is no longer running and this handle is spent (code=11).

The equivalent guard already existed on the Rust side in boxlite serve
(src/cli/src/commands/serve/mod.rs:922); the Go copy in the runner was the
unfixed sibling.

Before

dashboard "Start"
 └ POST /boxes/:boxId/start     (controllers.Start · runner/pkg/api/server.go:137)
    └ Client.Start              (runner/pkg/boxlite/client.go:312)
       └ getOrFetchBox          (client.go:481)  ← BUG: returns the cached handle without checking its box is still up, so a self-stopped box is served its dead VM on every later call
       └ bx.Start(ctx)          (sdks/go/box_handle.go:43)
          └ BoxImpl::start      (src/boxlite/src/litebox/box_impl.rs:269)
             └ ensure_booted    (box_impl.rs:960) → Stopped("… handle is spent") → code=11

box sync tick, 10s              (runner/pkg/services/box_sync.go:53)
 └ GetBoxState → getOrFetchBox  — re-primes the same cache for every box

After

dashboard "Start"
 └ POST /boxes/:boxId/start     (controllers.Start · runner/pkg/api/server.go:137)
    └ Client.Start              (runner/pkg/boxlite/client.go:312)
       └ getOrFetchBox          (client.go:515)
          ├ cached.Info(ctx)    — reads persisted state; never enters the boot funnel
          ├ Running or Paused?  → reuse            (mirrors BoxStatus::is_active)
          └ else → evictBox     (client.go:562)    — identity-checked unmap under the lock
                   → runtime.Get                   — fresh handle, which can boot
       └ bx.Start(ctx) → BoxImpl::start → ensure_booted: empty cell → boots ✓

box sync tick, 10s              (runner/pkg/services/box_sync.go:53)
 └ GetBoxState                  (client.go:370)
    └ runtime.GetInfo           — handle-free; never touches the cache
       └ apiStateFromCore       (client.go:389)    — one arm per BoxStatus

Why eviction does not free the handle

getOrFetchBox hands the same *boxlite.Box to every caller and the runner
serializes nothing per box, so calling Close there would free an FFI handle
another goroutine is mid-call on. Eviction therefore unmaps only, and the
unmapped handle leaks for the process lifetime. The cost tracks request volume —
which is why GetBoxState, run for every box on a timer, was moved off the
cache entirely. Ref-counting the wrapper is the real fix and belongs in the SDK.

Second commit: exhaustive state mapping

The core→control-plane mapping covered 3 of the runtime's 7 statuses. Failed
and Stopping both fell through to Unknown, which the API's usage accounting
counts as compute-consuming (BOX_STATES_CONSUMING_COMPUTE) while Error is
excluded — so a dead box read as occupying compute. The mapping is now
exhaustive and unit-tested, and the Go SDK gained the three State constants
the FFI layer could already emit but Go could not name.

Third commit: unblocking the pre-push suite

The Go secret-substitution test reached httpbin.org while its Python and Node
counterparts both use httpbingo.org. httpbin.org began answering 503 and
took the whole pre-push suite down with it. Tolerating an upstream 5xx instead
was considered and rejected: the guest reaches that host through boxlite's own
MITM, which answers 502 for any transport error of its own — secret
substitution included — so skipping on a server error would hide the regression
that test exists to catch.

Verification

  • Spent handle: with both production files reverted to their pre-fix state and
    only the reproducer present, TestIntegrationSelfStoppedBoxRestartsAfterCachedHandleGoesSpent
    fails with the genuine code=11 error; restored, it passes (18.30s, real microVM).
  • State mapping: with the old switch arms restored, the table test fails on
    stopping, paused and failed. A literal full revert is impossible there —
    apiStateFromCore is itself the new symbol — so that side is a compile error,
    recorded rather than papered over.
  • evictBox's identity guard has unit coverage including a concurrent case,
    green under -race.
  • go vet clean; apps/runner ./pkg/... and the full sdks/go suite green on
    this rebased base.

Summary by CodeRabbit

  • New Features

    • Expanded Go SDK state reporting to include unknown, paused, and failed states.
  • Bug Fixes

    • Improved box state reporting for paused, stopping, and failed boxes.
    • Prevented stale cached handles from being reused after a box stops.
    • Enabled stopped boxes to restart reliably.
  • Tests

    • Added coverage for state mappings, cache eviction, concurrent access, and restarting boxes with expired handles.
    • Updated network integration and end-to-end tests to use a more reliable endpoint.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d38085f-fd55-42d0-96b6-bcdfde0e4791

📥 Commits

Reviewing files that changed from the base of the PR and between 49a64bb and 3641450.

📒 Files selected for processing (7)
  • apps/e2e/cases/test_real_world.py
  • apps/runner/pkg/boxlite/box_state_mapping_test.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_spent_handle_test.go
  • apps/runner/pkg/boxlite/evict_box_test.go
  • sdks/go/info.go
  • sdks/go/network_secrets_integration_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/runner/pkg/boxlite/evict_box_test.go
  • sdks/go/network_secrets_integration_test.go
  • sdks/go/info.go
  • apps/runner/pkg/boxlite/box_state_mapping_test.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/client_spent_handle_test.go

📝 Walkthrough

Walkthrough

The runner now maps all runtime states, reads persisted state directly, and validates cached handles before reuse. Conditional eviction supports restart after self-stopped boxes. Go state constants and network test endpoints were updated.

Changes

Box lifecycle state handling

Layer / File(s) Summary
Runtime state contract and mappings
sdks/go/info.go, apps/runner/pkg/boxlite/box_state_mapping_test.go
The Go SDK adds StateUnknown, StatePaused, and StateFailed. Tests cover current and future runtime-state mappings.
State reads and cached-handle validation
apps/runner/pkg/boxlite/client.go
GetBoxState reads persisted runtime information and maps runtime states explicitly. Cached handles are validated before reuse, and stale handles are conditionally evicted.
Eviction and restart validation
apps/runner/pkg/boxlite/evict_box_test.go, apps/runner/pkg/boxlite/client_spent_handle_test.go
Tests cover conditional and concurrent eviction. An integration test verifies restart after a box stops itself.

Network integration endpoint update

Layer / File(s) Summary
Integration test endpoints
sdks/go/network_secrets_integration_test.go, apps/e2e/cases/test_real_world.py
The network integration tests now use httpbingo.org instead of httpbin.org.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: ltstriker

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes spent-handle eviction and exhaustive core-status mapping.
Description check ✅ Passed The description explains the bug, call-graph changes, implementation rationale, and verification steps with sufficient technical detail.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-jaunty-coalescing-lemon

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.

@DorianZheng
DorianZheng marked this pull request as ready for review August 3, 2026 15:53
@DorianZheng
DorianZheng requested a review from a team as a code owner August 3, 2026 15:53
@boxlite-agent

boxlite-agent Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"6e1a3aa8-2e75-428c-9e64-98dfd2ff6724","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","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":403,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","type":"result","duration_ms":330,"uuid":"5d419a5d-c45e-4962-9f88-61f78ff788eb"}

stderr:
<empty>

powered by BoxLite

A box can stop itself — its main command exits and the guest powers the
VM off — leaving the runner's cached handle bound to a dead VM. Nothing
cleared that entry on a self-stop, so every later Start, exec or copy on
that box was answered with the corpse for the life of the runner
process: "Box <id> is no longer running and this handle is spent".

getOrFetchBox now validates a cached handle before serving it and hands
back a fresh one once the box is no longer up, which is the auto-restart
the control plane relies on. Paused counts as up, matching the runtime's
own BoxStatus::is_active. The equivalent guard already existed on the
Rust side in `boxlite serve`.

Eviction unmaps but does not free the handle: the same *boxlite.Box is
handed to every caller and nothing serializes per box, so freeing would
pull it out from under a goroutine mid-call. That trade only holds while
eviction stays on operator-driven paths, so GetBoxState — which the sync
loop runs for every box every 10s — now reads through the runtime, which
needs no handle at all. Routing it through the cache would have evicted
and re-fetched per box per tick, accumulating dead handles for as long as
the runner ran.

Separately, the core-to-control-plane state mapping covered 3 of the
runtime's 7 statuses. Failed and Stopping both fell through to Unknown,
which the API's usage accounting counts as compute-consuming while Error
is excluded. The mapping is now exhaustive and unit-tested, and the Go
SDK gained the three State constants the FFI layer could already emit but
Go could not name.

Claude-Session: https://claude.ai/code/session_01CGk6BR7BHsUqijZagdfVWN
The Go secret-substitution test reached httpbin.org while its Python and
Node counterparts both use httpbingo.org. httpbin.org started answering
503 and took the whole pre-push suite down with it, letting a third party
decide whether any sdks/ change can be pushed. Aligning on the host the
other two SDKs already rely on removes the odd one out.

Tolerating an upstream 5xx instead was considered and rejected: the guest
reaches this host through boxlite's own MITM, which answers 502 for any
transport error of its own, secret substitution included. Skipping on a
server error would therefore hide the regression this test exists to
catch.

Claude-Session: https://claude.ai/code/session_01CGk6BR7BHsUqijZagdfVWN
The three SDK secret suites all reach httpbingo.org; this case was the
last reference to httpbin.org left in any test. Aligning it removes a
second third-party dependency from the test surface.

The prompt was an httpbin.org outage — it served 503 for long enough to
take a pre-push run down — which exposed how this case degrades: it skips
on a nonzero exit, so while the host was unreachable it reported "network
not available in guest" and stopped exercising guest networking, looking
identical to a real egress break. The silent-skip mechanism itself is
unchanged here; only the host that was tripping it.

httpbingo.org/get was confirmed answering 200.

Claude-Session: https://claude.ai/code/session_01CGk6BR7BHsUqijZagdfVWN
@DorianZheng
DorianZheng force-pushed the worktree-jaunty-coalescing-lemon branch from 49a64bb to 3641450 Compare August 3, 2026 16:22
@DorianZheng
DorianZheng merged commit b95da63 into main Aug 4, 2026
40 checks passed
@DorianZheng
DorianZheng deleted the worktree-jaunty-coalescing-lemon branch August 4, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant