Skip to content

Commit 6a165b2

Browse files
committed
take Cache.mx before bbolt's writer in WithBatch — publish/fill deadlock
1 parent add3581 commit 6a165b2

3 files changed

Lines changed: 247 additions & 38 deletions

File tree

cmd/apps/skychat/pairing/pair.go

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -256,31 +256,30 @@ func Open(cfg Config) (*Pair, error) {
256256
//
257257
// # Why this hangs off Send instead of a timer
258258
//
259-
// The announcement is a CXO write, and pkg/cxo/skyobject has a
259+
// The announcement is a CXO write, and pkg/cxo/skyobject HAD a
260260
// lock-order inversion between a write and a fill on the SAME node:
261-
// Cache.Get holds Cache.mx and blocks on bbolt's writer lock, while
262-
// Cache.WithBatch (reached only from the publisher's tree walk) holds
263-
// bbolt's writer lock and blocks on Cache.mx. A pair puts its publisher
261+
// Cache.Get held Cache.mx and blocked on bbolt's writer lock, while
262+
// Cache.WithBatch (reached only from the publisher's tree walk) held
263+
// bbolt's writer lock and blocked on Cache.mx. A pair puts its publisher
264264
// and its subscriber on one node by design, so an announcement
265-
// published while the peer's tree is filling can wedge the node
265+
// published while the peer's tree was filling could wedge the node
266266
// permanently — the publish loop stops clearing its dirty flag and
267267
// every later send is silently lost.
268268
//
269269
// Confirmed rather than guessed: with Cache.WithBatch bypassed the
270270
// pairing suite ran 6/6 green, and with it a background announce timer
271271
// hung the suite roughly one run in four.
272272
//
273-
// Riding Send is what makes this safe by construction rather than by
274-
// timing. The Put lands in the same publisher batch as the message, so
273+
// That inversion is fixed — WithBatch now takes Cache.mx before the
274+
// writer, pinned by TestWithBatch_DoesNotInvertCacheAndWriterLocks — so
275+
// a timer is no longer unsafe. Riding Send is kept anyway, on its own
276+
// merits: the Put lands in the same publisher batch as the message, so
275277
// the pair performs exactly as many publishes as it did before this
276-
// feature existed — no new window is opened. The cost is that forward
277-
// secrecy engages once each side has sent at least once (the first
278-
// message from each side goes out under the legacy static key), which
279-
// for a conversation is the normal case.
280-
//
281-
// Removing this workaround is a one-line change once the CXO lock order
282-
// is fixed; the announcement can then go on a timer and cover pairs
283-
// where one side never speaks.
278+
// feature existed, and no timing assumption is involved at all. The
279+
// cost is that forward secrecy engages once each side has sent at least
280+
// once (the first message from each side goes out under the legacy
281+
// static key), which for a conversation is the normal case. A timer
282+
// would only add value for pairs where one side never speaks.
284283
func (p *Pair) maybeAnnounce(now time.Time) {
285284
if p.pub == nil {
286285
return

pkg/cxo/skyobject/cache.go

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,13 @@ type Cache struct {
129129
// Reads happen under c.mx (every Cache write/read path holds it),
130130
// so concurrent Set/Get/Inc callers serialize their tx use through
131131
// the mutex; the bbolt tx is never touched by two goroutines at
132-
// once. WithBatch tears the pin down only after taking c.mx again
133-
// to drain any in-flight CXDS user — see WithBatch for the full
134-
// invariant.
132+
// once.
133+
//
134+
// The pin is established and torn down so that EVERY goroutine
135+
// holding c.mx during the tx's lifetime sees it. That is not a
136+
// nicety: a c.mx holder that misses the pin falls through to the
137+
// unpinned store and blocks on bbolt's writer lock, which the tx
138+
// owns — while the tx owner blocks on c.mx. See WithBatch.
135139
batchTx atomic.Pointer[data.CXDS]
136140
}
137141

@@ -196,34 +200,78 @@ func (c *Cache) db() data.CXDS {
196200
// that one tx — replacing N per-leaf commits with a single one. fn
197201
// returning nil commits the tx; a non-nil return rolls it back.
198202
//
199-
// The pin lives in c.batchTx (atomic). Reads happen under c.mx, so
200-
// concurrent Cache writers/readers from outside fn naturally serialize
201-
// against fn's tx use through the mutex — the bbolt tx is never
202-
// touched by two goroutines at once. The tear-down sequence on the
203-
// way out:
203+
// LOCK ORDER — the reason this function is shaped the way it is.
204+
//
205+
// Two locks are in play: c.mx and bbolt's writer lock (held for the
206+
// whole of DB.Update / DB.Batch). Everywhere else in the Cache the
207+
// order is c.mx → writer: Cache.Get(key, inc != 0) and Cache.Set on a
208+
// miss take c.mx and then reach bbolt, which needs the writer for the
209+
// refcount bump. This function is the one place that would take them
210+
// the other way round — RunBatch opens the tx (writer) and fn then
211+
// calls back into Cache.Set (c.mx) — and that inversion deadlocked:
212+
//
213+
// G1 fill: holds c.mx → waits for the writer (DB.Batch)
214+
// G2 publish: holds the writer → waits for c.mx (Cache.Set in fn)
215+
//
216+
// It wedged the node permanently: the publisher never cleared dirty,
217+
// every later send was silently lost, and teardown hung because the
218+
// cleanup sweep also wants c.mx. Windows CI hit it as a 25-minute
219+
// package timeout in cmd/apps/skychat/pairing, which publishes and
220+
// subscribes on one node by design.
221+
//
222+
// The fix is to make c.mx → writer hold here too, WITHOUT holding c.mx
223+
// across fn (fn's own Cache.Set needs it, and the mutex isn't
224+
// reentrant). Three windows, in order:
225+
//
226+
// 1. Take c.mx BEFORE opening the tx. Anyone already inside the Cache
227+
// — possibly blocked in bbolt — finishes first, so we never open
228+
// the writer underneath a c.mx holder that still needs it.
229+
// 2. Store the pin, then release c.mx. From here every arriving Cache
230+
// op sees the pin and joins our tx, so none of them touches the
231+
// writer while we own it. fn runs in this window.
232+
// 3. Re-take c.mx BEFORE clearing the pin. Taking it is safe (by (2)
233+
// no c.mx holder is waiting on the writer) and it drains anyone
234+
// mid-call on the scoped handle; clearing the pin only afterwards
235+
// means no goroutine can pick the unpinned store up while the tx
236+
// is still open. c.mx is released after RunBatch returns, i.e.
237+
// after the commit.
204238
//
205-
// 1. Store nil into c.batchTx so any newly-arriving Cache.Set sees
206-
// the untwisted CXDS (and will block in bbolt's writer mutex
207-
// until our tx commits, as it would have pre-batch).
208-
// 2. Lock/unlock c.mx once to drain any in-flight Cache.Set that
209-
// captured the now-stale tx pointer before step 1 — they finish
210-
// their work under our serialization while we wait.
211-
// 3. Return; the bbolt tx commits at this point.
239+
// The cost of step (1) is that Cache operations now also queue behind
240+
// however long the writer takes to become free — including a long
241+
// RemoveObjects sweep. Before, an inc==0 read (bbolt View, no writer)
242+
// could slip past. That is latency, not a cycle: nothing that holds the
243+
// writer ever asks for c.mx (RemoveObjects deliberately snapshots
244+
// CachedKeys up front for this reason), so the wait always ends.
212245
//
213246
// fn must not retain the pinned handle past its return — the tx is
214-
// gone afterward.
247+
// gone afterward. WithBatch is not reentrant (step 1 would deadlock
248+
// against itself); it has a single caller, treestore.publishRoot.
215249
func (c *Cache) WithBatch(fn func() error) error {
250+
// (1) Claim the Cache before the writer. RunBatch invokes fn
251+
// synchronously on this goroutine, so this plain bool needs no
252+
// synchronization of its own.
253+
c.mx.Lock()
254+
held := true
255+
unlock := func() {
256+
if held {
257+
held = false
258+
c.mx.Unlock()
259+
}
260+
}
261+
// Covers step (3)'s re-lock, and the case where RunBatch fails
262+
// before ever calling us back.
263+
defer unlock()
264+
216265
return c.c.db.CXDS().RunBatch(func(scoped data.CXDS) (err error) {
217266
c.batchTx.Store(&scoped)
267+
// (2) The pin is visible to everyone who takes c.mx from here
268+
// on; hand the Cache back to them.
269+
unlock()
218270
defer func() {
219-
c.batchTx.Store(nil)
220-
// Wait for any in-flight Cache operation that may still be
221-
// using the scoped CXDS to finish before letting bbolt
222-
// commit. Holding the mutex briefly is sufficient: every
223-
// Cache CXDS call holds c.mx for its duration, so once we
224-
// acquire it nobody else is mid-tx.
271+
// (3) Drain in-flight scoped users, then retire the pin.
225272
c.mx.Lock()
226-
c.mx.Unlock() //nolint:staticcheck // intentional drain
273+
held = true
274+
c.batchTx.Store(nil)
227275
}()
228276
return fn()
229277
})
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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

Comments
 (0)