| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/control" |
| 12 | ) |
| 13 | |
| 14 | // The lease registry folds session paths through agent.CanonicalSessionPath |
| 15 | // (lowercased on Windows). Every desktop "same session" comparison must fold |
| 16 | // the same way, or a lease the tab itself holds looks foreign and every |
| 17 | // model/effort/token switch self-locks with the "already open in another |
| 18 | // window" error (#5999, #6006, #5996). |
| 19 | |
| 20 | func TestSessionRuntimeKeyMatchesLeaseKey(t *testing.T) { |
| 21 | dir := t.TempDir() |
| 22 | // Mixed-case segment on every platform; on Windows the lease key folds it. |
| 23 | path := filepath.Join(dir, "Sessions-Dir", "20260705-Test.jsonl") |
| 24 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 25 | t.Fatalf("mkdir: %v", err) |
| 26 | } |
| 27 | |
| 28 | lease, err := agent.TryAcquireSessionLease(path) |
| 29 | if err != nil { |
| 30 | t.Fatalf("acquire lease: %v", err) |
| 31 | } |
| 32 | defer lease.Release() |
| 33 | |
| 34 | if got, want := sessionRuntimeKey(lease.Path()), sessionRuntimeKey(path); got != want { |
| 35 | t.Fatalf("lease path key %q != session path key %q; lease reuse checks will self-lock", got, want) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestSessionRuntimeKeyIsIdempotent(t *testing.T) { |
| 40 | dir := t.TempDir() |
| 41 | path := filepath.Join(dir, "Case-Mix", "20260705-Test.jsonl") |
| 42 | key := sessionRuntimeKey(path) |
| 43 | if key == "" { |
| 44 | t.Fatal("empty key for non-empty path") |
| 45 | } |
| 46 | if again := sessionRuntimeKey(key); again != key { |
| 47 | t.Fatalf("sessionRuntimeKey not idempotent: %q -> %q", key, again) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestSessionRuntimeKeyFoldsCaseOnWindows(t *testing.T) { |
| 52 | if runtime.GOOS != "windows" { |
| 53 | t.Skip("case-insensitive path identity is Windows-only") |
| 54 | } |
| 55 | dir := t.TempDir() |
| 56 | path := filepath.Join(dir, "Sessions", "20260705-Test.jsonl") |
| 57 | upper := strings.ToUpper(path) |
| 58 | if sessionRuntimeKey(path) != sessionRuntimeKey(upper) { |
| 59 | t.Fatalf("case variants of one file produced distinct keys: %q vs %q", |
| 60 | sessionRuntimeKey(path), sessionRuntimeKey(upper)) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestCanonicalTabSessionPathNormalizesOutsideSessionDir(t *testing.T) { |
| 65 | // Project-scope sessions live outside config.SessionDir(); the fallback |
| 66 | // must still clean the shape so one file cannot split into two keys. |
| 67 | dir := t.TempDir() |
| 68 | base := filepath.Join(dir, "projects", "p1", "sessions", "20260705-a.jsonl") |
| 69 | messy := filepath.Join(dir, "projects", "p1", ".", "sessions") + string(filepath.Separator) + "20260705-a.jsonl" |
| 70 | if sessionRuntimeKey(base) != sessionRuntimeKey(messy) { |
| 71 | t.Fatalf("path shape variants split keys: %q vs %q", |
| 72 | sessionRuntimeKey(base), sessionRuntimeKey(messy)) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestEnsureSessionLeaseReusesHeldLease(t *testing.T) { |
| 77 | dir := t.TempDir() |
| 78 | path := filepath.Join(dir, "Sessions", "20260705-Reuse.jsonl") |
| 79 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 80 | t.Fatalf("mkdir: %v", err) |
| 81 | } |
| 82 | |
| 83 | tab := &WorkspaceTab{ID: "tab_reuse"} |
| 84 | if err := tab.ensureSessionLease(path); err != nil { |
| 85 | t.Fatalf("first ensure: %v", err) |
| 86 | } |
| 87 | defer tab.releaseSessionLease() |
| 88 | |
| 89 | acquires := 0 |
| 90 | sessionLeaseAcquireHookForTest = func() { acquires++ } |
| 91 | defer func() { sessionLeaseAcquireHookForTest = nil }() |
| 92 | |
| 93 | // Same path again — and the lease's own (canonical) form: both must hit |
| 94 | // the fast path instead of re-acquiring against our own registry entry. |
| 95 | if err := tab.ensureSessionLease(path); err != nil { |
| 96 | t.Fatalf("re-ensure same path: %v", err) |
| 97 | } |
| 98 | if err := tab.ensureSessionLease(tab.sessionLeaseRuntimeKey()); err != nil { |
| 99 | t.Fatalf("re-ensure canonical form: %v", err) |
| 100 | } |
| 101 | if acquires != 0 { |
| 102 | t.Fatalf("expected fast-path reuse, got %d new acquires", acquires) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestCanReclaimAllowsMissingLeaseInfo(t *testing.T) { |
| 107 | // lease.json deleted or torn: Info is nil but nothing actually holds the |
| 108 | // session. The reclaim attempt must be allowed — the OS lock arbitrates — |
| 109 | // instead of wedging the session as busy on missing metadata (#5999's |
| 110 | // fresh-install variant). |
| 111 | app := NewApp() |
| 112 | tab := &WorkspaceTab{ID: "tab_nil_info"} |
| 113 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 114 | path := filepath.Join(t.TempDir(), "Sessions", "20260705-a.jsonl") |
| 115 | |
| 116 | errNil := &agent.SessionLeaseError{Path: path} |
| 117 | if !app.canReclaimCurrentProcessSessionLease(tab, path, errNil) { |
| 118 | t.Fatal("nil-Info lease error must allow a reclaim attempt") |
| 119 | } |
| 120 | |
| 121 | foreign := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{ |
| 122 | SessionPath: path, WriterID: "other-host-1-deadbeef", PID: os.Getpid() + 1, |
| 123 | }} |
| 124 | if app.canReclaimCurrentProcessSessionLease(tab, path, foreign) { |
| 125 | t.Fatal("foreign-holder lease error must not allow reclaim") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestCanReclaimRejectsDetachedRuntimeOwner(t *testing.T) { |
| 130 | app := NewApp() |
| 131 | tab := &WorkspaceTab{ID: "visible"} |
| 132 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 133 | path := filepath.Join(t.TempDir(), "Sessions", "detached-owner.jsonl") |
| 134 | ctrl := control.New(control.Options{ |
| 135 | SessionDir: filepath.Dir(path), |
| 136 | SessionPath: path, |
| 137 | Label: "detached", |
| 138 | }) |
| 139 | t.Cleanup(ctrl.Close) |
| 140 | app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{ |
| 141 | ID: "detached", |
| 142 | SessionPath: path, |
| 143 | Ctrl: ctrl, |
| 144 | } |
| 145 | err := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{ |
| 146 | SessionPath: path, |
| 147 | WriterID: agent.SessionWriterID(), |
| 148 | PID: os.Getpid(), |
| 149 | }} |
| 150 | |
| 151 | if app.canReclaimCurrentProcessSessionLease(tab, path, err) { |
| 152 | t.Fatal("detached runtime owner must block same-process lease reclaim") |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestSessionLeaseBusyErrorOmitsSettingClause(t *testing.T) { |
| 157 | generic := (&sessionLeaseBusyError{}).Error() |
| 158 | if strings.Contains(generic, "before changing") { |
| 159 | t.Fatalf("empty-setting busy error still names a setting: %q", generic) |
| 160 | } |
| 161 | withSetting := (&sessionLeaseBusyError{setting: "model"}).Error() |
| 162 | if !strings.Contains(withSetting, "before changing model") { |
| 163 | t.Fatalf("setting busy error lost its clause: %q", withSetting) |
| 164 | } |
| 165 | } |
| 166 |