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

481 lines
15 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/event"
)
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
// any residual raw access to tab.sessionLease shows up as a data race, and the
// final acquire asserts no lease leaked through an interleaving.
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
key := sessionRuntimeKey(path)
tabA := &WorkspaceTab{ID: "a"}
tabB := &WorkspaceTab{ID: "b"}
const iterations = 300
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
_ = tabA.ensureSessionLease(path)
_ = tabA.sessionLeaseRuntimeKey()
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
// The applyRuntimeTab transfer shape: move A's lease to B and back.
tabB.adoptSessionLease(tabA.takeSessionLease())
tabA.adoptSessionLease(tabB.takeSessionLease())
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
tabA.releaseSessionLease()
}
}()
wg.Wait()
tabA.releaseSessionLease()
tabB.releaseSessionLease()
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
}
lease.Release()
}
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
// lease ownership through the locked helpers and the visible tab keeps none.
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-transfer.jsonl")
key := sessionRuntimeKey(path)
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
t.Cleanup(func() {
tab.releaseSessionLease()
for _, d := range app.detachedSessions {
d.releaseSessionLease()
}
})
if !app.detachRuntimeForReplacement(tab) {
t.Fatal("detachRuntimeForReplacement failed for a live tab")
}
detached := app.detachedSessions[key]
if detached == nil {
t.Fatal("detached runtime was not registered")
}
if got := detached.sessionLeaseRuntimeKey(); got != key {
t.Fatalf("detached clone lease key = %q, want %q", got, key)
}
if got := tab.sessionLeaseRuntimeKey(); got != "" {
t.Fatalf("visible tab still holds lease key %q after detach", got)
}
}
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
// CloseTab already unlinked must not be re-published into detachedSessions
// (the "session resurrects" class, #4384).
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-removed.jsonl")
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
if app.detachRuntimeForReplacement(removed) {
t.Fatal("removed tab was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("removed tab left an entry in detachedSessions")
}
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
if app.detachRuntimeForReplacement(orphan) {
t.Fatal("tab absent from a.tabs was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("orphan tab left an entry in detachedSessions")
}
}
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
// -race; before setBinding existed the tabID write raced every Emit.
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
sink := &tabEventSink{tabID: "before"}
const iterations = 500
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
sink.clearContext()
}
}()
wg.Wait()
if tabID, _ := sink.binding(); tabID == "" {
t.Fatal("sink lost its tab binding")
}
}
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
// buildGeneration to strand any in-flight async build, so its swap (and every
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
// and rely on runtimeRebuildMu instead.
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
tab := &WorkspaceTab{ID: "tab"}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
app.mu.Lock()
tab.buildGeneration = 3
app.mu.Unlock()
if app.tabBuildSuperseded(tab, 3) {
t.Fatal("build with the current generation must not be superseded")
}
app.mu.Lock()
tab.buildGeneration++ // the rebind-side invalidation
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 3) {
t.Fatal("stale-generation build must be superseded after a rebind bump")
}
if app.tabBuildSuperseded(tab, 0) {
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
}
app.mu.Lock()
tab.removed = true
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 0) {
t.Fatal("removed tab must supersede every build, including synchronous ones")
}
}
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
// release the lease bound to their own path; a mismatched key (the rebind's
// replacement session) must be left untouched.
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
pathA := filepath.Join(dir, "lease-key-a.jsonl")
pathB := filepath.Join(dir, "lease-key-b.jsonl")
tab := &WorkspaceTab{ID: "tab"}
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(pathB); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
t.Fatal("mismatched key released the tab's live lease")
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
if err != nil {
t.Fatalf("matching key did not release the lease: %v", err)
}
lease.Release()
}
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
// interleaving from the #5968 review: a stale async build cleans up after a
// rebind's replacement build already published its own SharedHostKey and
// session lease on the same live tab. The stale build must drop only its own
// host reference and lease, never the tab's.
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldPath := filepath.Join(dir, "superseded-old.jsonl")
newPath := filepath.Join(dir, "superseded-new.jsonl")
newKey := sessionRuntimeKey(newPath)
app := &App{tabs: map[string]*WorkspaceTab{}}
// The stale build and the replacement build each hold one reference on
// the same workspace-root host (the common same-root rebind).
app.acquireSharedHost("root")
app.acquireSharedHost("root")
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
app.tabs["tab"] = tab
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(newPath); err != nil {
t.Fatalf("ensureSessionLease(new): %v", err)
}
// Stale build abandons with ITS key material: the old lease path and the
// root key it acquired.
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
app.mu.RLock()
hostKey := tab.SharedHostKey
app.mu.RUnlock()
if hostKey != "root" {
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
}
app.sharedHostsMu.Lock()
entry := app.sharedHosts["root"]
refs := 0
if entry != nil {
refs = entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
}
// Removal shape: the abandoned build's own key still on the tab is the
// last reference and must be released.
app.abandonSupersededBuild(tab, nil, "", newKey)
lease, err := agent.TryAcquireSessionLease(newKey)
if err != nil {
t.Fatalf("matching-key abandon did not release the lease: %v", err)
}
lease.Release()
}
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
// review scenario: an async startTabControllerBuild is stalled right after
// binding its session lease (holding its controller, lease, and shared-host
// reference), a rebind to a different session starts meanwhile, and the
// stalled build then tries to finish. The rebind must bump the generation
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
// superseded branches: the rebound controller must survive un-overwritten,
// the stale build's lease and host reference must be released, and the
// replacement build's ownership must stay intact.
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
writeHistoryTestSession(t, oldPath, "old prompt")
writeHistoryTestSession(t, newPath, "new prompt")
oldKey := sessionRuntimeKey(oldPath)
newKey := sessionRuntimeKey(newPath)
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: oldPath,
sink: &tabEventSink{tabID: "tab", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
app.mu.RLock()
ctrl := tab.Ctrl
app.mu.RUnlock()
if ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
// Stall the async build inside its lease bind: at that point it already
// holds its controller, its lease, and its shared-host reference.
stalled := make(chan struct{})
releaseHook := make(chan struct{})
var once sync.Once
sessionLeaseAcquireHookForTest = func() {
once.Do(func() {
close(stalled)
<-releaseHook
})
}
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
t.Cleanup(func() {
select {
case <-releaseHook:
default:
close(releaseHook)
}
})
// startTabControllerBuild only backgrounds the build when a Wails context
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
// nil-ctx emit guards are what every other build test relies on too).
buildCtx, cancel := context.WithCancel(context.Background())
app.mu.Lock()
tab.buildGeneration++
generation := tab.buildGeneration
tab.buildCancel = cancel
app.mu.Unlock()
buildDone := make(chan struct{})
go func() {
defer close(buildDone)
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
}()
select {
case <-stalled:
case <-time.After(15 * time.Second):
t.Fatal("async build did not reach the lease bind")
}
loaded, err := agent.LoadSession(newPath)
if err != nil {
t.Fatalf("LoadSession(new): %v", err)
}
rebindErr := make(chan error, 1)
go func() {
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
}()
// Wait until the rebind's validation section has invalidated the async
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
// while the async build is still stalled pre-swap.
deadline := time.Now().Add(15 * time.Second)
for {
app.mu.RLock()
generation := tab.buildGeneration
app.mu.RUnlock()
if generation >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatal("rebind did not bump the build generation")
}
time.Sleep(time.Millisecond)
}
close(releaseHook)
select {
case err := <-rebindErr:
if err != nil {
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("rebind did not finish after releasing the stalled build")
}
select {
case <-buildDone:
case <-time.After(30 * time.Second):
t.Fatal("stale async build did not finish its superseded cleanup")
}
app.mu.RLock()
builtCtrl := tab.Ctrl
sessionPath := tab.SessionPath
app.mu.RUnlock()
if builtCtrl == nil {
t.Fatal("rebind left the tab without a controller")
}
if got := builtCtrl.SessionPath(); got != newPath {
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
}
if sessionPath != newPath {
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
}
// The stale build's lease must be gone (released by its superseded
// cleanup or replaced by the rebound build's adopt) — the old session
// must be acquirable again.
lease, err := agent.TryAcquireSessionLease(oldKey)
if err != nil {
t.Fatalf("stale build leaked its session lease: %v", err)
}
lease.Release()
// Exactly one shared-host reference may remain: the rebound build's. The
// stale build must have released its own.
app.sharedHostsMu.Lock()
refs := 0
for _, entry := range app.sharedHosts {
refs += entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
}
}
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
isolateDesktopUserDirs(t)
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
app := &App{
tabs: map[string]*WorkspaceTab{"tab": tab},
tabOrder: []string{"tab"},
activeTabID: "tab",
}
const iterations = 100
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < iterations; i++ {
app.mu.Lock()
tab.Ready = !tab.Ready
tab.Label = fmt.Sprintf("model-%d", i)
tab.StartupErr = ""
tab.model = fmt.Sprintf("provider/m%d", i)
tab.goal = fmt.Sprintf("goal-%d", i)
tab.tokenMode = "full"
app.mu.Unlock()
}
}()
for i := 0; i < iterations; i++ {
meta := app.MetaForTab("tab")
if meta.EventChannel == "" {
t.Fatal("MetaForTab returned zero meta for a live tab")
}
}
<-done
}