From b81622842bf83e9bf6e3e5a10817d9022fde6ddf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:32:19 +0100 Subject: [PATCH] seatlist: an event heard before the seat ever answered reads the whole list (issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/seatlist.go | 18 +++++++++++++++++ go/seatlist/seatlist_test.go | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 70835ea..105de8b 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -345,6 +345,16 @@ func (r *Reader) dropLocked(seat string) { // heard takes one of the seat's events: a trunk definition moved, so that seat is asked again, or went, so it is dropped. // An error is returned so the bus offers the event again. func (r *Reader) heard(key string, body json.RawMessage) error { + r.mu.Lock() + unread := !r.spoken + r.mu.Unlock() + if unread { + // the seat was silent when the reader started and speaks now: read the whole list, not only the seat named + if err := r.Reload(); err != nil { + return err + } + return nil + } var c Changed if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" { return nil @@ -352,6 +362,14 @@ func (r *Reader) heard(key string, body json.RawMessage) error { if c.Source != "" && c.Source != SourceTrunk { return nil } + r.mu.Lock() + spoken := r.spoken + r.mu.Unlock() + if !spoken { + // The seat was silent when this reader started: its event says it speaks now, so the whole list is read, not this + // seat alone, and the reader is heard (MayAct) from here on. + return r.Reload() + } if strings.HasSuffix(key, "."+RemovedEvent) { r.mu.Lock() r.dropLocked(c.Seat) diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 673c002..079ad98 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -218,3 +218,42 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { t.Fatalf("again: %v %+v", err, r.Status()) } } + +// A reader started while the seat was silent reads the whole list at the first event the seat sends, and then acts on any module. +func TestAReaderStartedWhileTheSeatWasSilentReadsTheWholeListAtItsFirstEvent(t *testing.T) { + owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put"), "node-c": def("node-c", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + if r.Status().Heard || r.MayAct("node-b") { + t.Fatal("the silent seat was heard") + } + owner.down = false + if err := owner.say(ChangedEvent, "node-b", SourceTrunk); err != nil { + t.Fatal(err) + } + if !r.Status().Heard || !r.MayAct("node-b") || !r.Has("node-c") { + t.Fatalf("the event did not read the whole list: %+v", r.Status()) + } +} + +// A reader started while the seat was silent is heard at the seat's changed event: the whole list is read then (ADR 0636 ยง1). +func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing.T) { + owner := &fakeOwner{down: true} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + if r.Status().Heard || r.MayAct("node-files") { + t.Fatal("the silent seat must leave the reader unheard") + } + owner.down = false + owner.table = map[string]string{"node-a": def("node-a", "node", "put"), "node-b": def("node-b", "node", "put")} + if err := owner.say(ChangedEvent, "node-a", SourceTrunk); err != nil { + t.Fatal(err) + } + if st := r.Status(); !st.Heard || st.LastError != "" || !r.MayAct("node-files") || !r.Has("node-a") || !r.Has("node-b") { + t.Fatalf("after the changed event: %+v, node-b held %v: want heard and the whole list read", st, r.Has("node-b")) + } +}