fix(CLI-01): Go Events() emits terminal ErrSlowConsumer on overflow

Events()/EventsAfter() previously closed the channel silently when the
16-slot buffer overflowed, indistinguishable from a graceful server end.
Reserve one slot and emit a terminal EventResult wrapping the new exported
ErrSlowConsumer so overflow is always observable. The blocking
SubscribeEvents path (gRPC flow-controlled) is unchanged.

archreview: CLI-01 (P0). Verified: gofmt clean, go build ./..., go test ./... (+ -race) pass.
This commit is contained in:
Joseph Doherty
2026-07-09 05:51:45 -04:00
parent 59856b8c63
commit 1eb00276bc
3 changed files with 101 additions and 4 deletions
@@ -147,6 +147,59 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
}
}
func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
streamEventCount: 64,
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
events, err := session.EventsAfter(context.Background(), 0)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
// Do not drain the channel so the buffer overflows. The stream stops once
// the slow-consumer cancel fires on the producer side.
select {
case <-fake.streamDone:
case <-time.After(2 * time.Second):
t.Fatal("event stream did not stop after buffer overflow")
}
var last EventResult
gotResult := false
for {
select {
case res, ok := <-events:
if !ok {
if !gotResult {
t.Fatal("events channel closed without yielding any result")
}
if !errors.Is(last.Err, ErrSlowConsumer) {
t.Fatalf("final event result err = %v, want one wrapping ErrSlowConsumer", last.Err)
}
var gwErr *GatewayError
if !errors.As(last.Err, &gwErr) {
t.Fatalf("final event result err is %T, want *GatewayError", last.Err)
}
if gwErr.Op != "stream events" {
t.Fatalf("final event result err Op = %q, want %q", gwErr.Op, "stream events")
}
return
}
last = res
gotResult = true
case <-time.After(2 * time.Second):
t.Fatal("events channel did not close after slow-consumer termination")
}
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{