fix: prevent send-on-closed-channel panic in concurrent Put/Delete + Close (C7)

Put/Delete checked writeStopped then called queue.Submit (which sends on
a channel). Close set writeStopped then closed the channel. With no
synchronization between the check and Submit, a concurrent Close could
close the channel during the window, causing "send on closed channel"
panic in Put/Delete.

The CommitQueue.Close comment said "Callers must stop submitting before
Close" but Put/Delete didn't enforce this.

Changes:
- wal/writer.go: add submitMu sync.RWMutex. Put/Delete construct entry
  outside the lock, then hold RLock during writeStopped check + Submit;
  release before <-req.Result. Close holds write lock during
  writeStopped.Store + queue.Close, guaranteeing no Submit is in progress
  when the channel is closed.
- wal/writer_test.go: add TestConcurrentPutCloseNoPanic and
  TestConcurrentDeleteCloseNoPanic. 100 goroutines + 1 closer, recover
  panics. Run with -count=50 for regression detection.

Verified: all existing tests pass. New concurrent tests pass with
-count=50 (0 panics). go test -race ./... green.

Audit context: docs/audit-3.2.md C7.
This commit is contained in:
dailz
2026-06-18 13:25:30 +08:00
parent 94da39bb79
commit 408138b3c8
3 changed files with 469 additions and 12 deletions
+31 -12
View File
@@ -58,6 +58,7 @@ type WalWriter struct {
seqManager *SequenceManager
memTables *MemTableList
writeStopped atomic.Bool
submitMu sync.RWMutex // C7 fix: protects Submit vs Close critical section
done chan struct{}
wg sync.WaitGroup
closeOnce sync.Once
@@ -311,31 +312,43 @@ func (ww *WalWriter) sendSuccess(requests []*CommitRequest, baseSequence uint64,
// Put stores key with value.
func (ww *WalWriter) Put(key, value []byte) error {
if ww.writeStopped.Load() {
return errkit.ErrWriteStopped
}
req := ww.queue.Submit([]*WalEntry{{
entry := &WalEntry{
OpType: OpPut,
ValueKind: VKInline,
Key: cloneBytes(key),
Value: cloneBytes(value),
}})
}
// C7 fix: RLock protects check + Submit. Close holds write lock during
// writeStopped.Store + queue.Close, so Submit cannot send on a closed channel.
ww.submitMu.RLock()
if ww.writeStopped.Load() {
ww.submitMu.RUnlock()
return errkit.ErrWriteStopped
}
req := ww.queue.Submit([]*WalEntry{entry})
ww.submitMu.RUnlock()
result := <-req.Result
return result.Err
}
// Delete removes key.
func (ww *WalWriter) Delete(key []byte) error {
if ww.writeStopped.Load() {
return errkit.ErrWriteStopped
}
req := ww.queue.Submit([]*WalEntry{{
entry := &WalEntry{
OpType: OpDelete,
ValueKind: VKNone,
Key: cloneBytes(key),
}})
}
ww.submitMu.RLock()
if ww.writeStopped.Load() {
ww.submitMu.RUnlock()
return errkit.ErrWriteStopped
}
req := ww.queue.Submit([]*WalEntry{entry})
ww.submitMu.RUnlock()
result := <-req.Result
return result.Err
}
@@ -368,8 +381,14 @@ func (ww *WalWriter) IsWriteStopped() bool {
// Close drains queued writes, stops the writer goroutine, syncs, and closes the segment.
func (ww *WalWriter) Close() error {
ww.closeOnce.Do(func() {
// C7 fix: write lock waits for all in-flight Put/Delete Submits to
// complete. While we hold this lock, no Submit can be in progress,
// so queue.Close is safe (no send-on-closed-channel panic).
ww.submitMu.Lock()
ww.writeStopped.Store(true)
ww.queue.Close()
ww.submitMu.Unlock()
close(ww.done)
ww.wg.Wait()
if err := ww.segManager.Sync(); err != nil {