Repository navigation
fix(mount): join background writers before shutdown #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1805,6 +1805,11 @@ type Syncer struct { | |
| localMutationMu sync.Mutex | ||
| websocket bool | ||
| rootCtx context.Context | ||
| backgroundCtx context.Context | ||
| backgroundCancel context.CancelFunc | ||
| backgroundMu sync.Mutex | ||
| backgroundClosed bool | ||
| backgroundWG sync.WaitGroup | ||
| wsConn *websocket.Conn | ||
| wsCancel context.CancelFunc | ||
| wsNextAttempt time.Time | ||
|
|
@@ -2633,6 +2638,10 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { | |
| } else if _, ok := client.(*HTTPClient); ok { | ||
| cache = defaultObjectCache() | ||
| } | ||
| // Create the owned lifecycle context only after every constructor path that | ||
| // can still return an error. From here it is transferred directly to the | ||
| // Syncer and released by Close. | ||
| backgroundCtx, backgroundCancel := context.WithCancel(rootCtx) | ||
| return &Syncer{ | ||
| client: client, | ||
| objectCache: cache, | ||
|
|
@@ -2653,6 +2662,8 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { | |
| websocket: websocketEnabled, | ||
| recoverStartupDrift: true, | ||
| rootCtx: rootCtx, | ||
| backgroundCtx: backgroundCtx, | ||
| backgroundCancel: backgroundCancel, | ||
| logger: opts.Logger, | ||
| denialLogPath: filepath.Join(localRoot, ".relay", "permissions-denied.log"), | ||
| bulkFlushThreshold: bulkFlushThreshold, | ||
|
|
@@ -2693,6 +2704,60 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) { | |
| }, nil | ||
| } | ||
|
|
||
| // Close cancels and joins every deferred receipt and checkpoint writer owned by | ||
| // the Syncer. Callers must stop admitting foreground work before calling Close. | ||
| // Close is idempotent and does not return until no owned background task can | ||
| // write beneath the mount root. | ||
| func (s *Syncer) Close() { | ||
| if s == nil { | ||
| return | ||
| } | ||
| s.backgroundMu.Lock() | ||
| if !s.backgroundClosed { | ||
| s.backgroundClosed = true | ||
| if s.backgroundCancel != nil { | ||
| s.backgroundCancel() | ||
| } | ||
| } | ||
| s.backgroundMu.Unlock() | ||
| // A websocket reader owns a second receive goroutine and may be blocked in | ||
| // the transport even after its context is canceled. Closing the connection | ||
| // wakes that read; both goroutines are joined through backgroundWG below. | ||
| s.ResetWebSocket() | ||
|
|
||
| // Prevent a pending checkpoint timer from becoming a writer after shutdown. | ||
| // Timers whose callbacks already started remain counted in backgroundWG and | ||
| // are joined below. | ||
| s.checkpointMu.Lock() | ||
| s.checkpointVersion++ | ||
| if s.checkpointTimer != nil && s.checkpointTimer.Stop() { | ||
| s.backgroundWG.Done() | ||
| } | ||
| s.checkpointTimer = nil | ||
| s.checkpointStarted = time.Time{} | ||
| s.checkpointMu.Unlock() | ||
|
|
||
| s.backgroundWG.Wait() | ||
| } | ||
|
|
||
| func (s *Syncer) startBackground(run func(context.Context)) bool { | ||
| s.backgroundMu.Lock() | ||
| defer s.backgroundMu.Unlock() | ||
| if s.backgroundClosed { | ||
| return false | ||
| } | ||
| ctx := s.backgroundCtx | ||
| if ctx == nil { | ||
| ctx = context.Background() | ||
| } | ||
| s.backgroundWG.Add(1) | ||
| go func() { | ||
| defer s.backgroundWG.Done() | ||
| run(ctx) | ||
| }() | ||
| return true | ||
| } | ||
|
|
||
| // Circuit returns the cloud-error breaker for tests and status reporters. | ||
| func (s *Syncer) Circuit() *CloudErrorCircuit { return s.circuit } | ||
|
|
||
|
|
@@ -4382,16 +4447,12 @@ func (s *Syncer) scheduleOutboxReceiptSettlements(records []outboxRecord) { | |
| sem := s.receiptSem | ||
| s.receiptMu.Unlock() | ||
|
|
||
| go func() { | ||
| started := s.startBackground(func(root context.Context) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
ast-grep run --pattern 'func (s *Syncer) applyOutboxOperationResult($$$) $_ { $$$ }' --lang go internal/mountsync
rg -nP 'context\.Canceled|ctx\.Err\(\)|Attempt' internal/mountsync/syncer.go | head -80Repository: AgentWorkforce/relayfile Length of output: 5900 🏁 Script executed: #!/bin/bash
sed -n '4040,4465p' internal/mountsync/syncer.go
printf '\n--- Close/background context references ---\n'
rg -n -C 8 'func \(s \*Syncer\) Close|backgroundCtx|startBackground|GetOperation|applyOutboxOperationResult|markSyncError' internal/mountsync/syncer.go | head -260Repository: AgentWorkforce/relayfile Length of output: 25524 🏁 Script executed: sed -n '4040,4465p' internal/mountsync/syncer.go; printf '\n--- bindings ---\n'; rg -n -C 8 'func \(s \*Syncer\) Close|backgroundCtx|startBackground|GetOperation|applyOutboxOperationResult|markSyncError' internal/mountsync/syncer.go | head -260Repository: AgentWorkforce/relayfile Length of output: 25497 🏁 Script executed: #!/bin/bash
sed -n '4460,4525p' internal/mountsync/syncer.go
printf '\n--- attempt update ---\n'
sed -n '2145,2225p' internal/mountsync/syncer.goRepository: AgentWorkforce/relayfile Length of output: 5245 🏁 Script executed: #!/bin/bash
rg -n -A45 -B8 'func \(s \*Syncer\) incrementOutboxAttempt' internal/mountsync/syncer.goRepository: AgentWorkforce/relayfile Length of output: 162 Do not record shutdown cancellation as a failed receipt. When Suggested fix ctx, cancel := context.WithTimeout(root, timeout)
op, opErr := s.client.GetOperation(ctx, s.workspace, opID)
cancel()
+ if root.Err() != nil {
+ // Shutdown cancellation is not a remote receipt outcome.
+ return
+ }
s.mu.Lock()🤖 Prompt for AI Agents |
||
| defer func() { | ||
| s.receiptMu.Lock() | ||
| delete(s.receiptActive, opID) | ||
| s.receiptMu.Unlock() | ||
| }() | ||
| root := s.rootCtx | ||
| if root == nil { | ||
| root = context.Background() | ||
| } | ||
| select { | ||
| case sem <- struct{}{}: | ||
| defer func() { <-sem }() | ||
|
|
@@ -4430,7 +4491,12 @@ func (s *Syncer) scheduleOutboxReceiptSettlements(records []outboxRecord) { | |
| // derived state document instead of making every receipt worker hold | ||
| // the main state mutex through a full outbox summary scan. | ||
| s.scheduleLocalWriteCheckpoint() | ||
| }() | ||
| }) | ||
| if !started { | ||
| s.receiptMu.Lock() | ||
| delete(s.receiptActive, opID) | ||
| s.receiptMu.Unlock() | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -4452,9 +4518,21 @@ func (s *Syncer) scheduleLocalWriteCheckpoint() { | |
| delay = time.Nanosecond | ||
| } | ||
| if s.checkpointTimer != nil { | ||
| s.checkpointTimer.Stop() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shutdown persists canceled receipt failuresMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 79555e4. Configure here. |
||
| if s.checkpointTimer.Stop() { | ||
| s.backgroundWG.Done() | ||
| } | ||
| } | ||
| s.backgroundMu.Lock() | ||
| if s.backgroundClosed { | ||
| s.backgroundMu.Unlock() | ||
| s.checkpointTimer = nil | ||
| s.checkpointStarted = time.Time{} | ||
| return | ||
| } | ||
| s.backgroundWG.Add(1) | ||
| s.backgroundMu.Unlock() | ||
| s.checkpointTimer = time.AfterFunc(delay, func() { | ||
| defer s.backgroundWG.Done() | ||
| s.checkpointMu.Lock() | ||
| if s.checkpointVersion != version { | ||
| s.checkpointMu.Unlock() | ||
|
|
@@ -4464,6 +4542,7 @@ func (s *Syncer) scheduleLocalWriteCheckpoint() { | |
| s.checkpointStarted = time.Time{} | ||
| s.checkpointMu.Unlock() | ||
|
|
||
| s.runCheckpointTestHook("local-write-checkpoint-before-save") | ||
| s.mu.Lock() | ||
| // Match the WebSocket checkpoint contract: persist only the private | ||
| // recovery cursor/state on the burst path. The public .relay/state.json | ||
|
|
@@ -5487,7 +5566,7 @@ func (s *Syncer) connectWebSocket(ctx context.Context) error { | |
| } | ||
| conn.SetReadLimit(maxWebSocketMessageBytes) | ||
|
|
||
| readCtx, cancel := context.WithCancel(s.rootCtx) | ||
| readCtx, cancel := context.WithCancel(s.backgroundCtx) | ||
|
|
||
| s.mu.Lock() | ||
| if s.wsGeneration != generation || s.wsConn != nil { | ||
|
|
@@ -5507,16 +5586,25 @@ func (s *Syncer) connectWebSocket(ctx context.Context) error { | |
| s.wsLastConnectedAt = time.Now().UTC() | ||
| s.mu.Unlock() | ||
|
|
||
| go s.readWebSocketLoop(readCtx, conn) | ||
| if !s.startBackground(func(context.Context) { | ||
| s.readWebSocketLoop(readCtx, conn) | ||
| }) { | ||
| cancel() | ||
| s.ResetWebSocket() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (s *Syncer) readWebSocketLoop(ctx context.Context, conn *websocket.Conn) { | ||
| defer s.handleWebSocketDisconnect(conn) | ||
| ctx, cancelReader := context.WithCancel(ctx) | ||
|
|
||
| eventCh := make(chan websocketEvent, webSocketApplyQueueSize) | ||
| readErrCh := make(chan error, 1) | ||
| var readerWG sync.WaitGroup | ||
| readerWG.Add(1) | ||
| go func() { | ||
| defer readerWG.Done() | ||
| for { | ||
| var event websocketEvent | ||
| if err := wsjson.Read(ctx, conn, &event); err != nil { | ||
|
|
@@ -5533,6 +5621,13 @@ func (s *Syncer) readWebSocketLoop(ctx context.Context, conn *websocket.Conn) { | |
| } | ||
| } | ||
| }() | ||
| defer func() { | ||
| // The apply loop can stop before the transport read does (for example, | ||
| // after a persistence failure). Cancel first, then join, so the nested | ||
| // reader cannot survive the lifecycle owner or deadlock its return path. | ||
| cancelReader() | ||
| readerWG.Wait() | ||
| }() | ||
|
|
||
| var checkpointTimer *time.Timer | ||
| var checkpointC <-chan time.Time | ||
|
|
@@ -6167,7 +6262,9 @@ func (s *Syncer) bootstrapContext(parent context.Context) (context.Context, cont | |
| pollEvery = 10 * time.Millisecond | ||
| } | ||
| done := make(chan struct{}) | ||
| stopped := make(chan struct{}) | ||
| go func() { | ||
| defer close(stopped) | ||
| ticker := time.NewTicker(pollEvery) | ||
| defer ticker.Stop() | ||
| for { | ||
|
|
@@ -6192,6 +6289,7 @@ func (s *Syncer) bootstrapContext(parent context.Context) (context.Context, cont | |
| wrapped := func() { | ||
| close(done) | ||
| cancel() | ||
| <-stopped | ||
| } | ||
| return ctx, wrapped, prog, nil | ||
| } | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Shutdown exhausts pending receipt retries
When
Closecancels an in-flight receipt request, the worker records that cancellation as a failed attempt. Repeated mount restarts can exhaust retries and leave an accepted writeback requiring manual attention.(Refers to this code)
Learn more
A deferred receipt worker polls an already accepted writeback operation.
Closecancels its context, but the worker still passes the resultingcontext.Cancelederror to applyOutboxOperationResult. That path calls incrementOutboxAttempt, which persists a retry delay and eventually setsNeedsAttention. The new mount-loop defer also callsCloseon exits where the root context remains live, so shutdown now triggers this path even without a canceled root context.Example: An operation remains pending through several short-lived mount runs. Each exit cancels its active receipt GET and records another failed attempt; eventually the mount stops retrying that operation despite its successful dispatch.
Recommended fix: After
GetOperation, exit the worker without settling or modifying the durable outbox if its background context was canceled. Preserve actual transport failures while the syncer remains open, and add a test with a context-aware blocked GET verifying shutdown leavesAttemptCount,NeedsAttention, andNextAttemptAtunchanged.Was this helpful? React with 👍 or 👎 to provide feedback.