Skip to content

fix(sync): avoid send on closed channel in fileinfo watcher Close#1992

Open
anxkhn wants to merge 1 commit into
open-feature:mainfrom
anxkhn:fix/fileinfo-watcher-close-after-goroutine-stop
Open

fix(sync): avoid send on closed channel in fileinfo watcher Close#1992
anxkhn wants to merge 1 commit into
open-feature:mainfrom
anxkhn:fix/fileinfo-watcher-close-after-goroutine-stop

Conversation

@anxkhn

@anxkhn anxkhn commented Jul 10, 2026

Copy link
Copy Markdown

The FILEINFO file watcher (core/pkg/sync/file/fileinfo_watcher.go) can crash flagd with panic: send on closed channel at shutdown.

The watcher's timer goroutine is the sole sender on evChan and erChan, but Close() closed both channels without coordinating with that goroutine. If the ticker fires while Close() runs, the goroutine can be mid-send on evChan and hit the panic. The realistic trigger is filepath Sync() returning on context cancel: its defer fs.watcher.Close() runs while the same context is stopping the timer goroutine, so a send can still be in flight exactly as the channel is closed.

This only affects the non-default fileinfo watcher (selected by watchType == "fileinfo"); the default fsnotify watcher is unaffected. The window is narrow, but the panic is unrecovered and takes the process down.

Fix

Close() now stops the timer goroutine before it closes the channels the goroutine sends on:

  • Add a done channel (closed once, guarded by a sync.Once) and a sync.WaitGroup to fileInfoWatcher.
  • Close() signals done, waits for the goroutine to exit (wg.Wait()), and only then closes evChan/erChan, so no send can race with the close.
  • done is also added to the timer goroutine's select and to every send in update(), so an in-flight send is aborted promptly and shutdown cannot deadlock on a full, unread buffer.

No happy-path behavior changes.

Changes

  • core/pkg/sync/file/fileinfo_watcher.go: add done/stopOnce/wg; make Close() stop-then-close; make the timer goroutine and the three sends (erChan in run, both evChan sends in update) abortable on done.
  • core/pkg/sync/file/fileinfo_watcher_test.go: add Test_fileInfoWatcher_Close_racesWithTimer, which drives the timer at a fast tick with a stat func that emits an event every tick, then calls Close() with no reader draining the channels. It panics before this change and passes after. makeTestWatcher gains the done field so the existing close test keeps compiling.

Notes

  • make test-core (-race), golangci-lint v2.7.2, and gofmt all pass locally; core/pkg/sync/file coverage is ~69.9% and the full core/pkg/... -race suite has no regressions.
  • The new test is red -> green: reverting only the Close/run/update coordination (keeping the field so it compiles) reproduces the exact panic: send on closed channel; with the fix it is race-clean across repeated runs.

The FILEINFO file watcher's timer goroutine is the sole sender on evChan
and erChan, but Close() closed both channels without coordinating with
that goroutine. If the ticker fired while Close ran (for example when
filepath Sync returns on context cancel and its deferred watcher.Close()
executes), the goroutine could be mid-send on evChan and hit
"panic: send on closed channel", crashing flagd. This only affects the
non-default fileinfo watcher; the default fsnotify watcher is unaffected.

Give the watcher a done channel (guarded by a sync.Once) and a
sync.WaitGroup. Close now signals the goroutine to stop and aborts any
in-flight send, waits for the goroutine to exit, and only then closes the
channels, so no send can race with the close. The stop signal is also
added to the run loop's select and to every send in update so shutdown is
prompt and cannot deadlock on a full buffer.

Adds a regression test that drives the timer at a fast tick with a stat
func that emits an event every tick, then calls Close with no reader
draining the channels; it panics before this change and passes after.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
@anxkhn anxkhn requested review from a team as code owners July 10, 2026 19:40
@netlify

netlify Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit c7c4db7
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/6a514aaf77a1160008f9849d

@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 10, 2026
@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The file watcher now coordinates timer goroutine shutdown with a completion signal, idempotent close protection, and a wait group before closing channels. Event and error sends can abort during shutdown, and a race test covers closing while timer events are being emitted.

Changes

File watcher shutdown

Layer / File(s) Summary
Coordinate watcher shutdown
core/pkg/sync/file/fileinfo_watcher.go
The watcher initializes shutdown state, makes Close() idempotent, waits for the timer goroutine to exit, and guards event and error sends against shutdown.
Validate shutdown race
core/pkg/sync/file/fileinfo_watcher_test.go
A test closes a busy 1ms timer watcher and drains its channels to verify safe goroutine termination and channel closure.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main fix: preventing a send-on-closed-channel race in fileinfo watcher Close().
Description check ✅ Passed The description accurately explains the shutdown race, the fix, and the regression test, all of which match the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
core/pkg/sync/file/fileinfo_watcher_test.go (1)

45-58: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make the race setup deterministic.

The 512-slot buffer cannot be assumed to fill during a 20 ms sleep, so the test may never reach the documented blocked-send state. Use an unbuffered channel and signal when statFunc is entered instead of sleeping.

Proposed test setup
 watcher := makeTestWatcher(t, map[string]fs.FileInfo{
     "foo": &mockFileInfo{name: "foo", modTime: time.Now()},
 })
+watcher.evChan = make(chan fsnotify.Event)
+statCalled := make(chan struct{})

 watcher.statFunc = func(_ string) (fs.FileInfo, error) {
+    close(statCalled)
     return &mockFileInfo{name: "foo", modTime: time.Now().Add(time.Hour)}, nil
 }

 watcher.run(context.Background(), time.Millisecond)
-time.Sleep(20 * time.Millisecond)
+<-statCalled
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/file/fileinfo_watcher_test.go` around lines 45 - 58, Make
Test_fileInfoWatcher_Close_racesWithTimer deterministic by replacing the
watcher’s event channel with an unbuffered channel and adding a synchronization
signal from statFunc when it is entered. Wait on that signal before invoking the
close/race logic, rather than relying on the fixed 20 ms sleep, ensuring the
goroutine reaches the blocked send state described by the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/pkg/sync/file/fileinfo_watcher_test.go`:
- Around line 60-70: Add a second call to fileInfoWatcher.Close() in the test
after the initial close succeeds, and assert it returns nil without panicking.
Keep the existing channel-closure assertions to verify repeated Close remains
idempotent.

In `@core/pkg/sync/file/fileinfo_watcher.go`:
- Around line 55-65: Make fileInfoWatcher.Close fully idempotent by moving the
done signal, f.wg.Wait(), and both channel closures inside the existing
f.stopOnce.Do callback; subsequent Close calls must perform no operations and
must not panic.

---

Nitpick comments:
In `@core/pkg/sync/file/fileinfo_watcher_test.go`:
- Around line 45-58: Make Test_fileInfoWatcher_Close_racesWithTimer
deterministic by replacing the watcher’s event channel with an unbuffered
channel and adding a synchronization signal from statFunc when it is entered.
Wait on that signal before invoking the close/race logic, rather than relying on
the fixed 20 ms sleep, ensuring the goroutine reaches the blocked send state
described by the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a36fd104-91af-49d3-9555-523d7e7c97f5

📥 Commits

Reviewing files that changed from the base of the PR and between d99a3e3 and c7c4db7.

📒 Files selected for processing (2)
  • core/pkg/sync/file/fileinfo_watcher.go
  • core/pkg/sync/file/fileinfo_watcher_test.go

Comment on lines +60 to +70
if err := watcher.Close(); err != nil {
t.Errorf("fileInfoWatcher.Close() error = %v", err)
}

// Close must have stopped the timer goroutine and closed both channels;
// draining the (buffered) events chan blocks forever unless it is closed
for range watcher.Events() {
}
if _, ok := <-watcher.Errors(); ok {
t.Error("fileInfoWatcher.Close() failed to close error chan")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cover repeated Close() calls.

The implementation claims idempotency, but this test only closes once and therefore misses the double-close panic at Lines 64-65 of fileinfo_watcher.go.

Proposed coverage
 if err := watcher.Close(); err != nil {
     t.Errorf("fileInfoWatcher.Close() error = %v", err)
 }
+if err := watcher.Close(); err != nil {
+    t.Errorf("second fileInfoWatcher.Close() error = %v", err)
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := watcher.Close(); err != nil {
t.Errorf("fileInfoWatcher.Close() error = %v", err)
}
// Close must have stopped the timer goroutine and closed both channels;
// draining the (buffered) events chan blocks forever unless it is closed
for range watcher.Events() {
}
if _, ok := <-watcher.Errors(); ok {
t.Error("fileInfoWatcher.Close() failed to close error chan")
}
if err := watcher.Close(); err != nil {
t.Errorf("fileInfoWatcher.Close() error = %v", err)
}
if err := watcher.Close(); err != nil {
t.Errorf("second fileInfoWatcher.Close() error = %v", err)
}
// Close must have stopped the timer goroutine and closed both channels;
// draining the (buffered) events chan blocks forever unless it is closed
for range watcher.Events() {
}
if _, ok := <-watcher.Errors(); ok {
t.Error("fileInfoWatcher.Close() failed to close error chan")
}
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 66-67: Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=open-feature_flagd&issues=AZ9NjFFifbPghhCac1a8&open=AZ9NjFFifbPghhCac1a8&pullRequest=1992

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/file/fileinfo_watcher_test.go` around lines 60 - 70, Add a
second call to fileInfoWatcher.Close() in the test after the initial close
succeeds, and assert it returns nil without panicking. Keep the existing
channel-closure assertions to verify repeated Close remains idempotent.

Comment on lines +55 to 65
// Close stops the timer goroutine and closes the event and error channels
func (f *fileInfoWatcher) Close() error {
// close all channels and exit
// signal the timer goroutine to stop and abort any in-flight send, then wait
// for it to exit before closing the channels it sends on. Closing before the
// goroutine has stopped would race with its sends -> send on closed channel.
f.stopOnce.Do(func() {
close(f.done)
})
f.wg.Wait()
close(f.evChan)
close(f.erChan)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Include channel closure inside stopOnce.

A second Close() still panics because Lines 64-65 execute on every call. Put signaling, waiting, and channel closure inside stopOnce.Do to make the complete operation idempotent.

Proposed fix
 f.stopOnce.Do(func() {
     close(f.done)
+    f.wg.Wait()
+    close(f.evChan)
+    close(f.erChan)
 })
-f.wg.Wait()
-close(f.evChan)
-close(f.erChan)
 return nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Close stops the timer goroutine and closes the event and error channels
func (f *fileInfoWatcher) Close() error {
// close all channels and exit
// signal the timer goroutine to stop and abort any in-flight send, then wait
// for it to exit before closing the channels it sends on. Closing before the
// goroutine has stopped would race with its sends -> send on closed channel.
f.stopOnce.Do(func() {
close(f.done)
})
f.wg.Wait()
close(f.evChan)
close(f.erChan)
// Close stops the timer goroutine and closes the event and error channels
func (f *fileInfoWatcher) Close() error {
// signal the timer goroutine to stop and abort any in-flight send, then wait
// for it to exit before closing the channels it sends on. Closing before the
// goroutine has stopped would race with its sends -> send on closed channel.
f.stopOnce.Do(func() {
close(f.done)
f.wg.Wait()
close(f.evChan)
close(f.erChan)
})
return nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/file/fileinfo_watcher.go` around lines 55 - 65, Make
fileInfoWatcher.Close fully idempotent by moving the done signal, f.wg.Wait(),
and both channel closures inside the existing f.stopOnce.Do callback; subsequent
Close calls must perform no operations and must not panic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant