fix: bound component shutdown against unreachable peers (SYN-110) - #751
Conversation
deleteFunc reached two detached contexts — MarkTreeDeleted and the settings object AddContent broadcast — so a delete in flight against unreachable nodes never returned and deleteLoop.Close blocked until the app.Close watchdog panicked. Plumb the delete ctx through both, bail out of the queued/bound-child loops once it is cancelled, and bound the Close wait with a timeout and warning so a treemanager that ignores ctx can no longer wedge shutdown.
Two more instances of the deletionmanager hang. concurrentLimiter.Close waited on the WaitGroup with no bound while the scheduled goroutine was inside a peer sync that only checks ctx before it starts. syncAcl broadcast under context.Background while holding the acl lock, which Close then waits for, so an acl record arriving against unreachable nodes wedged Close forever. Bound the limiter wait with a timeout and the close ctx, give syncAcl a cancellable broadcast ctx, and cancel it before Close takes the lock.
actionPool created a cancel func and never called it, so every queued action ran under an effectively uncancellable ctx and kept issuing peer requests after close — it is also the ctx a syncTree holds its lock under, which made settings.Close unrecoverable. app.Start's closeServices closes components whose Run never executed, which deadlocked subscribeClient.Close on a channel only streamWatcher closes and nil-dereferenced actionPool.periodicLoop and streamPool.dial. Guard all three.
ocache.Close waited with context.Background on entries the gc was already closing, and that gc sits in peer.TryClose -> drpc conn Close -> a yamux stream whose peer never sends FIN, which yamux bounds at 5 minutes — past the app close deadline. Give Close one deadline for the whole pass; an entry still held by another closer is left to it instead of blocking shutdown. peer.gc closed those conns while holding p.mu, freezing AcquireDrpcConn for the same peer; close them after the lock is released. Set the yamux stream close timeout from the write timeout, and bound the webtransport dial with DialTimeoutSec, which until now applied only to the accept path.
ReviewReviewed with two independent passes plus verification of every claim against a running repro. The direction is right, but the first version had two regressions that were worse than the hang it fixed. Both are now fixed in a7ee564 with tests that fail against the previous code. Fixed during review1. Fixed by rejecting requests once closed. 2. Fixed by spending the deadline only on the wait-for-another-closer, with loads on an uncancellable ctx ( 3. Confirmed sound
Open, deliberately not addressed here
VerdictMergeable once the reviewer agrees on (1) — the additive budgets are the difference between "bounded" and "bounded per component but not overall". Everything else above is follow-up. |
concurrentLimiter.Close returning on timeout left a waiter parked on the WaitGroup; a later ScheduleRequest panicked with "WaitGroup is reused before previous Wait has returned", and SyncWithPeer stays callable after close. Reject requests once closed. ocache.Close passed its deadline to waitLoad too, where an expired ctx and a finished load are both ready and select picks at random — roughly half the healthy entries returned early and were never closed. Spend the deadline only on entries another closer holds. deleteLoop.Close burned the full timeout when Run never ran.
80585bb to
d1c41ba
Compare
Fixes SYN-110, plus the other instances of the same failure mode found by auditing the repo for it.
The bug
deleteLoop.ClosediddeleteCancel(); <-loopDone, butdeleteFunc's call graph dropped the ctx twice —MarkTreeDeleted(context.Background(), …)and the settings object'sAddContent(context.Background(), …). The latter reachessyncClient.Broadcast→peerManager.BroadcastMessage, which dials responsible peers and never returns against blackholed nodes. Cancelling did nothing,Closeblocked forever, and theapp.Closewatchdog panicked withapp.Close timeout.Broadcast failures there are only logged, so honouring the ctx cannot fail a delete that is already committed locally.
Other instances
concurrentLimiter.Closewaited on its WaitGroup with no bound while the scheduled goroutine was inside a peer sync; its only ctx check happens before the action starts.syncAcl.broadcastran undercontext.Background()while holding the acl lock, whichsyncAcl.Closethen waits for. It also gatesspace.Description,SyncWithPeerand everyaclSpaceClientcall.actionPoolbuilt a cancel func and never called it, so every queued action ran under an effectively uncancellable ctx — also the ctx asyncTreeholds its lock under, which is what madesettings.Closeunrecoverable.ocache.Closewaited withcontext.Background()on entries the gc was already closing; that gc sits inpeer.TryClose→ drpc connClose→ a yamux stream whose peer never sends FIN, which yamux bounds at 5 minutes — past the app close deadline.peer.gcclosed those conns while holdingp.mu, freezingAcquireDrpcConnfor the same peer.StreamCloseTimeout(library default 5 min), and the webtransport dial was unbounded —DialTimeoutSecapplied only to the accept path.app.Start'scloseServicesruns during the Init loop, so components get closed having never run. That deadlockedsubscribeClient.Closeon a channel onlystreamWatchercloses, stalleddeleteLoop.Closefor its full timeout, and nil-dereferencedactionPool.periodicLoopandstreamPool.dial.Approach
Cancellable contexts where the call graph is ours; a timeout plus the close ctx where it crosses into client-implemented interfaces (
treemanager,peermanager) that may ignore cancellation. Shutdown may now leave work unfinished, which is the intent: the state is persisted and retried on the next run.Because a bounded
Closereturns while the abandoned work is still running,concurrentLimiterrejects requests once closed —SyncWithPeerstays callable after close, and anAddlanding on a WaitGroup that still has a parked waiter panics withWaitGroup is reused before previous Wait has returned.ocacheis deliberately conservative. The GO-7332 invariant — exactly one remover owns and closes a given close channel — holds: the ctx escape only removes a waiter and never takes ownership, andremovereturns before touchinge.value.Close()orsetClosed. The behaviour change is confined toClose(); every other caller passes its own ctx and is unchanged. The deadline is spent only on entries another closer holds: loads are cancelled before the pass andvalue.Close()takes no ctx, so uncontended entries still close normally once it has expired, and total close time still scales with cache size.Testing
New regression tests for each bounded close and each close-before-run path, including the two failure modes above — each fails against the code it replaces. Full suite green,
-racegreen acrosscommonspace,util,app,coordinator,acl,net.Pre-existing and untouched:
net/transport/webtransportfails under-raceon a cleanv0.13.xtoo — the race is insidewt.ServerbetweenClose()andServe().Not addressed
commonspace/space.go:250passescontext.Background(), so everyctx.Done()arm here is dead in production and only the timers apply. Three spaces closing serially reachapp.StopDeadline(60s). The fix is a single space-level budget atspace.go:250with these timers as backstops, which changes shutdown semantics for all ~15 space components.syncAcl.Closestill takes an unboundeds.Lock(). Cancelling the broadcast ctx unblocks it only if the client'speerManagerhonours cancellation.settingsObject.addContentwidens an existing torn-write window.AddContentWithValidatoradvances the in-memory tree before persisting, so a cancelled client ctx can leave a head that was never written. AnyAddAllfailure already does this; ctx cancellation is a new trigger.ocacheitself expose config or options for the equivalent knobs, and yamux'sStreamCloseTimeoutborrowsWriteTimeoutSecrather than having its own field.🤖 Generated with Claude Code