init repo

This commit is contained in:
2026-10-20 18:53:09 +08:00
commit 466b0ea05d
13 changed files with 1877 additions and 0 deletions

329
dispatcher_test.go Normal file
View File

@@ -0,0 +1,329 @@
package actor
import (
"reflect"
"sync"
"sync/atomic"
"testing"
)
// queuedDispatcher deliberately postpones execution until the test takes a
// task. Its mutex protects the test scheduler, not the actor's mailbox.
type queuedDispatcher struct {
mu sync.Mutex
tasks []func()
submitted int
}
func (d *queuedDispatcher) Schedule(task func()) {
d.mu.Lock()
defer d.mu.Unlock()
d.tasks = append(d.tasks, task)
d.submitted++
}
func (d *queuedDispatcher) stats() (submitted, pending int) {
d.mu.Lock()
defer d.mu.Unlock()
return d.submitted, len(d.tasks)
}
func (d *queuedDispatcher) take(t *testing.T) func() {
t.Helper()
d.mu.Lock()
defer d.mu.Unlock()
if len(d.tasks) == 0 {
t.Fatal("dispatcher has no pending task")
}
task := d.tasks[0]
d.tasks[0] = nil
d.tasks = d.tasks[1:]
return task
}
func TestQueuedDispatcherOwnsMailboxBeforeTaskStarts(t *testing.T) {
d := &queuedDispatcher{}
var got []int
var active atomic.Int32
a := New(func(message int) {
if count := active.Add(1); count != 1 {
t.Errorf("%d concurrent handlers", count)
}
defer active.Add(-1)
got = append(got, message)
}, WithFixedCapacity[int](4), WithDispatcher[int](d))
for message := range 200 {
a.Send(message)
}
if len(got) != 0 || !a.mailbox.running.Load() {
t.Fatal("Send did not retain consumption ownership for the queued task")
}
if submitted, pending := d.stats(); submitted != 1 || pending != 1 {
t.Fatalf("before execution: submitted=%d pending=%d, want 1, 1", submitted, pending)
}
d.take(t)()
if len(got) != 200 || a.mailbox.running.Load() {
t.Fatalf("first task did not finish draining: handled=%d", len(got))
}
for message, value := range got {
if message != value {
t.Fatalf("FIFO: got[%d]=%d", message, value)
}
}
a.Send(200)
if len(got) != 200 {
t.Error("next wave ran before its task was executed")
}
if submitted, pending := d.stats(); submitted != 2 || pending != 1 {
t.Fatalf("second wave: submitted=%d pending=%d, want 2, 1", submitted, pending)
}
d.take(t)()
if len(got) != 201 || got[200] != 200 || a.mailbox.running.Load() {
t.Error("second task did not drain the next wave")
}
}
func TestSelfSendAndYieldDoNotRescheduleActiveTask(t *testing.T) {
d := &queuedDispatcher{}
var a *Actor[int]
var got []int
depth, yields := 0, 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(1001)
}
}, WithDispatcher[int](d))
a.mailbox.yield = func() {
yields++
if !a.mailbox.running.Load() {
t.Error("yield released consumption ownership")
}
if yields == 1 {
a.Send(1000)
}
if submitted, pending := d.stats(); submitted != 1 || pending != 0 {
t.Errorf("active task rescheduled: submitted=%d pending=%d", submitted, pending)
}
}
for message := range 130 {
a.Send(message)
}
d.take(t)()
want := make([]int, 130, 132)
for i := range want {
want[i] = i
}
// The first reversal yields before handler 0 runs, so publication of 1000
// precedes the self-send of 1001. Both follow the already detached batch.
want = append(want, 1000, 1001)
if !reflect.DeepEqual(got, want) {
t.Errorf("yield/self-send publication order: got %v, want %v", got, want)
}
if yields == 0 || a.mailbox.running.Load() {
t.Error("task did not yield and finish")
}
}
func TestSharedDispatcherKeepsActorsIndependent(t *testing.T) {
const actors, messages = 3, 100
d := &queuedDispatcher{}
var all [actors]*Actor[int]
var entered, release [actors]chan struct{}
var count [actors]int
var active [actors]atomic.Int32
for i := range actors {
entered[i], release[i] = make(chan struct{}), make(chan struct{})
all[i] = New(func(message int) {
if current := active[i].Add(1); current != 1 {
t.Errorf("actor %d has %d concurrent handlers", i, current)
}
defer active[i].Add(-1)
if message != count[i] {
t.Errorf("actor %d: message=%d, want %d", i, message, count[i])
}
count[i]++
if message == 0 {
close(entered[i])
<-release[i]
}
}, WithFixedCapacity[int](4), WithDispatcher[int](d))
for message := range messages {
all[i].Send(message)
}
}
var workers sync.WaitGroup
for range actors {
task := d.take(t)
workers.Add(1)
go func() {
defer workers.Done()
task()
}()
}
for i := range actors {
await(t, entered[i])
all[i].Send(messages)
}
if submitted, pending := d.stats(); submitted != actors || pending != 0 {
t.Errorf("shared dispatcher: submitted=%d pending=%d", submitted, pending)
}
for i := range actors {
close(release[i])
}
done := make(chan struct{})
go func() {
workers.Wait()
close(done)
}()
await(t, done)
for i := range actors {
if count[i] != messages+1 || all[i].mailbox.running.Load() {
t.Errorf("actor %d did not finish independently: handled=%d", i, count[i])
}
}
}
func TestDispatcherFuncAsynchronousConcurrentDrain(t *testing.T) {
const producers, messages, selfMessages = 8, 400, 16
type message struct{ producer, sequence int }
var tasks sync.WaitGroup
var submitted, active atomic.Int32
d := DispatcherFunc(func(task func()) {
submitted.Add(1)
tasks.Add(1)
go func() {
defer tasks.Done()
task()
}()
})
entered, release := make(chan struct{}), make(chan struct{})
last := make([]int, producers+1)
seen := make([][]bool, producers+1)
for producer := range producers + 1 {
last[producer] = -1
seen[producer] = make([]bool, messages+1)
}
handled, panics := 0, 0
var a *Actor[message]
a = New(func(m message) {
if current := active.Add(1); current != 1 {
t.Errorf("async actor has %d concurrent handlers", current)
}
defer active.Add(-1)
if m.producer == -1 {
a.Send(message{producers, 0})
close(entered)
<-release
return
}
if m.sequence != last[m.producer]+1 || seen[m.producer][m.sequence] {
t.Errorf("async producer %d: sequence=%d after %d", m.producer, m.sequence, last[m.producer])
}
last[m.producer] = m.sequence
seen[m.producer][m.sequence] = true
handled++
if m.producer == producers {
if m.sequence+1 < selfMessages {
a.Send(message{producers, m.sequence + 1})
}
} else if m.sequence == 7 {
panic(m.producer)
}
}, WithFixedCapacity[message](4), WithBatchSize[message](8), WithDispatcher[message](d),
WithPanicHandler(func(m message, value any, stack []byte) {
panics++
if value != m.producer || len(stack) == 0 {
t.Error("async panic callback lost message, panic value, or stack")
}
if m.producer == 0 {
panic("callback failed")
}
}))
a.Send(message{producer: -1})
await(t, entered)
var senders sync.WaitGroup
for producer := range producers {
senders.Add(1)
go func() {
defer senders.Done()
for sequence := range messages {
a.Send(message{producer, sequence})
}
}()
}
sendersDone := make(chan struct{})
go func() {
senders.Wait()
close(sendersDone)
}()
await(t, sendersDone)
if got := submitted.Load(); got != 1 {
t.Errorf("queued/running async task submitted %d times", got)
}
close(release)
awaitTasks := func() {
done := make(chan struct{})
go func() {
tasks.Wait()
close(done)
}()
await(t, done)
}
// Wait for the task itself to return, not just its final handler, before
// inspecting ownership and the consumer's ordinary state.
awaitTasks()
if handled != producers*messages+selfMessages || panics != producers {
t.Errorf("async drain: handled=%d panics=%d", handled, panics)
}
for producer := range producers {
for sequence := range messages {
if !seen[producer][sequence] {
t.Fatalf("async missing producer=%d sequence=%d", producer, sequence)
}
}
}
if last[producers] != selfMessages-1 {
t.Error("async self-send chain did not finish")
}
a.Send(message{0, messages})
awaitTasks()
if submitted.Load() != 2 || handled != producers*messages+selfMessages+1 {
t.Error("async next wave did not schedule and finish")
}
if a.mailbox.running.Load() || !a.mailbox.queue.empty() {
t.Error("async task returned with consumption ownership or queued messages")
}
for i := range a.mailbox.queue.fixed {
n := &a.mailbox.queue.fixed[i]
if n.value != (message{}) || n.next != nil {
t.Errorf("async fixed node %d retains a message or link", i)
}
}
}
type nilSafeDispatcher struct{}
func (*nilSafeDispatcher) Schedule(task func()) { task() }
func TestCustomTypedNilDispatcherUsesItsContract(t *testing.T) {
var dispatcher *nilSafeDispatcher
handled := false
a := New(func(int) { handled = true }, WithDispatcher[int](dispatcher))
a.Send(0)
if !handled {
t.Error("valid typed-nil dispatcher was not called")
}
}
func TestInlineFixedStorageSendDoesNotAllocate(t *testing.T) {
a := New(func(int) {}, WithFixedCapacity[int](4))
if allocations := testing.AllocsPerRun(1000, func() { a.Send(1) }); allocations != 0 {
t.Errorf("light-load inline Send allocated %v times per call", allocations)
}
}