Files
2026-07-13 13:00:08 +08:00

340 lines
9.2 KiB
Go

package acp
import (
"context"
"testing"
"time"
"reasonix/internal/control"
)
type snapshotLockProbeController struct {
*control.Controller
onSnapshot func()
}
func (c *snapshotLockProbeController) Snapshot() error {
if c.onSnapshot != nil {
c.onSnapshot()
}
return nil
}
func expectACPSessionMutexAvailableDuringSnapshot(t *testing.T, sess *acpSession, checks chan<- struct{}) func() {
t.Helper()
return func() {
acquired := make(chan struct{})
go func() {
sess.mu.Lock()
sess.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
close(acquired)
}()
select {
case <-acquired:
case <-time.After(500 * time.Millisecond):
t.Error("Snapshot ran while holding ACP session mutex")
}
if checks == nil {
return
}
select {
case checks <- struct{}{}:
default:
}
}
}
func TestACPPersistAfterTurnSnapshotsWithoutSessionLock(t *testing.T) {
sess := &acpSession{id: "sess-lock"}
checks := make(chan struct{}, 1)
sess.ctrl = &snapshotLockProbeController{
Controller: control.New(control.Options{}),
onSnapshot: expectACPSessionMutexAvailableDuringSnapshot(t, sess, checks),
}
sess.persistAfterTurn("hello from acp")
select {
case <-checks:
case <-time.After(time.Second):
t.Fatal("session was not snapshotted after turn")
}
if sess.title == "" {
t.Fatal("session title was not updated after turn")
}
}
func TestACPRebuildSessionSnapshotsWithoutSessionLock(t *testing.T) {
sink := newUpdateSink(&fakeNotifier{}, "sess-lock")
sess := &acpSession{
id: "sess-lock",
sink: sink,
cwd: t.TempDir(),
model: "fast",
}
checks := make(chan struct{}, 1)
oldCtrl := &snapshotLockProbeController{
Controller: control.New(control.Options{}),
onSnapshot: expectACPSessionMutexAvailableDuringSnapshot(t, sess, checks),
}
sess.ctrl = oldCtrl
svc := &service{
factory: &configurableFactory{},
sessions: map[string]*acpSession{sess.id: sess},
}
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}); err != nil {
t.Fatalf("rebuildSession: %v", err)
}
select {
case <-checks:
case <-time.After(time.Second):
t.Fatal("session was not snapshotted before rebuild")
}
if sess.ctrl == oldCtrl {
t.Fatal("session controller was not replaced")
}
if sess.model != "pro" {
t.Fatalf("session model = %q, want pro", sess.model)
}
}
type blockingConfigFactory struct {
configurableFactory
started chan string
releaseFirst chan struct{}
}
func (f *blockingConfigFactory) NewSession(ctx context.Context, p SessionParams) (*control.Controller, error) {
select {
case f.started <- p.Model:
default:
}
f.mu.Lock()
buildNumber := len(f.builds) + 1
f.mu.Unlock()
if buildNumber == 1 {
select {
case <-f.releaseFirst:
case <-ctx.Done():
return nil, ctx.Err()
}
}
return f.configurableFactory.NewSession(ctx, p)
}
func TestACPRebuildSessionAppliesPendingConfigAfterMaintenance(t *testing.T) {
sink := newUpdateSink(&fakeNotifier{}, "sess-lock")
sess := &acpSession{
id: "sess-lock",
sink: sink,
cwd: t.TempDir(),
model: "fast",
ctrl: control.New(control.Options{}),
}
factory := &blockingConfigFactory{
started: make(chan string, 2),
releaseFirst: make(chan struct{}),
}
svc := &service{
factory: factory,
sessions: map[string]*acpSession{sess.id: sess},
}
errs := make(chan error, 1)
go func() {
errs <- svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"})
}()
select {
case got := <-factory.started:
if got != "pro" {
t.Fatalf("first rebuild model = %q, want pro", got)
}
case <-time.After(time.Second):
t.Fatal("first rebuild did not start")
}
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "fast"}); err != nil {
t.Fatalf("queue pending rebuild: %v", err)
}
close(factory.releaseFirst)
select {
case err := <-errs:
if err != nil {
t.Fatalf("first rebuild: %v", err)
}
case <-time.After(time.Second):
t.Fatal("first rebuild did not finish")
}
if sess.model != "fast" {
t.Fatalf("session model = %q, want pending fast", sess.model)
}
if got := factory.buildCount(); got != 2 {
t.Fatalf("factory builds = %d, want 2", got)
}
}
// TestACPCtrlReadPathsDoNotRaceWithRebuild drives the lock-free read surfaces
// that used to read sess.ctrl outside sess.mu — info(), service.sessionDir(),
// sendAvailableCommands, and resolveSlashPrompt — while a rebuild goroutine
// keeps swapping the controller. Under -race this fails without currentCtrl().
func TestACPCtrlReadPathsDoNotRaceWithRebuild(t *testing.T) {
sink := newUpdateSink(&fakeNotifier{}, "sess-race")
sess := &acpSession{
id: "sess-race",
sink: sink,
cwd: t.TempDir(),
model: "fast",
ctrl: control.New(control.Options{}),
}
factory := &configurableFactory{}
svc := &service{
factory: factory,
sessions: map[string]*acpSession{sess.id: sess},
}
const rebuilds = 50
models := [...]string{"pro", "fast"}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < rebuilds; i++ {
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: models[i%len(models)]}); err != nil {
t.Errorf("rebuildSession %d: %v", i, err)
return
}
}
}()
for rebuilding := true; rebuilding; {
select {
case <-done:
rebuilding = false
default:
}
if got := sess.info().SessionID; got != sess.id {
t.Fatalf("info().SessionID = %q, want %q", got, sess.id)
}
_ = svc.sessionDir()
svc.sendAvailableCommands(sess)
if got := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
t.Fatalf("resolveSlashPrompt rewrote unknown command to %q", got)
}
}
if sess.currentCtrl() == nil {
t.Fatal("session controller is nil after rebuilds")
}
if got := factory.buildCount(); got != rebuilds {
t.Fatalf("factory builds = %d, want %d", got, rebuilds)
}
}
// TestACPBeginRefusesWhilePendingConfigQueued pins the invariant begin relies
// on: a session with a queued (not yet applied) config switch must not start a
// new turn, or the prompt would run on the outgoing config.
func TestACPBeginRefusesWhilePendingConfigQueued(t *testing.T) {
sess := &acpSession{id: "sess-pending", ctrl: control.New(control.Options{})}
pending := SessionConfigState{Model: "pro"}
sess.mu.Lock()
sess.pendingConfig = &pending
sess.mu.Unlock()
if _, _, ok := sess.begin(context.Background()); ok {
t.Fatal("begin succeeded while a pending config switch was queued")
}
sess.mu.Lock()
sess.pendingConfig = nil
sess.mu.Unlock()
_, cancel, ok := sess.begin(context.Background())
if !ok {
t.Fatal("begin failed on an idle session with no pending config")
}
cancel()
sess.finish()
}
// TestACPBeginRefusesDuringPendingConfigApplyWindow drives the exact
// interleaving begin used to lose: rebuildSession's defer first finishes
// maintenance (maintenanceDone back to nil) and only then applies the queued
// pendingConfig. Holding service.mu parks applyPendingSessionConfig on its
// initial s.session lookup, so the session sits in that window with the queue
// still set; begin must keep refusing until the pending config has landed.
func TestACPBeginRefusesDuringPendingConfigApplyWindow(t *testing.T) {
sink := newUpdateSink(&fakeNotifier{}, "sess-window")
sess := &acpSession{
id: "sess-window",
sink: sink,
cwd: t.TempDir(),
model: "fast",
ctrl: control.New(control.Options{}),
}
factory := &blockingConfigFactory{
started: make(chan string, 2),
releaseFirst: make(chan struct{}),
}
svc := &service{
factory: factory,
sessions: map[string]*acpSession{sess.id: sess},
}
errs := make(chan error, 1)
go func() {
errs <- svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"})
}()
select {
case <-factory.started:
case <-time.After(time.Second):
t.Fatal("first rebuild did not start")
}
// Queue a second switch while the first build is blocked in maintenance.
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "fast"}); err != nil {
t.Fatalf("queue pending rebuild: %v", err)
}
sess.mu.Lock()
maintenanceDone := sess.maintenanceDone
queued := sess.pendingConfig != nil
sess.mu.Unlock()
if maintenanceDone == nil || !queued {
t.Fatalf("maintenance in flight = %v, pending queued = %v, want both", maintenanceDone != nil, queued)
}
svc.mu.Lock()
close(factory.releaseFirst)
select {
case <-maintenanceDone: // closed after maintenanceDone is reset to nil
case <-time.After(time.Second):
svc.mu.Unlock()
t.Fatal("maintenance did not finish")
}
if _, _, ok := sess.begin(context.Background()); ok {
svc.mu.Unlock()
t.Fatal("begin succeeded between maintenance end and pending config apply; the turn would run on the outgoing config")
}
svc.mu.Unlock()
select {
case err := <-errs:
if err != nil {
t.Fatalf("first rebuild: %v", err)
}
case <-time.After(time.Second):
t.Fatal("first rebuild did not finish")
}
_, cancel, ok := sess.begin(context.Background())
if !ok {
t.Fatal("begin failed after the pending config was applied")
}
cancel()
sess.finish()
if sess.model != "fast" {
t.Fatalf("session model = %q, want pending fast", sess.model)
}
if got := factory.buildCount(); got != 2 {
t.Fatalf("factory builds = %d, want 2", got)
}
}