From 8ec95a280c1bcd17eaefc421d64a9bee225a7deb Mon Sep 17 00:00:00 2001 From: Shawn <118158941+kevinWangSheng@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:19:53 +0800 Subject: [PATCH] fix(core): prevent infinite loop in drainEvents on closed channel (#152) drainEvents uses a select loop with a default case to drain buffered events. When the channel is closed, receives always succeed immediately with a zero value, so the default case never triggers and the function spins in an infinite loop, consuming 100% CPU. Check the ok value from the receive and return immediately when the channel is closed. Add regression tests for both closed and open channel scenarios. --- core/engine.go | 6 +++++- core/engine_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/core/engine.go b/core/engine.go index 4a37f25..f18d2a5 100644 --- a/core/engine.go +++ b/core/engine.go @@ -4423,7 +4423,11 @@ func drainEvents(ch <-chan Event) { drained := 0 for { select { - case <-ch: + case _, ok := <-ch: + if !ok { + // Channel is closed; stop immediately to avoid an infinite loop. + return + } drained++ default: if drained > 0 { diff --git a/core/engine_test.go b/core/engine_test.go index f394677..d36b05a 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -2726,3 +2726,49 @@ func TestCmdBindSetup_UsesSharedLogic(t *testing.T) { t.Error("expected instructions written to file") } } + +func TestDrainEventsClosedChannel(t *testing.T) { + ch := make(chan Event, 2) + ch <- Event{Type: EventToolUse, Content: "a"} + ch <- Event{Type: EventToolUse, Content: "b"} + close(ch) + + done := make(chan struct{}) + go func() { + drainEvents(ch) + close(done) + }() + + select { + case <-done: + // ok — returned promptly + case <-time.After(2 * time.Second): + t.Fatal("drainEvents did not return on closed channel (infinite loop)") + } +} + +func TestDrainEventsOpenChannel(t *testing.T) { + ch := make(chan Event, 3) + ch <- Event{Type: EventToolUse, Content: "a"} + ch <- Event{Type: EventToolUse, Content: "b"} + + done := make(chan struct{}) + go func() { + drainEvents(ch) + close(done) + }() + + select { + case <-done: + // ok + case <-time.After(2 * time.Second): + t.Fatal("drainEvents did not return on open channel with buffered events") + } + + // Channel should now be empty. + select { + case <-ch: + t.Fatal("expected channel to be drained") + default: + } +}