Skip to content

Fix indexwatch send on closed channel panic on watch-stream reconnect#887

Merged
phinze merged 1 commit into
mainfrom
phinze/mir-1307-index-watch-send-on-closed-channel-panic-on-watch-stream
Jul 6, 2026
Merged

Fix indexwatch send on closed channel panic on watch-stream reconnect#887
phinze merged 1 commit into
mainfrom
phinze/mir-1307-index-watch-send-on-closed-channel-panic-on-watch-stream

Conversation

@phinze

@phinze phinze commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Garden hard-crashed with panic: send on closed channel coming out of the indexwatch abstraction during a watch-stream reconnect. With garden restarting repeatedly as PRs deployed, the reconnect/teardown path got hammered and this race surfaced as a whole-process fatal.

The root cause is that the Watcher's updates channel has two senders that don't know about each other. The run goroutine delivers snapshots on it directly, and the RPC dispatch goroutine delivers live watch events on it by invoking the WatchIndex callback we registered. But run also owns the close: it does a defer close(w.updates) when it tears down. The catch is that the RPC callback's lifetime isn't bounded by the WatchIndex call returning (the RPC layer doesn't join in-flight handler goroutines when the call unwinds), so on Stop or a reconnect a callback can still be sitting in send at the exact moment run closes the channel underneath it.

The send already had a select on ctx.Done(), which looks like it should cover this, but it doesn't. Once a channel is closed, its send case becomes permanently ready (a send on a closed channel panics rather than blocks), and Go's select picks randomly among ready cases. So even with the context already cancelled, select would still choose the closed-channel send about half the time and panic.

The fix serializes send and the close behind a mutex plus a closed flag, so a check-and-send is atomic with respect to the close and a late callback can never touch a closed channel. We deliberately kept closing the channel on Stop rather than taking the "don't close from the producer" route, because both real consumers (dns, ipalloc) and TestWatcher_StopClosesUpdates rely on that contract.

There's a new white-box regression test that reproduces the panic (it fails without the guard) plus a -race stress test over concurrent send/close.

Closes MIR-1307

The Watcher's updates channel had two unsynchronized senders: the run
goroutine that delivers snapshots, and the RPC dispatch goroutine that
invokes our WatchIndex callback for live events. The run goroutine also
owned the close (a deferred close(w.updates) on teardown), but the RPC
callback's lifetime isn't bounded by the WatchIndex call returning, so
on Stop or reconnect a callback could still be mid-send when run closed
the channel underneath it, panicking the whole process.

The existing select-with-ctx.Done guard didn't help: once the channel
is closed, the send case is permanently ready (it panics rather than
blocks), and select picks randomly among ready cases, so it hit the
closed channel roughly half the time even with the context already
cancelled.

Serialize send and close with a mutex and a closed flag so a late
callback can never touch a closed channel. We keep closing the channel
on Stop rather than dropping the close, since consumers and a test rely
on that contract.
@phinze phinze requested a review from a team as a code owner July 6, 2026 20:29
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9bc8cb2-ed21-4fb1-9de1-153726963ab4

📥 Commits

Reviewing files that changed from the base of the PR and between f5adf99 and 8c7e86c.

📒 Files selected for processing (2)
  • pkg/entity/indexwatch/watcher.go
  • pkg/entity/indexwatch/watcher_internal_test.go

📝 Walkthrough

Walkthrough

Changes

The Watcher type in pkg/entity/indexwatch/watcher.go adds a mutex and a closed flag to coordinate closure of its updates channel with in-flight sends. The send method now locks the mutex and checks the closed flag before sending, holding the lock across the send/context-cancellation select. A new closeUpdates method locks the mutex, sets closed to true, and closes the channel; the watch goroutine's deferred cleanup now calls closeUpdates instead of closing the channel directly. A new test file adds two tests verifying safe behavior when sending after close and under concurrent send/close operations.

Sequence Diagram(s)

Included in the hidden review stack artifact above.

Estimated code review effort: 3/5 (concurrency-sensitive locking logic requiring careful review of race conditions)

Related issues: None specified.

Related PRs: None specified.

Suggested labels: bug, concurrency, indexwatch

Suggested reviewers: None specified.

🐰 A mutex guards the gate so tight,
No stray send slips through the night,
Close and send now hand in hand,
Race conditions, safely planned.


Comment @coderabbitai help to get the list of available commands.

@phinze phinze merged commit 9829b72 into main Jul 6, 2026
17 checks passed
@phinze phinze deleted the phinze/mir-1307-index-watch-send-on-closed-channel-panic-on-watch-stream branch July 6, 2026 20:43
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.

2 participants