From 2f156e7f30508e9aaf06322b305036645c3ed266 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 00:31:29 +0100 Subject: [PATCH] S2: the seat list's reader asks again while its ask reached no one A node-engine started in the five seconds its holder re-served read no seat list and nothing asked again: the reader read only on the seat's next event. An ask the bus delivered to no one is now asked again within busretry's bound; an answer, a refusal or a timeout never is (issue 1966). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/seatlist/seatlist.go | 18 +++++++++++++++--- go/seatlist/seatlist_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index cc366f3..a7f156f 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -7,7 +7,9 @@ // **What it asks and hears.** The seat's verb with no seat (every trunk definition's name and digest, Listing) and with // one seat (that definition, Answer); the seat's two events, whose bodies name the seat (Changed). It calls the seat, // never the module that holds it, and only trunk definitions are held: a pull request's are the indexer's to judge. -// Nothing polls and nothing waits on a timer: a reader that could not ask says so in its Status and reads again on the +// An ask that reached no one (busretry.Undelivered: the bus's "no responders", as while the seat's holder re-serves) is +// asked again within the bus's retry bound (busretry.Bound, about 30 s, backing off; issue 774's form, issue 1966). +// Nothing else polls or waits on a timer: a reader that could not ask says so in its Status and reads again on the // next event or on Reload, which the host offers as a tool (Tools). // // **A reader that has not heard acts on nothing new** (ADR 0636 ยง1): MayAct says what a host may start, compose, grant @@ -27,6 +29,7 @@ import ( "sync" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -284,7 +287,7 @@ func (r *Reader) seatsLocked() []catalogue.Seat { // cannot be read is returned and changes nothing; one definition that does not parse leaves the one held standing and is // said in Status. func (r *Reader) Reload() error { - raw, err := r.cfg.Bus.Ask(Key(), map[string]any{}) + raw, err := r.ask(map[string]any{}, "the seat list") if err != nil { return r.failed(fmt.Errorf("seatlist: asking %s: %w", Key(), err)) } @@ -358,9 +361,18 @@ func (r *Reader) ReloadIfUnread() <-chan struct{} { return done } +// ask asks the seat's verb, and again while the ask reached no one, within the bus's retry bound: a holder that re-serves +// for a few seconds while the reader starts leaves no list unread (issue 1966). An answer, a refusal or a timeout is never +// asked again. +func (r *Reader) ask(body map[string]any, what string) (json.RawMessage, error) { + return busretry.Do(what, func() (json.RawMessage, error) { + return r.cfg.Bus.Ask(Key(), body) + }, nil) +} + // fetch asks the seat for one definition and holds it, or drops it when the seat holds none. func (r *Reader) fetch(seat string) error { - raw, err := r.cfg.Bus.Ask(Key(), map[string]any{"seat": seat}) + raw, err := r.ask(map[string]any{"seat": seat}, "the seat "+seat) if err != nil { return fmt.Errorf("seatlist: asking %s for %s: %w", Key(), seat, err) } diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index e0e8147..7a3548f 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -7,17 +7,21 @@ import ( "reflect" "strings" "testing" + "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" + "github.com/nats-io/nats.go" ) // fakeOwner is the seat mesh-catalog: its verb answers from its table and the events are delivered by the test. type fakeOwner struct { table map[string]string // seat -> definition JSON, trunk down bool + silent int // when set, so many asks first reach no one (the bus's no responders), as while the holder re-serves gate chan struct{} // when set, an ask waits for it to be closed asked []string // "key seat" handlers map[string]func(string, json.RawMessage) error @@ -30,6 +34,10 @@ func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { args, _ := body.(map[string]any) seat, _ := args["seat"].(string) f.asked = append(f.asked, key+" "+seat) + if f.silent > 0 { + f.silent-- + return nil, fmt.Errorf("not asked: nothing on this machine answers it (%w)", nats.ErrNoResponders) + } if f.down { return nil, errors.New("no answer") } @@ -266,6 +274,19 @@ func TestStartReturnsAtOnceAndTheReaderSaysItIsReadingUntilTheSeatAnswers(t *tes if st := r.Status(); st.Reading || !st.Heard || !r.MayAct("node-b") { t.Fatalf("after the read: %+v", st) } + + // Issue 1966: the first read falls into the holder's re-serve (the bus answers no responders twice); it is asked again + // within the bus's retry bound, with no event and no hand act, and the reader is heard. + t.Cleanup(busretry.Waits(10*time.Second, time.Millisecond, 4*time.Millisecond)) + owner = &fakeOwner{silent: 2, table: map[string]string{"node-b": def("node-b", "node", "put")}} + r, err = Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + <-r.Ready() + if st := r.Status(); !st.Heard || st.LastError != "" || !r.Has("node-b") || len(owner.asked) != 4 { + t.Fatalf("after a holder that re-served: %+v, asked %q: want heard on the third ask of the list", st, owner.asked) + } } // The bootstrap and the tests' seat list are copies of the same contracts files: where a seat is in both, they are equal, -- 2.54.0