From c599e784b59e4e7f1a84699d05c464874bb681ee Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Wed, 2 Sep 2026 23:54:02 +0800 Subject: [PATCH] fix(distillfs): prevent duplicate watcher panic Start each daemon watcher with an atomic ownership claim so repeated start requests cannot launch multiple goroutines for the same lifecycle. Capture the completion and stop channels when the watcher starts so its cleanup cannot follow later mutations of the daemon fields. Add a regression test that starts the watcher repeatedly and verifies a single clean shutdown instead of a close-of-closed-channel panic. Signed-off-by: Tianyu Zhou --- pkg/imagemanager/distillfs/daemon.go | 12 ++++++---- pkg/imagemanager/distillfs/daemon_test.go | 28 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/pkg/imagemanager/distillfs/daemon.go b/pkg/imagemanager/distillfs/daemon.go index 91bf049..ec31d56 100644 --- a/pkg/imagemanager/distillfs/daemon.go +++ b/pkg/imagemanager/distillfs/daemon.go @@ -397,16 +397,18 @@ func (d *Daemon) shouldRemount() bool { } func (d *Daemon) startWatch() { - d.watcherActive.Store(true) - go d.watch() + if !d.watcherActive.CompareAndSwap(false, true) { + return + } + go d.watch(d.stopChan, d.kickStop) } -func (d *Daemon) watch() { +func (d *Daemon) watch(stopChan chan struct{}, kickStop *Stopper) { ticker := time.NewTicker(5 * time.Second) defer func() { ticker.Stop() logrus.WithFields(d.daemonLogFields()).Info("daemon exited") - close(d.stopChan) + close(stopChan) // Set watcherActive to false before remount // remount will call startWatch() which sets it back to true d.watcherActive.Store(false) @@ -421,7 +423,7 @@ func (d *Daemon) watch() { if !d.tick() { return } - case <-d.kickStop.Done(): + case <-kickStop.Done(): for d.IsAlive() { time.Sleep(10 * time.Millisecond) } diff --git a/pkg/imagemanager/distillfs/daemon_test.go b/pkg/imagemanager/distillfs/daemon_test.go index 6406d21..a754891 100644 --- a/pkg/imagemanager/distillfs/daemon_test.go +++ b/pkg/imagemanager/distillfs/daemon_test.go @@ -508,6 +508,34 @@ func newMockDaemon(tmpDir string) *mockDaemon { return mock } +func TestDaemon_StartWatchOnlyOnce(t *testing.T) { + tmpDir := t.TempDir() + mock := newMockDaemon(tmpDir) + mock.stopChan = make(chan struct{}) + mock.kickStop = NewStopper() + mock.mockIsAlive = func() bool { return false } + mock.setState(DaemonStateUnmounting) + + for i := 0; i < 10; i++ { + mock.startWatch() + } + mock.kickStop.Close() + + select { + case <-mock.stopChan: + case <-time.After(time.Second): + t.Fatal("watcher did not stop") + } + + deadline := time.Now().Add(time.Second) + for mock.watcherActive.Load() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if mock.watcherActive.Load() { + t.Fatal("watcher remained active after stopping") + } +} + func TestDaemonApplyConfigProtectsCredentials(t *testing.T) { mock := newMockDaemon(t.TempDir()) mock.config = &BackendConfig{