433 lines
12 KiB
Go
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())
|
|
}
|
|
}
|