Skip to content

fix(shim): reduce critical sections in local sandbox - #295

Open
austinvazquez wants to merge 4 commits into
containerd:mainfrom
austinvazquez:fix/reduce-critical-section-in-start-stream
Open

fix(shim): reduce critical sections in local sandbox#295
austinvazquez wants to merge 4 commits into
containerd:mainfrom
austinvazquez:fix/reduce-critical-section-in-start-stream

Conversation

@austinvazquez

@austinvazquez austinvazquez commented Sep 9, 2026

Copy link
Copy Markdown
Member

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:

  • Releases localsandbox's s.mu before the blocking calls in Stop and
    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).
  • Has the libkrun vm.Instance track in-flight StartStream handshakes so
    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.
  • Bounds the handshake's ack read relative to the stream ID's length,
    closing a guest-controlled unbounded-allocation path.
  • Bounds the handshake itself with a deadline (min of an internal
    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, and go test
on both changed packages; each fix has a dedicated regression test
verified against a temporarily reverted version of the fix.

Copilot AI lite review requested due to automatic review settings September 9, 2026 22:20

Copilot AI 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.

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() and StartStream() calls outside of the localsandbox critical section by copying s.instance under lock.
  • Add concurrency-focused unit tests to ensure Client() is not blocked by in-flight StartStream() handshakes or Stop() 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.

Comment thread internal/shim/sandbox/vm/vm.go
Comment thread internal/shim/sandbox/vm/vm.go Outdated
Comment on lines +69 to +73
select {
case <-inst.startStreamCalled:
case <-time.After(5 * time.Second):
t.Fatal("StartStream was never called on the instance")
}
Comment on lines +83 to +87
select {
case <-clientDone:
case <-time.After(5 * time.Second):
t.Fatal("Client() blocked behind an in-flight StartStream handshake")
}
Comment on lines +107 to +111
select {
case <-inst.shutdownCalled:
case <-time.After(5 * time.Second):
t.Fatal("Shutdown was never called on the instance")
}
Comment on lines +121 to +125
select {
case <-clientDone:
case <-time.After(5 * time.Second):
t.Fatal("Client() blocked behind an in-flight Shutdown")
}
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 7bbaddf to e1c50f6 Compare September 10, 2026 17:19
Copilot AI review requested due to automatic review settings September 10, 2026 17:19
@austinvazquez
austinvazquez marked this pull request as draft September 10, 2026 17:19

Copilot AI 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.

🟡 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

Comment thread internal/shim/sandbox/vm/vm_test.go
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from e1c50f6 to 75fca53 Compare September 10, 2026 18:33
Copilot AI review requested due to automatic review settings September 10, 2026 18:33

Copilot AI 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.

🟡 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

Comment thread internal/shim/sandbox/vm/vm.go Outdated
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 75fca53 to 93444b2 Compare September 10, 2026 19:28
Copilot AI review requested due to automatic review settings September 10, 2026 19:28

Copilot AI 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.

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.

Comment thread internal/vm/libkrun/instance.go
Comment thread internal/vm/libkrun/instance.go
Comment thread internal/vm/libkrun/instance.go Outdated
Copilot AI review requested due to automatic review settings September 10, 2026 21:46
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 93444b2 to 73e6c94 Compare September 10, 2026 21:46

Copilot AI 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.

🟡 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

Comment thread internal/shim/sandbox/vm/vm.go Outdated
Comment thread internal/vm/libkrun/instance.go Outdated
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 73e6c94 to 6f332e1 Compare September 10, 2026 22:43
Copilot AI review requested due to automatic review settings September 10, 2026 22:43

Copilot AI 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.

🟡 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

Comment thread internal/vm/libkrun/instance.go Outdated
Copilot AI review requested due to automatic review settings September 11, 2026 14:07
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 6f332e1 to 621bcfa Compare September 11, 2026 14:07

Copilot AI 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.

🔵 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

  • Shutdown still holds v.mu while this closes the tracked connections and performs the rest of teardown. A handshake that is unblocked here returns from completeStreamHandshake and then blocks in untrackStreamConn waiting for the same mutex, so StartStream can remain stuck until vmc.Shutdown/dlClose finish (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

Copilot AI review requested due to automatic review settings September 11, 2026 14:57
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 621bcfa to 48bd108 Compare September 11, 2026 14:57

Copilot AI 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.

🟡 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 = 0 immediately above this return is now written without v.mu, while a concurrent Shutdown can read it under beginShutdown. This data race was introduced by moving the lock out of Shutdown and 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, completeStreamHandshake never observes that cancellation and remains blocked for the full vmStartTimeout; interrupt the tracked connection on ctx.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

Comment thread internal/vm/libkrun/instance.go
Comment thread internal/vm/libkrun/instance.go Outdated
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>
Copilot AI review requested due to automatic review settings September 11, 2026 16:43
@austinvazquez
austinvazquez force-pushed the fix/reduce-critical-section-in-start-stream branch from 48bd108 to 27c2814 Compare September 11, 2026 16:43

Copilot AI 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.

🟡 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

  • activeInstance only protects the lookup; after it returns, Stop can set stopping and finish instance.Shutdown while this StartStream call 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 for StartStream to return) before allowing Stop to call Shutdown.
	return instance.StartStream(ctx, streamID)

internal/vm/libkrun/instance.go:417

  • Shutdown is only observed here after os.Stat succeeds and a new socket connection is dialed. If an in-flight StartStream is still in the retry loop when streaming.sock disappears during teardown, it never reaches trackStreamConn and 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

Comment on lines +425 to +427
deadline := time.Now().Add(vmStartTimeout)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
@austinvazquez
austinvazquez marked this pull request as ready for review September 11, 2026 19:19
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.

2 participants