mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
* feat(daemon): make workspace sync event-driven Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): preserve trailing workspace changes Co-authored-by: multica-agent <github@multica.ai> * fix(workspace): reconcile failed creates Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
261 lines
6.7 KiB
Go
261 lines
6.7 KiB
Go
package daemon
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestReconcileBroadcaster_FansOutToManySubscribers pins the close-and-replace
|
|
// fan-out: every subscriber registered before broadcast wakes from its
|
|
// snapshot channel.
|
|
func TestReconcileBroadcaster_FansOutToManySubscribers(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = 0 // disable debounce for this test
|
|
|
|
const subs = 16
|
|
var wg sync.WaitGroup
|
|
wg.Add(subs)
|
|
wokeUp := make(chan struct{}, subs)
|
|
for i := 0; i < subs; i++ {
|
|
ch := b.notify()
|
|
go func() {
|
|
defer wg.Done()
|
|
<-ch
|
|
wokeUp <- struct{}{}
|
|
}()
|
|
}
|
|
|
|
// Give subscribers a moment to park on <-ch.
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
if !b.broadcast() {
|
|
t.Fatalf("broadcast() = false, want true")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("only %d/%d subscribers woke up", len(wokeUp), subs)
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_ReplaysMissedBroadcastToFirstLateSubscriber pins
|
|
// the level-triggered safety net: if a broadcast fires while no one is
|
|
// subscribed, the next notify() returns an already-closed channel so the
|
|
// late subscriber observes the missed event exactly once. This closes the
|
|
// daemon-startup race where a sync loop subscribes a beat after the WS
|
|
// connect broadcast.
|
|
func TestReconcileBroadcaster_ReplaysMissedBroadcastToFirstLateSubscriber(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = 0
|
|
|
|
// No subscribers yet — fire.
|
|
if !b.broadcast() {
|
|
t.Fatalf("broadcast() = false, want true")
|
|
}
|
|
|
|
// First late subscriber must see the replay.
|
|
first := b.notify()
|
|
select {
|
|
case <-first:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("first late subscriber did not receive replayed broadcast")
|
|
}
|
|
|
|
// Second late subscriber must NOT see the same replay; it should park.
|
|
second := b.notify()
|
|
select {
|
|
case <-second:
|
|
t.Fatalf("second late subscriber received a stale replay; should be fresh")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// Expected: parked.
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_ReplayPersistsAcrossSubscriberDelay pins that an
|
|
// unobserved pending replay is preserved indefinitely until the first
|
|
// notify() consumes it.
|
|
func TestReconcileBroadcaster_ReplayPersistsAcrossSubscriberDelay(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = 0
|
|
|
|
b.broadcast()
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
ch := b.notify()
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("pending replay was lost after 100ms delay")
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_DebouncesFlappingReconnects pins the safety
|
|
// property that a flapping WS cannot translate ten reconnects per second
|
|
// into ten fan-out broadcasts. The clock is injected so the debounce
|
|
// window is deterministic.
|
|
func TestReconcileBroadcaster_DebouncesFlappingReconnects(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = time.Second
|
|
var nowVal time.Time
|
|
b.now = func() time.Time { return nowVal }
|
|
|
|
nowVal = time.Unix(1_700_000_000, 0)
|
|
if !b.broadcast() {
|
|
t.Fatalf("first broadcast suppressed")
|
|
}
|
|
|
|
// Ten back-to-back calls within 900ms — all suppressed.
|
|
for i := 0; i < 10; i++ {
|
|
nowVal = nowVal.Add(90 * time.Millisecond)
|
|
if b.broadcast() {
|
|
t.Fatalf("broadcast at +%dms not debounced", (i+1)*90)
|
|
}
|
|
}
|
|
|
|
// Cross the threshold — next broadcast fires.
|
|
nowVal = nowVal.Add(time.Second)
|
|
if !b.broadcast() {
|
|
t.Fatalf("broadcast past debounce window suppressed")
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_DebounceBoundaryIsExact pins the exact comparison
|
|
// (strict less-than): a call landing at exactly minBroadcastInterval after
|
|
// the previous broadcast must succeed, not be suppressed.
|
|
func TestReconcileBroadcaster_DebounceBoundaryIsExact(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = time.Second
|
|
var nowVal time.Time
|
|
b.now = func() time.Time { return nowVal }
|
|
|
|
nowVal = time.Unix(1_700_000_000, 0)
|
|
if !b.broadcast() {
|
|
t.Fatal("first broadcast suppressed")
|
|
}
|
|
|
|
// Exactly at the interval — must fire (>= boundary is allowed).
|
|
nowVal = nowVal.Add(time.Second)
|
|
if !b.broadcast() {
|
|
t.Fatal("broadcast at exact debounce boundary was suppressed")
|
|
}
|
|
|
|
// Just below the next boundary — must be suppressed.
|
|
nowVal = nowVal.Add(999 * time.Millisecond)
|
|
if b.broadcast() {
|
|
t.Fatal("broadcast at boundary-minus-1ms was not suppressed")
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_ReSubscribesEachWake pins the edge-triggered
|
|
// contract for ACTIVE subscribers: after the first wake, the same channel
|
|
// stays closed; the subscriber must call notify() again to receive future
|
|
// broadcasts.
|
|
func TestReconcileBroadcaster_ReSubscribesEachWake(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = 0
|
|
|
|
ch1 := b.notify()
|
|
b.broadcast()
|
|
select {
|
|
case <-ch1:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("first wake did not arrive on ch1")
|
|
}
|
|
|
|
// ch1 is now closed; broadcasting again must not panic.
|
|
b.broadcast()
|
|
select {
|
|
case <-ch1:
|
|
// Already closed — still drains cleanly.
|
|
default:
|
|
t.Fatalf("ch1 should remain closed after second broadcast")
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangeSignalCoalescesUntilConsumed(t *testing.T) {
|
|
s := newWorkspaceChangeSignal()
|
|
if !s.broadcast() {
|
|
t.Fatal("first workspace change was not recorded")
|
|
}
|
|
if s.broadcast() {
|
|
t.Fatal("duplicate workspace change should coalesce while dirty")
|
|
}
|
|
|
|
select {
|
|
case <-s.notify():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("recorded workspace change was not delivered")
|
|
}
|
|
if !s.broadcast() {
|
|
t.Fatal("workspace change after consumption was not recorded")
|
|
}
|
|
}
|
|
|
|
// TestReconcileBroadcaster_ConcurrentBroadcastAndNotify exercises the lock
|
|
// boundary: heavy concurrent notify/broadcast traffic must not panic, dead-
|
|
// lock, or fail under the race detector. Run with `go test -race`.
|
|
func TestReconcileBroadcaster_ConcurrentBroadcastAndNotify(t *testing.T) {
|
|
b := newReconcileBroadcaster()
|
|
b.minBroadcastInterval = 0
|
|
|
|
stop := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
|
|
// 8 subscribers in tight loops.
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
ch := b.notify()
|
|
select {
|
|
case <-ch:
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 4 broadcasters in tight loops.
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
b.broadcast()
|
|
}
|
|
}()
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
close(stop)
|
|
|
|
// Bound the join — if anything deadlocks we want a clear failure rather
|
|
// than a hung test.
|
|
done := make(chan struct{})
|
|
go func() { wg.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("concurrent broadcast/notify did not converge after stop")
|
|
}
|
|
}
|