fix(shim): reduce critical sections in local sandbox - #295
Conversation
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
This PR reduces time spent holding the localsandbox mutex during Stop and StartStream to prevent unrelated calls (notably Client()) from blocking behind long-running guest operations.
Changes:
- Move
Shutdown()andStartStream()calls outside of thelocalsandboxcritical section by copyings.instanceunder lock. - Add concurrency-focused unit tests to ensure
Client()is not blocked by in-flightStartStream()handshakes orStop()shutdowns.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| internal/shim/sandbox/vm/vm.go | Shrinks mutex-held regions in Stop and StartStream by calling into the VM instance outside the lock. |
| internal/shim/sandbox/vm/vm_test.go | Adds tests that simulate blocking VM calls and verify Client() remains responsive; checks Client() fails after Stop(). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| select { | ||
| case <-inst.startStreamCalled: | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("StartStream was never called on the instance") | ||
| } |
| select { | ||
| case <-clientDone: | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("Client() blocked behind an in-flight StartStream handshake") | ||
| } |
| select { | ||
| case <-inst.shutdownCalled: | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("Shutdown was never called on the instance") | ||
| } |
| select { | ||
| case <-clientDone: | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("Client() blocked behind an in-flight Shutdown") | ||
| } |
7bbaddf to
e1c50f6
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new tests can leak/block goroutines on failure paths (before inst.release is closed), which can hang the overall test run and should be hardened with cleanup unblocking.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
internal/shim/sandbox/vm/vm_test.go:100
- If this test fails before reaching close(inst.release), the Stop goroutine will remain blocked, potentially hanging the test suite. Add a t.Cleanup that closes inst.release if it isn’t already closed.
s := &localsandbox{instance: inst}
internal/shim/sandbox/vm/vm_test.go:146
- This test can hang the suite on early failure because the first Stop goroutine blocks on inst.release until it’s closed. Add a cleanup close for inst.release so t.Fatal paths still unblock the goroutine.
s := &localsandbox{instance: inst}
internal/shim/sandbox/vm/vm_test.go:187
- If any assertion fails before close(inst.release), the Stop goroutine will remain blocked in Shutdown and may hang the overall test run. Add a t.Cleanup to close inst.release if needed.
s := &localsandbox{instance: inst}
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
e1c50f6 to
75fca53
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Stop can leave an instance usable after Shutdown returns an error, contradicting the vm.Instance lifecycle contract and risking undefined behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
75fca53 to
93444b2
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
93444b2 to
73e6c94
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The shutdown state transition is not atomic, and oversized stream IDs can be rejected after guest acceptance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
73e6c94 to
6f332e1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown can hold v.mu during teardown while StartStream waits to untrack its connection.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
6f332e1 to
621bcfa
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Shutdown still holds the VM mutex during slow teardown, potentially blocking StartStream indefinitely.
Review details
Suppressed comments (1)
internal/vm/libkrun/instance.go:538
Shutdownstill holdsv.muwhile this closes the tracked connections and performs the rest of teardown. A handshake that is unblocked here returns fromcompleteStreamHandshakeand then blocks inuntrackStreamConnwaiting for the same mutex, soStartStreamcan remain stuck untilvmc.Shutdown/dlClosefinish (or indefinitely if VM teardown hangs). Detach/clear the in-flight set under the lock and release the lock before the slow teardown, while preserving a shutdown state for the post-handshake check.
// Run before the VM teardown below, so an in-flight handshake unblocks
// with an error instead of racing that teardown.
v.closeStreamConnsLocked()
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
621bcfa to
48bd108
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved VM teardown and stream/lifecycle synchronization issues remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
internal/vm/libkrun/instance.go:608
- The
v.handler = 0immediately above this return is now written withoutv.mu, while a concurrentShutdowncan read it underbeginShutdown. This data race was introduced by moving the lock out ofShutdownand can make lifecycle state nondeterministic under-race; assign the zero value while holding the same mutex.
return nil
internal/vm/libkrun/instance.go:428
- This only derives a deadline from
ctx.Deadline(). If the caller cancels a context that has no deadline while the guest is waiting to ack,completeStreamHandshakenever observes that cancellation and remains blocked for the fullvmStartTimeout; interrupt the tracked connection onctx.Done()as well.
deadline := time.Now().Add(vmStartTimeout)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
}
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
localsandbox held s.mu for the full duration of Stop's call into instance.Shutdown and, previously, of StartStream's guest handshake. Neither call has a deadline of its own, so a slow or hung guest held that mutex for as long as the stall lasted, blocking Client and StartStream for every other RPC against the sandbox. Stop and StartStream now hold s.mu only to read/mutate localsandbox's own state, releasing it before the blocking call into the instance. A stopping flag, set by the new beginStopping helper, serializes concurrent Stop calls against each other and makes Client/StartStream fail fast instead of racing a call against an in-flight Shutdown. Client/StartStream's shared precondition check is factored into activeInstance so it can't drift out of sync between the two. Client also now treats a nil client from the instance as unavailable instead of returning (nil, nil), which could otherwise happen if a prior Stop failed partway through tearing the instance down. Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
vmInstance.StartStream shared no lock with Shutdown at all, so it ran fully concurrently with VM teardown. Its guest handshake (completeStreamHandshake) also has no deadline of its own: a guest that stopped acking streams left StartStream, and any caller blocked on it, stuck for the life of the VM. StartStream now registers each dialed connection in inFlightHandshakes before starting the handshake, and unregisters it afterward regardless of outcome. Shutdown detaches this set under v.mu (closing every tracked connection, which unblocks any in-flight handshake with an error) via the new beginShutdown, which also adds a shuttingDown flag: it serializes concurrent Shutdown calls against each other, and lets StartStream fail fast once shutdown has begun instead of registering a connection nothing will ever close. beginShutdown returns before the unbounded vmc.Shutdown/dlClose calls, so Shutdown no longer holds v.mu for their duration either. Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
ackLen in completeStreamHandshake is guest-controlled and fed directly into an allocation with no bound, so a single crafted length prefix could force an allocation of up to 4 GiB. Bound it with maxAckSize, scaled to the stream ID's own length rather than a fixed constant: the streaming protocol documents stream IDs as arbitrary strings, and the guest's success ack is the ID itself, so a fixed cap would reject legitimate IDs above it after the guest already accepted them. The guest's only other response is a short rejection message naming the ID, wrapped in %q, whose escaping can expand a single byte to 4 characters -- accounted for in the 4x multiplier. Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
closeStreamConnsLocked only unblocks a stalled StartStream handshake once Shutdown runs. A guest that stops acking streams while the VM keeps running leaving the handshake, and any caller blocked on it, stuck for the VM's lifetime regardless. Bound the handshake itself: set a deadline of min(vmStartTimeout, ctx's deadline) on the dialed connection before the handshake and clear it before handing the connection back as a long-lived I/O stream. The connection stays registered in inFlightHandshakes through every step that can still fail, including clearing the deadline, with the final check right before returning it to the caller -- so a concurrent Shutdown anywhere in that window still finds and closes it instead of this call handing back a connection racing (or already lost to) that teardown. Signed-off-by: Austin Vazquez <austin.vazquez@docker.com>
48bd108 to
27c2814
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown coordination, cancellation handling, retry wake-up, and fail-fast test coverage need improvement.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
internal/shim/sandbox/vm/vm.go:195
activeInstanceonly protects the lookup; after it returns,Stopcan setstoppingand finishinstance.Shutdownwhile thisStartStreamcall is still returning. In the libkrun implementation the handshake connection is untracked immediately before this return, so that interleaving can yield(conn, nil)for a VM that has already been torn down. Add an in-flight operation lease (or otherwise wait forStartStreamto return) before allowingStopto callShutdown.
return instance.StartStream(ctx, streamID)
internal/vm/libkrun/instance.go:417
- Shutdown is only observed here after
os.Statsucceeds and a new socket connection is dialed. If an in-flight StartStream is still in the retry loop whenstreaming.sockdisappears during teardown, it never reachestrackStreamConnand waits through the 10–990 ms sleeps (about 49.5 seconds) before returning, so Shutdown does not unblock it. Check the shutdown state while retrying and provide a wake-up path instead of checking only after dialing.
if !v.trackStreamConn(conn) {
// Shutdown has already closed every tracked stream
// connection and is tearing down (or has torn down) the
// VM; don't hand back a connection racing that teardown.
conn.Close()
return nil, errdefs.ErrUnavailable.WithMessage("vm instance is shutting down")
internal/vm/libkrun/instance_test.go:131
- This assertion does not verify the documented “fail fast” behavior: an implementation that reaches the guest handshake and blocks could still return a context-deadline error within 5 seconds and pass. Measure the call duration (or synchronize on the server to prove no handshake was attempted) and assert it returns well before the test deadline.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := v.StartStream(ctx, "test-stream"); err == nil {
t.Fatal("expected StartStream to fail fast once inFlightHandshakes is nil")
}
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
| deadline := time.Now().Add(vmStartTimeout) | ||
| if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { | ||
| deadline = dl |
localsandbox serialized every RPC behind one mutex, including two calls
with no deadline of their own: Stop's call into instance.Shutdown and
StartStream's guest handshake. A guest that stopped acking streams, or a
slow Shutdown, held that mutex for as long as the stall lasted, blocking
every other RPC against the sandbox. The same problem existed one layer
down in the libkrun vm.Instance: StartStream shared no lock with Shutdown
at all, and its handshake had no deadline, so a stalled guest could wedge
an individual exec for the VM's lifetime even once the sandbox-level fix
stopped it from blocking unrelated RPCs.
This change, across four commits:
StartStream, holding it only to read/mutate localsandbox's own state.
A stopping flag makes concurrent Stop calls fail fast instead of
racing each other, and makes Client/StartStream fail fast instead of
racing a call against an in-flight Shutdown. Client also now rejects a
nil client from the instance as unavailable, rather than silently
returning (nil, nil).
Shutdown can close them, unblocking a handshake stalled on a guest
that never acks instead of leaving it to hang for the life of the VM.
A shuttingDown flag serializes concurrent Shutdown calls and lets
Shutdown release its own lock before the unbounded VM teardown.
closing a guest-controlled unbounded-allocation path.
timeout and the caller's context deadline), and separately watches
for context cancellation without a deadline (e.g. a disconnected
ttrpc request), so a wedged guest or a canceled caller can no longer
hold a stream handshake open indefinitely.
Tested with
go build ./...,go vet ./...,gofmt -l, andgo teston both changed packages; each fix has a dedicated regression test
verified against a temporarily reverted version of the fix.