|
| 1 | +// Package skyobject pkg/cxo/skyobject/cache_batch_lockorder_test.go |
| 2 | +// |
| 3 | +// Pins the lock order between Cache.mx and bbolt's writer lock, which |
| 4 | +// WithBatch is the only place able to invert (it opens the writer, then |
| 5 | +// calls back into Cache.Set). The inversion deadlocked a node |
| 6 | +// permanently — the publisher stopped clearing dirty, every later send |
| 7 | +// was silently dropped, and teardown hung because the cleanup sweep |
| 8 | +// wants Cache.mx too. Windows CI hit it as a 25-minute package timeout |
| 9 | +// in cmd/apps/skychat/pairing, which publishes and subscribes on one |
| 10 | +// CXO node by design. |
| 11 | +// |
| 12 | +// This test drives the interleaving directly rather than waiting for a |
| 13 | +// pairing suite to get unlucky: hold the writer, park a cache reader |
| 14 | +// that needs it (a refcount bump goes through a write tx), then start |
| 15 | +// the batch on top of both. |
| 16 | +package skyobject |
| 17 | + |
| 18 | +import ( |
| 19 | + "fmt" |
| 20 | + "path/filepath" |
| 21 | + "sync" |
| 22 | + "testing" |
| 23 | + "time" |
| 24 | + |
| 25 | + "github.com/skycoin/skycoin/src/cipher" |
| 26 | + |
| 27 | + "github.com/skycoin/skywire/pkg/cxo/data" |
| 28 | +) |
| 29 | + |
| 30 | +// lockOrderRounds — one round is enough against the pre-fix code (the |
| 31 | +// staging in lockOrderRound is deliberate, not a race to lose), but the |
| 32 | +// staging leans on two short sleeps, so a few rounds keep it honest on a |
| 33 | +// loaded runner where one of them might land wrong. |
| 34 | +const lockOrderRounds = 5 |
| 35 | + |
| 36 | +// lockOrderWatchdog bounds the whole run. Far above the ~1s the rounds |
| 37 | +// take when nothing is wedged — it only has to tell "slow CI" from |
| 38 | +// "blocked forever". |
| 39 | +const lockOrderWatchdog = 30 * time.Second |
| 40 | + |
| 41 | +// TestWithBatch_DoesNotInvertCacheAndWriterLocks fails (by watchdog) if |
| 42 | +// WithBatch ever again takes bbolt's writer lock before Cache.mx. |
| 43 | +func TestWithBatch_DoesNotInvertCacheAndWriterLocks(t *testing.T) { |
| 44 | + // An on-disk container: the in-memory CXDS has no writer lock, so |
| 45 | + // the inversion this guards against cannot exist there. |
| 46 | + conf := NewConfig() |
| 47 | + conf.InMemoryDB = false |
| 48 | + conf.DBPath = filepath.Join(t.TempDir(), "db") |
| 49 | + |
| 50 | + c, err := NewContainer(conf) |
| 51 | + if err != nil { |
| 52 | + t.Fatalf("NewContainer: %v", err) |
| 53 | + } |
| 54 | + |
| 55 | + done := make(chan struct{}) |
| 56 | + go func() { |
| 57 | + defer close(done) |
| 58 | + for i := 0; i < lockOrderRounds; i++ { |
| 59 | + lockOrderRound(t, c, i) |
| 60 | + } |
| 61 | + }() |
| 62 | + |
| 63 | + select { |
| 64 | + case <-done: |
| 65 | + case <-time.After(lockOrderWatchdog): |
| 66 | + // Deliberately no Close() on this path: the wedged goroutines |
| 67 | + // hold the bbolt writer, and bolt's Close waits for it — the |
| 68 | + // cleanup would hang exactly like the bug under test. |
| 69 | + t.Fatalf("deadlock: WithBatch and a concurrent Cache.Get are waiting on each other "+ |
| 70 | + "(Cache.mx vs the bbolt writer) — %s elapsed with rounds unfinished", lockOrderWatchdog) |
| 71 | + } |
| 72 | + |
| 73 | + if err := c.Close(); err != nil { |
| 74 | + t.Errorf("Close: %v", err) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +// lockOrderRound stages the three-goroutine interleaving that produced |
| 79 | +// the CI hang, then lets it resolve: |
| 80 | +// |
| 81 | +// (a) holds bbolt's writer lock, |
| 82 | +// (b) WithBatch — wants the writer, and once inside wants Cache.mx, |
| 83 | +// (c) a Cache.Get with inc != 0 — takes Cache.mx, then needs the |
| 84 | +// writer for the refcount bump, so it parks holding the mutex. |
| 85 | +// |
| 86 | +// Order matters, and it is why (b) is started before (c): bbolt hands a |
| 87 | +// contended writer lock to the goroutine that queued first, so whoever |
| 88 | +// waits first wins it when (a) lets go. With (b) first, the pre-fix code |
| 89 | +// took the writer, pinned, and only then asked for Cache.mx — which (c) |
| 90 | +// was holding while waiting for the writer (b) had just taken. Cycle. |
| 91 | +// |
| 92 | +// Start them the other way round and the pre-fix code survives by luck: |
| 93 | +// (c)'s refcount bump commits before (b) ever enters the tx. That |
| 94 | +// accident is why this bug reached CI as an occasional 25-minute hang |
| 95 | +// instead of a reproducible failure. |
| 96 | +// |
| 97 | +// Post-fix (b) takes Cache.mx before the writer, so it simply queues |
| 98 | +// behind (c) — no interleaving of the three can cycle. |
| 99 | +func lockOrderRound(t *testing.T, c *Container, round int) { |
| 100 | + t.Helper() |
| 101 | + |
| 102 | + // A key that is on disk but NOT in the cache, so Get has to reach |
| 103 | + // bbolt. Fresh per round: once round N's Get lands the object in |
| 104 | + // the cache, a repeat would short-circuit before the DB. |
| 105 | + key := cipher.SumSHA256([]byte(fmt.Sprintf("lock-order-probe-%d", round))) |
| 106 | + if _, err := c.DB().CXDS().Set(key, []byte("probe"), 1); err != nil { |
| 107 | + t.Errorf("seed CXDS: %v", err) |
| 108 | + return |
| 109 | + } |
| 110 | + |
| 111 | + var wg sync.WaitGroup |
| 112 | + holding, release := make(chan struct{}), make(chan struct{}) |
| 113 | + |
| 114 | + // (a) Occupy the writer lock so (b) and (c) both have to wait. |
| 115 | + wg.Add(1) |
| 116 | + go func() { |
| 117 | + defer wg.Done() |
| 118 | + err := c.DB().CXDS().RunBatch(func(_ data.CXDS) error { |
| 119 | + close(holding) |
| 120 | + <-release |
| 121 | + return nil |
| 122 | + }) |
| 123 | + if err != nil { |
| 124 | + t.Errorf("holder RunBatch: %v", err) |
| 125 | + } |
| 126 | + }() |
| 127 | + <-holding |
| 128 | + |
| 129 | + // (b) The publisher's batch. Queues on the writer first, so it is |
| 130 | + // the one that gets it. |
| 131 | + wg.Add(1) |
| 132 | + go func() { |
| 133 | + defer wg.Done() |
| 134 | + err := c.WithBatch(func() error { |
| 135 | + k := cipher.SumSHA256([]byte(fmt.Sprintf("lock-order-batch-%d", round))) |
| 136 | + _, err := c.Set(k, []byte("batched"), 1) |
| 137 | + return err |
| 138 | + }) |
| 139 | + if err != nil { |
| 140 | + t.Errorf("WithBatch: %v", err) |
| 141 | + } |
| 142 | + }() |
| 143 | + // No signal exists for "now blocked on the writer", so give it a |
| 144 | + // moment. A too-short wait only weakens the round; it cannot cause a |
| 145 | + // false failure, since a correct WithBatch never deadlocks either |
| 146 | + // way. |
| 147 | + time.Sleep(100 * time.Millisecond) |
| 148 | + |
| 149 | + // (c) The cache reader. inc != 0 makes it a read-modify-write, which |
| 150 | + // bbolt serves from a write tx — so it parks with Cache.mx held. |
| 151 | + wg.Add(1) |
| 152 | + go func() { |
| 153 | + defer wg.Done() |
| 154 | + // The returned value and error don't matter — the locks this |
| 155 | + // call takes on its way to bbolt are the whole point. |
| 156 | + _, _, _ = c.Get(key, 1) //nolint:errcheck,gosec // see above |
| 157 | + }() |
| 158 | + time.Sleep(100 * time.Millisecond) |
| 159 | + |
| 160 | + close(release) |
| 161 | + wg.Wait() |
| 162 | +} |
0 commit comments