Skip to content

Deadlock in Inflight.NextImmediate when a publisher races the resend loop - #174

Open
goingforstudying-ctrl wants to merge 1 commit into
wind-c:mainfrom
goingforstudying-ctrl:fix/inflight-resend-deadlock
Open

goingforstudying-ctrl wants to merge 1 commit into
wind-c:mainfrom
goingforstudying-ctrl:fix/inflight-resend-deadlock

Conversation

@goingforstudying-ctrl

Copy link
Copy Markdown
Contributor

Was load-testing a broker with a bunch of qos1 publishers against one slow subscriber and the subscriber's read loop wedged solid. Goroutine dump showed the client stuck in Inflight.NextImmediate and a publisher stuck in Inflight.Set, neither making progress.

Turns out NextImmediate takes RLock and then calls GetAll, which takes RLock again. Go's RWMutex blocks new readers as soon as a writer is queued, so if a publisher's Set lands between those two read locks the goroutine holds the first lock forever and everything behind it (the whole per-client packet loop in processPacket, which hits the resend block at server.go:714 whenever inflight is non-empty and send quota is free) just hangs.

While staring at that code two more things looked off:

  • The quota helpers do load-then-add (if Load() > 0 { Add(-1) }), which isn't atomic. Publishers targeting the same subscriber run on different goroutines, so the send quota can drift below zero or above the max. A stress test with 32 goroutines pushed it to 65 with a max of 64.
  • GetAll sorts by uint16(Created), but Created is an int64 unix timestamp. The truncation wraps every ~18 hours, so resend-on-reconnect ordering scrambles across the boundary. Demo: packets created at 65534..65537 come back as 65536, 65537, 65534, 65535.

Changes, all in mqtt/inflight.go:

  • split GetAll into a locking wrapper plus getAllNoLock; NextImmediate uses the no-lock variant under its own RLock, so it only locks once
  • quota inc/dec use CAS loops so 0 <= quota <= max actually holds under concurrency
  • sort compares the int64 timestamps directly

Added regression tests for all three in mqtt/inflight_test.go: a deadlock repro that wedges immediately on the old code (hits the 10s timeout) and finishes in ~0.2s now, a quota storm that overshoots on the old code (65 > 64) and stays bounded now, and a sort test straddling the uint16 wrap boundary.

go test ./mqtt/ -race -count=1 passes (full package, not just the new tests).

Not 100% sure CAS loops are the right call for the quota counters vs just taking the existing mutex — CAS keeps those hot paths lock-free, but I can redo it the other way if you'd rather keep it simple.

NextImmediate took RLock and then called GetAll, which takes RLock
again. Once a writer queued behind the first read lock, Go blocks new
readers, so the second RLock waited on itself and the client's whole
packet loop hung. Split out getAllNoLock so NextImmediate only locks
once.

The quota helpers did load-then-add, which is not atomic. Publishers
writing to the same subscriber run concurrently, so the send quota
could drift below zero or past the max. Use CAS loops instead.

GetAll sorted by uint16(Created), but Created is an int64 unix
timestamp. The truncation wraps every ~18h and scrambles resend order
on reconnect; compare the int64s directly.
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.

1 participant