F-tools: the runner's views consumer is proven on the real bus; the heartbeat is asked of the bus only when a bundle wants it, with no race; a seat record says when the bus wrote it; the beat test counts ticks, not sleeps

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 16:26:50 +01:00
parent 76e025dbbc
commit db3b5fe63d
9 changed files with 290 additions and 32 deletions
+42 -10
View File
@@ -775,7 +775,36 @@ type consumers struct {
type moduleEvents struct {
stop func()
delivers []*func(json.RawMessage) error
beats []*func(json.RawMessage)
beats []*beatSlot
}
// beatSlot is where a bundle's heartbeat handler is kept; a bundle loaded again sets it while the consumer's loop
// reads it, so both go through its own lock.
type beatSlot struct {
mu sync.Mutex
f func(json.RawMessage)
}
func (s *beatSlot) get() func(json.RawMessage) {
s.mu.Lock()
defer s.mu.Unlock()
return s.f
}
func (s *beatSlot) set(f func(json.RawMessage)) {
s.mu.Lock()
defer s.mu.Unlock()
s.f = f
}
// beatAsked says whether any bundle of the module asked for the heartbeat; the caller holds c.mu.
func (m *moduleEvents) beatAsked() bool {
for _, t := range m.beats {
if t.get() != nil {
return true
}
}
return false
}
// stopAll unbinds every module's consumer.
@@ -823,7 +852,7 @@ type moduleBus struct {
module string
mu sync.Mutex
deliver *func(json.RawMessage) error
beat *func(json.RawMessage)
beat *beatSlot
}
// Publish emits an event as the module (ADR 0193).
@@ -871,11 +900,9 @@ func (b *moduleBus) Subscribe(deliver func(json.RawMessage) error, beat func(jso
}
mine := b.deliver
if b.beat == nil {
x := beat
b.beat = &x
} else {
*b.beat = beat
b.beat = &beatSlot{}
}
b.beat.set(beat)
mineBeat := b.beat
b.mu.Unlock()
@@ -901,8 +928,13 @@ func (b *moduleBus) Subscribe(deliver func(json.RawMessage) error, beat func(jso
return nil
}
module := b.module
stop, err := c.conn.ConsumeAsBeating(module, func(env bus.Envelope) error { return c.handOut(module, m, env) },
func(b bus.Beat) { c.handBeat(m, b) })
// The bus is asked about the consumer only while a bundle of the module asked for the heartbeat.
stop, err := c.conn.ConsumeAsBeatingWhen(module, func(env bus.Envelope) error { return c.handOut(module, m, env) },
func(b bus.Beat) { c.handBeat(m, b) }, func() bool {
c.mu.Lock()
defer c.mu.Unlock()
return m.beatAsked()
})
if err != nil {
return err
}
@@ -918,10 +950,10 @@ func (c *consumers) handBeat(m *moduleEvents, b bus.Beat) {
return
}
c.mu.Lock()
targets := append([](*func(json.RawMessage))(nil), m.beats...)
targets := append([]*beatSlot(nil), m.beats...)
c.mu.Unlock()
for _, t := range targets {
if f := *t; f != nil {
if f := t.get(); f != nil {
f(raw)
}
}