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.
This commit is contained in:
Shawn authored and GitHub committed 2026-03-15 19:19:53 +08:00
1 parent 6455d2a07a
commit 8ec95a280c
2 files changed
+51 -1

No files matched your search

+5 -1
View File
@@ -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 {
+46
View File
@@ -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:
}
}