-
Notifications
You must be signed in to change notification settings - Fork 457
fix(orch): prevent NBD dispatch read-loop stall on WRITE_ZEROES (behind flag) #3048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
195 changes: 195 additions & 0 deletions
195
packages/orchestrator/pkg/sandbox/nbd/dispatch_writezeroes_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| //go:build linux | ||
|
|
||
| package nbd | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/binary" | ||
| "io" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| // ctrlConn is a fake NBD socket driving the real Dispatch.Handle loop. | ||
| // | ||
| // - Read serves queued request bytes (one queued slice per Read), blocking | ||
| // until a request is queued (or reqCh is closed -> io.EOF). | ||
| // - Write fires firstWrite once, then blocks until gate is closed, simulating | ||
| // a full socket send buffer (the kernel not draining replies). | ||
| type ctrlConn struct { | ||
| reqCh chan []byte | ||
| rbuf []byte | ||
| gate chan struct{} | ||
| firstWrite chan struct{} | ||
| fwOnce sync.Once | ||
| } | ||
|
|
||
| func (c *ctrlConn) Read(p []byte) (int, error) { | ||
| for len(c.rbuf) == 0 { | ||
| b, ok := <-c.reqCh | ||
| if !ok { | ||
| return 0, io.EOF | ||
| } | ||
| c.rbuf = b | ||
| } | ||
| n := copy(p, c.rbuf) | ||
| c.rbuf = c.rbuf[n:] | ||
|
|
||
| return n, nil | ||
| } | ||
|
|
||
| func (c *ctrlConn) Write(p []byte) (int, error) { | ||
| c.fwOnce.Do(func() { close(c.firstWrite) }) | ||
| <-c.gate | ||
|
|
||
| return len(p), nil | ||
| } | ||
|
|
||
| // stallProv is a minimal Provider. It records the offsets passed to ReadAt so a | ||
| // test can tell whether the read loop served a particular request, and signals | ||
| // the first WriteZeroesAt call. | ||
| type stallProv struct { | ||
| mu sync.Mutex | ||
| seen map[int64]bool | ||
| wz chan struct{} | ||
| wzOnce sync.Once | ||
| } | ||
|
|
||
| func (m *stallProv) ReadAt(_ context.Context, p []byte, off int64) (int, error) { | ||
| m.mu.Lock() | ||
| m.seen[off] = true | ||
| m.mu.Unlock() | ||
|
|
||
| return len(p), nil | ||
| } | ||
|
|
||
| func (m *stallProv) sawRead(off int64) bool { | ||
| m.mu.Lock() | ||
| defer m.mu.Unlock() | ||
|
|
||
| return m.seen[off] | ||
| } | ||
|
|
||
| func (m *stallProv) Size(_ context.Context) (int64, error) { return 1 << 40, nil } | ||
| func (m *stallProv) WriteAt(p []byte, _ int64) (int, error) { return len(p), nil } | ||
|
|
||
| func (m *stallProv) WriteZeroesAt(_, length int64) (int, error) { | ||
| m.wzOnce.Do(func() { close(m.wz) }) | ||
|
|
||
| return int(length), nil | ||
| } | ||
|
|
||
| func nbdRequest(typ uint16, handle, from uint64, length uint32) []byte { | ||
| b := make([]byte, 28) | ||
| binary.BigEndian.PutUint32(b[0:], NBDRequestMagic) | ||
| binary.BigEndian.PutUint16(b[4:], 0) // flags | ||
| binary.BigEndian.PutUint16(b[6:], typ) | ||
| binary.BigEndian.PutUint64(b[8:], handle) | ||
| binary.BigEndian.PutUint64(b[16:], from) | ||
| binary.BigEndian.PutUint32(b[24:], length) | ||
|
|
||
| return b | ||
| } | ||
|
|
||
| func waitForRead(p *stallProv, off int64, attempts int) bool { | ||
| for range attempts { | ||
| if p.sawRead(off) { | ||
| return true | ||
| } | ||
| time.Sleep(10 * time.Millisecond) | ||
| } | ||
|
|
||
| return p.sawRead(off) | ||
| } | ||
|
|
||
| // TestDispatchWriteZeroesReadLoopStall pins the head-of-line stall the | ||
| // nbd-async-write-zeroes flag fixes. | ||
| // | ||
| // A READ reply writer is made to block inside writeResponse while holding | ||
| // writeLock (simulating a full socket send buffer). A WRITE_ZEROES is then | ||
| // dispatched: | ||
| // - inline (asyncWriteZeroes=false): cmdWriteZeroes runs on the read loop and | ||
| // blocks acquiring writeLock, so the loop stops serving new requests. | ||
| // - async (asyncWriteZeroes=true): cmdWriteZeroes runs in a goroutine, so the | ||
| // read loop keeps serving. | ||
| // | ||
| // In both modes, once the blocked reply drains the loop must make progress | ||
| // again (matches the transient, self-clearing incidents this fix targets). | ||
| func TestDispatchWriteZeroesReadLoopStall(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| for _, tc := range []struct { | ||
| name string | ||
| asyncWriteZeroes bool | ||
| wantStall bool | ||
| }{ | ||
| {name: "inline_stalls_read_loop", asyncWriteZeroes: false, wantStall: true}, | ||
| {name: "async_keeps_read_loop_alive", asyncWriteZeroes: true, wantStall: false}, | ||
| } { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| conn := &ctrlConn{ | ||
| reqCh: make(chan []byte, 8), | ||
| gate: make(chan struct{}), | ||
| firstWrite: make(chan struct{}), | ||
| } | ||
| prov := &stallProv{seen: map[int64]bool{}, wz: make(chan struct{})} | ||
| d := NewDispatch(conn, prov, tc.asyncWriteZeroes) | ||
|
|
||
| done := make(chan struct{}) | ||
| go func() { | ||
| _ = d.Handle(t.Context()) | ||
| close(done) | ||
| }() | ||
| // Wind the loop down deterministically at the end: unblock any | ||
| // blocked reply Write, then close the request stream so Handle's | ||
| // Read returns io.EOF and the goroutine exits. | ||
| t.Cleanup(func() { | ||
| select { | ||
| case <-conn.gate: | ||
| default: | ||
| close(conn.gate) | ||
| } | ||
| close(conn.reqCh) | ||
| <-done | ||
| }) | ||
|
|
||
| const offProbe = int64(0xABCDEF000) | ||
|
|
||
| // 1) READ -> its async reply writer blocks in Write holding writeLock. | ||
| conn.reqCh <- nbdRequest(NBDCmdRead, 1, 0, 512) | ||
| select { | ||
| case <-conn.firstWrite: | ||
| case <-time.After(2 * time.Second): | ||
| t.Fatal("read reply writer never reached the blocked socket Write") | ||
| } | ||
|
|
||
| // 2) WRITE_ZEROES -> inline blocks the loop on writeLock; async does not. | ||
| conn.reqCh <- nbdRequest(NBDCmdWriteZeroes, 2, 4096, 4096) | ||
| select { | ||
| case <-prov.wz: | ||
| case <-time.After(2 * time.Second): | ||
| t.Fatal("WriteZeroesAt was never called") | ||
| } | ||
|
|
||
| // 3) Probe with a fresh READ; a live loop serves it (records offProbe). | ||
| conn.reqCh <- nbdRequest(NBDCmdRead, 3, uint64(offProbe), 512) | ||
|
|
||
| served := waitForRead(prov, offProbe, 50) // ~500ms | ||
| switch { | ||
| case tc.wantStall && served: | ||
| t.Fatal("expected the read loop to STALL while a reply write is blocked, but it served a new request") | ||
| case !tc.wantStall && !served: | ||
| t.Fatal("expected the read loop to keep serving with async WRITE_ZEROES, but it stalled") | ||
| } | ||
|
|
||
| // Unblock replies; in both modes the loop must then serve the probe. | ||
| close(conn.gate) | ||
| if !waitForRead(prov, offProbe, 300) { // ~3s | ||
| t.Fatal("read loop did not serve the probe after replies drained") | ||
| } | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unlike cmdRead and cmdWrite, cmdWriteZeroes does not handle context cancellation when executing the backend operation. If the backend WriteZeroesAt call blocks indefinitely during shutdown, the goroutine will hang and prevent the orchestrator from draining pending responses cleanly, leading to a shutdown deadlock. Spawning a goroutine for the backend call and selecting on ctx.Done allows the dispatcher to unblock and exit gracefully.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — fixed.
cmdWriteZeroesnow runsWriteZeroesAtin a goroutine and selects onctx, mirroringcmdRead/cmdWrite, so a blocked backend call can't hang the goroutine or the pending-response drain on shutdown. Folded into the refactor commit (performWriteZeroes).