package actor import ( "bytes" "reflect" "strconv" "sync" "sync/atomic" "testing" "time" ) func await(t *testing.T, done <-chan struct{}) { t.Helper() timer := time.NewTimer(10 * time.Second) defer timer.Stop() select { case <-done: case <-timer.C: t.Fatal("actor operation did not finish") } } func TestNewValidationAndDefaults(t *testing.T) { a := New(func(int) {}) if len(a.mailbox.queue.fixed) != 64 || a.mailbox.batchSize != 64 { t.Fatalf("defaults: capacity=%d batch=%d", len(a.mailbox.queue.fixed), a.mailbox.batchSize) } tests := map[string]func(){ "nil handler": func() { New[int](nil) }, "nil option": func() { New(func(int) {}, nil) }, "zero capacity": func() { New(func(int) {}, WithFixedCapacity[int](0)) }, "negative capacity": func() { New(func(int) {}, WithFixedCapacity[int](-1)) }, "zero batch": func() { New(func(int) {}, WithBatchSize[int](0)) }, "negative batch": func() { New(func(int) {}, WithBatchSize[int](-1)) }, "nil dispatcher": func() { New(func(int) {}, WithDispatcher[int](nil)) }, "nil dispatcher function": func() { New(func(int) {}, WithDispatcher[int](DispatcherFunc(nil))) }, } for name, construct := range tests { t.Run(name, func(t *testing.T) { defer func() { if recover() == nil { t.Error("expected constructor panic") } }() construct() }) } } func TestIdleSendHandlesSynchronously(t *testing.T) { returned, handled := false, false a := New(func(message int) { if returned { t.Error("handler entered after Send returned") } if message != 42 { t.Errorf("message=%d, want 42", message) } handled = true }) a.Send(42) returned = true if !handled { t.Fatal("idle Send returned without handling its message") } } func TestConcurrentSendSerialAndProducerFIFO(t *testing.T) { const producers, messages = 12, 1000 type message struct{ producer, sequence int } var active atomic.Int32 last := make([]int, producers) for i := range last { last[i] = -1 } seen := make([]bool, producers*messages) count := 0 a := New(func(m message) { if current := active.Add(1); current != 1 { t.Errorf("%d concurrent handlers", current) } defer active.Add(-1) if m.sequence != last[m.producer]+1 { t.Errorf("producer %d: received %d after %d", m.producer, m.sequence, last[m.producer]) } last[m.producer] = m.sequence index := m.producer*messages + m.sequence if seen[index] { t.Errorf("duplicate message: %+v", m) } seen[index] = true count++ }, WithFixedCapacity[message](8)) start, done := make(chan struct{}), make(chan struct{}) var workers sync.WaitGroup for producer := range producers { workers.Add(1) go func() { defer workers.Done() <-start for sequence := range messages { a.Send(message{producer, sequence}) } }() } go func() { workers.Wait() close(done) }() close(start) await(t, done) if count != len(seen) { t.Errorf("handled %d messages, want %d", count, len(seen)) } for index, received := range seen { if !received { t.Fatalf("missing message %d", index) } } } func TestFixedStorageReusesCompleteRounds(t *testing.T) { for _, capacity := range []int{1, 2, 4} { t.Run(strconv.Itoa(capacity), func(t *testing.T) { count := 0 a := New(func(message int) { if message != count { t.Errorf("received %d, want %d", message, count) } count++ }, WithFixedCapacity[int](capacity)) first := &a.mailbox.queue.fixed[0] for i := range 1024 { a.Send(i) want := (i + 1) % capacity if got := a.mailbox.queue.reserved.Load(); got != uint64(want) || a.mailbox.queue.consumed != want { t.Fatalf("message %d: reserved=%d consumed=%d, want %d", i, got, a.mailbox.queue.consumed, want) } if &a.mailbox.queue.fixed[0] != first { t.Fatal("fixed storage was replaced") } for j := range a.mailbox.queue.fixed { if a.mailbox.queue.fixed[j].value != 0 || a.mailbox.queue.fixed[j].next != nil || !a.mailbox.queue.fixed[j].fixed { t.Fatalf("fixed slot %d was not cleared", j) } } } }) } } func TestFixedAndDynamicFIFOAndClearedReferences(t *testing.T) { type message struct { id int reference *int } entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) var got []int var a *Actor[message] reference := 7 a = New(func(m message) { got = append(got, m.id) if m.id == -1 { close(entered) <-release } if m.id == 1 { // All fixed slots are now reusable, but dynamic messages 2..9 // already belong ahead of this new fixed-slot message. a.Send(message{10, &reference}) } }, WithFixedCapacity[message](3)) go func() { defer close(done) a.Send(message{-1, &reference}) }() await(t, entered) for i := range 10 { a.Send(message{i, &reference}) } var queued []*node[message] fixed, dynamic := 0, 0 for n := a.mailbox.queue.head.Load(); n != nil; n = n.next { queued = append(queued, n) if n.fixed { fixed++ } else { dynamic++ } } if fixed != 2 || dynamic != 8 { t.Fatalf("queued storage: fixed=%d dynamic=%d", fixed, dynamic) } close(release) await(t, done) want := []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10} if !reflect.DeepEqual(got, want) { t.Errorf("FIFO: got %v, want %v", got, want) } for i, n := range queued { if n.value != (message{}) || n.next != nil { t.Errorf("consumed node %d retains a message or link", i) } } for i := range a.mailbox.queue.fixed { if a.mailbox.queue.fixed[i].value != (message{}) || a.mailbox.queue.fixed[i].next != nil { t.Errorf("fixed node %d retains a message or link", i) } } if a.mailbox.queue.head.Load() != nil { t.Error("mailbox retains a consumed batch") } } func TestPausedReservationDoesNotBlockPublication(t *testing.T) { var got []int a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](1)) paused := a.mailbox.queue.reserveNode() // The reserved fixed slot has not been initialized or published. Other // senders must use dynamic storage and finish without waiting for it. a.Send(2) a.Send(3) if !reflect.DeepEqual(got, []int{2, 3}) { t.Fatalf("unpublished reservation blocked messages: %v", got) } if a.mailbox.queue.reserved.Load() != 1 || a.mailbox.queue.consumed != 0 { t.Fatal("fixed storage was reopened before its reserved node was consumed") } paused.value = 1 a.mailbox.queue.publish(paused) a.mailbox.tryRun() if !reflect.DeepEqual(got, []int{2, 3, 1}) { t.Errorf("publication order: %v", got) } if a.mailbox.queue.reserved.Load() != 0 || a.mailbox.queue.consumed != 0 { t.Error("completed fixed round was not reopened") } } func TestPublicationWithReusedExpectedHead(t *testing.T) { var got []int a := New(func(message int) { got = append(got, message) }, WithFixedCapacity[int](2)) first, second := a.mailbox.queue.reserveNode(), a.mailbox.queue.reserveNode() first.value, second.value = 1, 2 a.mailbox.queue.publish(first) a.mailbox.queue.publish(second) paused := a.mailbox.queue.reserveNode() // Dynamic: it cannot prevent the fixed round ending. paused.value = 5 expected := a.mailbox.queue.head.Load() paused.next = expected a.mailbox.tryRun() a.Send(3) reused := a.mailbox.queue.reserveNode() if reused != expected { t.Fatal("test did not reuse the old head address") } reused.value = 4 a.mailbox.queue.publish(reused) // Emulate a producer resuming its original CAS after the same address has // become a new head. It only compares the pointer; no old links are read. if !a.mailbox.queue.head.CompareAndSwap(expected, paused) { t.Fatal("expected the paused publication CAS to succeed") } a.mailbox.tryRun() if !reflect.DeepEqual(got, []int{1, 2, 3, 4, 5}) { t.Errorf("reused head corrupted publication order: %v", got) } } func TestZeroValueMessages(t *testing.T) { var got []*int a := New(func(message *int) { got = append(got, message) }, WithFixedCapacity[*int](1)) a.Send(nil) a.Send(nil) if len(got) != 2 || got[0] != nil || got[1] != nil { t.Errorf("zero messages: %v", got) } } func TestSelfSendDoesNotReenter(t *testing.T) { var a *Actor[int] var got []int depth := 0 a = New(func(message int) { depth++ defer func() { depth-- }() if depth != 1 { t.Errorf("reentered handler at depth %d", depth) } got = append(got, message) if message == 0 { a.Send(1) a.Send(2) if !reflect.DeepEqual(got, []int{0}) { t.Errorf("self-send processed before handler returned: %v", got) } } else if message == 1 { a.Send(3) } }) a.Send(0) if !reflect.DeepEqual(got, []int{0, 1, 2, 3}) { t.Errorf("self-send order: %v", got) } } func TestHandlerAndPanicCallbackRecovery(t *testing.T) { var a *Actor[int] var got, reported []int var values []any a = New(func(message int) { got = append(got, message) switch message { case 0: for i := 1; i <= 3; i++ { a.Send(i) } panic("first") case 1: panic("second") case 2: panic(nil) } }, WithPanicHandler(func(message int, value any, stack []byte) { reported = append(reported, message) values = append(values, value) if !bytes.Contains(stack, []byte("TestHandlerAndPanicCallbackRecovery")) { t.Error("panic callback did not receive the handler stack") } if message == 0 { panic("panic callback failed") } })) a.Send(0) a.Send(4) if !reflect.DeepEqual(got, []int{0, 1, 2, 3, 4}) { t.Errorf("processing stopped after panic: %v", got) } if !reflect.DeepEqual(reported, []int{0, 1, 2}) || values[0] != "first" || values[1] != "second" { t.Errorf("panic reports: messages=%v values=%v", reported, values) } if a.mailbox.running.Load() { t.Error("panic left consumption ownership held") } count := 0 withoutCallback := New(func(int) { count++; panic("ignored") }) withoutCallback.Send(0) withoutCallback.Send(1) if count != 2 { t.Errorf("panic without callback stopped processing: %d", count) } } func TestSchedulingYieldsDuringConsumptionAndReversal(t *testing.T) { for _, test := range []struct { name string batch, queued int want []int }{ {name: "default interval", batch: 64, queued: 130, want: []int{1, 1, 64, 128}}, {name: "configured interval", batch: 4, queued: 9, want: []int{1, 1, 4, 8}}, } { t.Run(test.name, func(t *testing.T) { var a *Actor[int] processed := 0 var yieldedAt []int a = New(func(message int) { if message != processed { t.Errorf("message=%d, want %d", message, processed) } processed++ if message == 0 { for i := 1; i <= test.queued; i++ { a.Send(i) } } }, WithBatchSize[int](test.batch)) a.mailbox.yield = func() { yieldedAt = append(yieldedAt, processed) } a.Send(0) if !reflect.DeepEqual(yieldedAt, test.want) { t.Errorf("yield checkpoints=%v, want %v", yieldedAt, test.want) } }) } } func TestConsumerHandoffDoesNotLoseWakeupOrState(t *testing.T) { type message struct { id int entered, release chan struct{} } // This ordinary state belongs to the actor, whose consumer changes between // rounds. It needs no atomic access inside the serial handler. handled := 0 a := New(func(m message) { if m.id != handled { t.Errorf("handoff: message=%d, want %d", m.id, handled) } handled++ if m.entered != nil { close(m.entered) <-m.release } }, WithFixedCapacity[message](1), WithBatchSize[message](1)) const rounds = 2000 for round := range rounds { entered, release := make(chan struct{}), make(chan struct{}) ownerDone, producerDone, start := make(chan struct{}), make(chan struct{}), make(chan struct{}) go func() { defer close(ownerDone) a.Send(message{round * 2, entered, release}) }() await(t, entered) go func() { defer close(producerDone) <-start a.Send(message{id: round*2 + 1}) }() // Race publication against the old consumer leaving its final handler. // No sleeps or timing assumptions are needed to repeat this boundary. close(start) close(release) await(t, ownerDone) await(t, producerDone) } if handled != rounds*2 || a.mailbox.running.Load() || a.mailbox.queue.head.Load() != nil { t.Errorf("incomplete handoff: handled=%d running=%v", handled, a.mailbox.running.Load()) } }