Files
actor/actor_test.go
2026-10-20 18:53:09 +08:00

433 lines
12 KiB
Go

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())
}
}