返回 DeepSeek-Reasonix
sessions_lease_guard_test.go
根目录 / desktop / sessions_lease_guard_test.go
1 //go:build !windows
2
3 package main
4
5 import (
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "golang.org/x/sys/unix"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 )
18
19 // simulateForeignSessionLeaseHolder holds path's lease the way another process
20 // would: the on-disk info names a foreign writer and a raw flock on a separate
21 // fd keeps the lock file locked without registering in this process's
22 // in-process lease bookkeeping (flock excludes other fds even within one
23 // process, so this faithfully mimics a foreign holder).
24 func simulateForeignSessionLeaseHolder(t *testing.T, path string) {
25 t.Helper()
26 if err := agent.SaveSessionLeaseInfo(path, agent.SessionLeaseInfo{
27 SessionPath: path,
28 WriterID: "other-host-4242-cafebabe",
29 PID: os.Getpid() + 1,
30 AcquiredAt: time.Now().UTC(),
31 }); err != nil {
32 t.Fatalf("SaveSessionLeaseInfo: %v", err)
33 }
34 f, err := os.OpenFile(path+".lease.lock", os.O_CREATE|os.O_RDWR, 0o600)
35 if err != nil {
36 t.Fatalf("open lease lock: %v", err)
37 }
38 if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
39 _ = f.Close()
40 t.Fatalf("flock lease lock: %v", err)
41 }
42 t.Cleanup(func() {
43 _ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
44 _ = f.Close()
45 _ = os.Remove(path + ".lease.lock")
46 _ = os.Remove(path + ".lease.json")
47 })
48 }
49
50 func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T) {
51 isolateDesktopUserDirs(t)
52
53 dir := config.SessionDir()
54 if err := os.MkdirAll(dir, 0o755); err != nil {
55 t.Fatalf("mkdir session dir: %v", err)
56 }
57 path := filepath.Join(dir, "duplicate-foreign-held.jsonl")
58 content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
59 if err := os.WriteFile(path, content, 0o644); err != nil {
60 t.Fatalf("write live session: %v", err)
61 }
62 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
63 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
64 t.Fatalf("create trash dir: %v", err)
65 }
66 if err := os.WriteFile(trashPath, content, 0o644); err != nil {
67 t.Fatalf("write trash session: %v", err)
68 }
69 simulateForeignSessionLeaseHolder(t, path)
70
71 activePath := filepath.Join(dir, "active.jsonl")
72 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
73 t.Fatalf("write active session: %v", err)
74 }
75 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
76 defer activeCtrl.Close()
77 app := &App{
78 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
79 activeTabID: "active",
80 tabOrder: []string{"active"},
81 }
82
83 err := app.DeleteSession(filepath.Base(path))
84 if err == nil || !strings.Contains(err.Error(), errSessionBusyElsewhere.Error()) {
85 t.Fatalf("DeleteSession err = %v, want refusal while a foreign runtime holds the lease", err)
86 }
87 if got, readErr := os.ReadFile(path); readErr != nil || string(got) != string(content) {
88 t.Fatalf("live session must stay intact, got %q err=%v", string(got), readErr)
89 }
90 }
91
92 // TestEnsureBlankTabSkipsIndexedTopicHeldByForeignRuntime reproduces #6028:
93 // a blank topic whose session lease is still held by another runtime (a
94 // lingering background/tray-hidden instance, or a leftover from a crash) must
95 // not be handed back to a "new conversation" click. Reusing it would collide
96 // the new tab with that holder, so every lease-gated switch (effort, model,
97 // token mode) would fail as "open in another Reasonix window" no matter how
98 // many times the user retries — because it keeps re-picking the same stuck
99 // topic instead of ever landing on a fresh one.
100 func TestEnsureBlankTabSkipsIndexedTopicHeldByForeignRuntime(t *testing.T) {
101 isolateDesktopUserDirs(t)
102
103 app := NewApp()
104 stuckTopic, err := app.CreateTopic("global", "", "")
105 if err != nil {
106 t.Fatalf("create topic: %v", err)
107 }
108 globalRoot := globalWorkspaceRoot()
109 dir := desktopSessionDir(globalRoot)
110 if err := os.MkdirAll(dir, 0o755); err != nil {
111 t.Fatalf("mkdir sessions: %v", err)
112 }
113 stubPath := filepath.Join(dir, "stuck-blank-stub.jsonl")
114 if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
115 t.Fatalf("write empty stub: %v", err)
116 }
117 now := time.Now()
118 if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
119 CreatedAt: now.Add(-time.Minute),
120 UpdatedAt: now,
121 Scope: "global",
122 WorkspaceRoot: globalRoot,
123 TopicID: stuckTopic.ID,
124 TopicTitle: defaultTopicTitle,
125 }); err != nil {
126 t.Fatalf("save branch meta: %v", err)
127 }
128 simulateForeignSessionLeaseHolder(t, stubPath)
129
130 meta, err := app.EnsureBlankTab("global", "")
131 if err != nil {
132 t.Fatalf("EnsureBlankTab: %v", err)
133 }
134 if meta.TopicID == stuckTopic.ID {
135 t.Fatalf("EnsureBlankTab reused topic %q even though its session is held by another runtime", stuckTopic.ID)
136 }
137 }
138
138 lines GO