Skip to content

Commit 65a79de

Browse files
committed
feat: add broadcast watch
Signed-off-by: Christian Stewart <christian@aperture.us>
1 parent e37bb28 commit 65a79de

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

broadcast/watch.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package broadcast
2+
3+
import (
4+
"context"
5+
6+
proto "github.com/aperturerobotics/protobuf-go-lite"
7+
)
8+
9+
// WatchBroadcast watches a broadcast for changes and sends snapshots.
10+
//
11+
// snapshot is called under the broadcast lock to get the current value.
12+
// send is called outside the lock to transmit the value.
13+
// Skips sending when the value is equal to the previous via ==.
14+
// Returns when ctx is canceled or send returns an error.
15+
func WatchBroadcast[T comparable](
16+
ctx context.Context,
17+
bcast *Broadcast,
18+
snapshot func() T,
19+
send func(T) error,
20+
) error {
21+
return WatchBroadcastWithEqual(ctx, bcast, snapshot, send, nil)
22+
}
23+
24+
// WatchBroadcastWithEqual watches a broadcast for changes and sends snapshots.
25+
//
26+
// snapshot is called under the broadcast lock to get the current value.
27+
// send is called outside the lock to transmit the value.
28+
// equal is an optional comparator; if nil, uses == for dedup.
29+
// Returns when ctx is canceled or send returns an error.
30+
func WatchBroadcastWithEqual[T comparable](
31+
ctx context.Context,
32+
bcast *Broadcast,
33+
snapshot func() T,
34+
send func(T) error,
35+
equal func(a, b T) bool,
36+
) error {
37+
var ch <-chan struct{}
38+
var val T
39+
bcast.HoldLock(func(_ func(), getWaitCh func() <-chan struct{}) {
40+
ch = getWaitCh()
41+
val = snapshot()
42+
})
43+
if err := send(val); err != nil {
44+
return err
45+
}
46+
prev := val
47+
for {
48+
select {
49+
case <-ctx.Done():
50+
return ctx.Err()
51+
case <-ch:
52+
}
53+
bcast.HoldLock(func(_ func(), getWaitCh func() <-chan struct{}) {
54+
ch = getWaitCh()
55+
val = snapshot()
56+
})
57+
if val == prev {
58+
continue
59+
}
60+
if equal != nil && equal(val, prev) {
61+
continue
62+
}
63+
if err := send(val); err != nil {
64+
return err
65+
}
66+
prev = val
67+
}
68+
}
69+
70+
// WatchBroadcastVT watches a broadcast for changes and sends snapshots.
71+
//
72+
// Uses EqualVT for deduplication. Same as WatchBroadcast but for VTProtobuf messages.
73+
func WatchBroadcastVT[T proto.EqualVT[T]](
74+
ctx context.Context,
75+
bcast *Broadcast,
76+
snapshot func() T,
77+
send func(T) error,
78+
) error {
79+
return WatchBroadcastWithEqual(ctx, bcast, snapshot, send, proto.CompareEqualVT[T]())
80+
}

broadcast/watch_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package broadcast
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestWatchBroadcast_SendsInitialValue(t *testing.T) {
9+
var bcast Broadcast
10+
val := 42
11+
12+
ctx, cancel := context.WithCancel(context.Background())
13+
var received []int
14+
err := WatchBroadcast(ctx, &bcast, func() int { return val }, func(v int) error {
15+
received = append(received, v)
16+
cancel()
17+
return nil
18+
})
19+
if err != context.Canceled {
20+
t.Fatalf("expected context.Canceled, got %v", err)
21+
}
22+
if len(received) != 1 || received[0] != 42 {
23+
t.Fatalf("expected [42], got %v", received)
24+
}
25+
}
26+
27+
func TestWatchBroadcast_SkipsDuplicates(t *testing.T) {
28+
var bcast Broadcast
29+
val := 1
30+
31+
ctx, cancel := context.WithCancel(context.Background())
32+
var received []int
33+
sent := make(chan struct{}, 10)
34+
35+
done := make(chan error, 1)
36+
go func() {
37+
done <- WatchBroadcast(ctx, &bcast, func() int { return val }, func(v int) error {
38+
received = append(received, v)
39+
sent <- struct{}{}
40+
return nil
41+
})
42+
}()
43+
44+
// Wait for initial send.
45+
<-sent
46+
47+
// Broadcast with same value (should be skipped).
48+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
49+
broadcast()
50+
})
51+
52+
// Change value and broadcast (should be sent).
53+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
54+
val = 2
55+
broadcast()
56+
})
57+
58+
// Wait for second send.
59+
<-sent
60+
cancel()
61+
62+
err := <-done
63+
if err != context.Canceled {
64+
t.Fatalf("expected context.Canceled, got %v", err)
65+
}
66+
if len(received) != 2 || received[0] != 1 || received[1] != 2 {
67+
t.Fatalf("expected [1, 2], got %v", received)
68+
}
69+
}
70+
71+
func TestWatchBroadcastWithEqual_CustomComparator(t *testing.T) {
72+
type pair struct{ a, b int }
73+
74+
var bcast Broadcast
75+
val := pair{1, 2}
76+
77+
ctx, cancel := context.WithCancel(context.Background())
78+
var received []pair
79+
sent := make(chan struct{}, 10)
80+
81+
done := make(chan error, 1)
82+
go func() {
83+
done <- WatchBroadcastWithEqual(
84+
ctx, &bcast,
85+
func() pair { return val },
86+
func(v pair) error {
87+
received = append(received, v)
88+
sent <- struct{}{}
89+
return nil
90+
},
91+
// Only compare .a field.
92+
func(x, y pair) bool { return x.a == y.a },
93+
)
94+
}()
95+
96+
// Wait for initial send.
97+
<-sent
98+
99+
// Change .b only (should be skipped by custom equal).
100+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
101+
val = pair{1, 99}
102+
broadcast()
103+
})
104+
105+
// Change .a (should be sent).
106+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
107+
val = pair{2, 99}
108+
broadcast()
109+
})
110+
111+
<-sent
112+
cancel()
113+
114+
err := <-done
115+
if err != context.Canceled {
116+
t.Fatalf("expected context.Canceled, got %v", err)
117+
}
118+
if len(received) != 2 {
119+
t.Fatalf("expected 2 sends, got %d: %v", len(received), received)
120+
}
121+
if received[0].a != 1 || received[1].a != 2 {
122+
t.Fatalf("expected a=1 then a=2, got %v", received)
123+
}
124+
}
125+
126+
// vtMsg implements proto.EqualVT for testing WatchBroadcastVT.
127+
type vtMsg struct {
128+
data string
129+
}
130+
131+
func (m *vtMsg) EqualVT(other *vtMsg) bool {
132+
if m == nil && other == nil {
133+
return true
134+
}
135+
if m == nil || other == nil {
136+
return false
137+
}
138+
return m.data == other.data
139+
}
140+
141+
func TestWatchBroadcastVT_SkipsDuplicates(t *testing.T) {
142+
var bcast Broadcast
143+
val := &vtMsg{data: "hello"}
144+
145+
ctx, cancel := context.WithCancel(context.Background())
146+
var received []*vtMsg
147+
sent := make(chan struct{}, 10)
148+
149+
done := make(chan error, 1)
150+
go func() {
151+
done <- WatchBroadcastVT(
152+
ctx, &bcast,
153+
func() *vtMsg { return val },
154+
func(v *vtMsg) error {
155+
received = append(received, v)
156+
sent <- struct{}{}
157+
return nil
158+
},
159+
)
160+
}()
161+
162+
// Wait for initial send.
163+
<-sent
164+
165+
// Different pointer, same data (should be skipped by VT equal).
166+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
167+
val = &vtMsg{data: "hello"}
168+
broadcast()
169+
})
170+
171+
// Different data (should be sent).
172+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
173+
val = &vtMsg{data: "world"}
174+
broadcast()
175+
})
176+
177+
<-sent
178+
cancel()
179+
180+
err := <-done
181+
if err != context.Canceled {
182+
t.Fatalf("expected context.Canceled, got %v", err)
183+
}
184+
if len(received) != 2 {
185+
t.Fatalf("expected 2 sends, got %d: %v", len(received), received)
186+
}
187+
if received[0].data != "hello" || received[1].data != "world" {
188+
t.Fatalf("expected hello then world, got %v %v", received[0].data, received[1].data)
189+
}
190+
}
191+
192+
func TestWatchBroadcastVT_NilToNonNil(t *testing.T) {
193+
var bcast Broadcast
194+
var val *vtMsg
195+
196+
ctx, cancel := context.WithCancel(context.Background())
197+
var received []*vtMsg
198+
sent := make(chan struct{}, 10)
199+
200+
done := make(chan error, 1)
201+
go func() {
202+
done <- WatchBroadcastVT(
203+
ctx, &bcast,
204+
func() *vtMsg { return val },
205+
func(v *vtMsg) error {
206+
received = append(received, v)
207+
sent <- struct{}{}
208+
return nil
209+
},
210+
)
211+
}()
212+
213+
// Wait for initial send (nil).
214+
<-sent
215+
216+
// Broadcast nil -> non-nil (should send).
217+
bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) {
218+
val = &vtMsg{data: "first"}
219+
broadcast()
220+
})
221+
222+
<-sent
223+
cancel()
224+
225+
err := <-done
226+
if err != context.Canceled {
227+
t.Fatalf("expected context.Canceled, got %v", err)
228+
}
229+
if len(received) != 2 {
230+
t.Fatalf("expected 2 sends (nil then non-nil), got %d", len(received))
231+
}
232+
if received[0] != nil {
233+
t.Fatalf("expected first send to be nil, got %v", received[0])
234+
}
235+
if received[1].data != "first" {
236+
t.Fatalf("expected second send to be 'first', got %v", received[1].data)
237+
}
238+
}

0 commit comments

Comments
 (0)