| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | func TestRemoteResumeBusyKeepsCurrentSessionReady(t *testing.T) { |
| 11 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}}) |
| 12 | kernel := &fakeRemoteKernel{ |
| 13 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 14 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 15 | } |
| 16 | seedBridgeTestHost(t, "box") |
| 17 | a := &App{remoteRuntime: kernel} |
| 18 | cleanupRemoteTabPumps(t, a) |
| 19 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 20 | fs.mu.Lock() |
| 21 | fs.failEnter = "cannot resume while a turn is running" |
| 22 | fs.mu.Unlock() |
| 23 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "saved"}); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | waitForRemoteTabError(t, a, meta.ID, "Finish the current turn") |
| 27 | a.remoteTabMu.Lock() |
| 28 | state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err |
| 29 | a.remoteTabMu.Unlock() |
| 30 | if state != "ready" || !strings.Contains(message, "Finish the current turn") { |
| 31 | t.Fatalf("busy resume state/error = %q/%q, want ready non-terminal notice", state, message) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func TestRemoteResumeRejectedKeepsCurrentSessionReady(t *testing.T) { |
| 36 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}}) |
| 37 | kernel := &fakeRemoteKernel{ |
| 38 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 39 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 40 | } |
| 41 | seedBridgeTestHost(t, "box") |
| 42 | a := &App{remoteRuntime: kernel} |
| 43 | cleanupRemoteTabPumps(t, a) |
| 44 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 45 | fs.mu.Lock() |
| 46 | fs.failEnter = "session is already leased by another process" |
| 47 | fs.mu.Unlock() |
| 48 | a.resumeRemoteTabSession(meta.ID, "saved") |
| 49 | a.remoteTabMu.Lock() |
| 50 | state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err |
| 51 | a.remoteTabMu.Unlock() |
| 52 | if state != "ready" || !strings.Contains(message, "already leased") { |
| 53 | t.Fatalf("rejected resume state/error = %q/%q, want ready action error", state, message) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestRemoteResumeRejectedRestoresForegroundRoute(t *testing.T) { |
| 58 | const oldPath = "/old.jsonl" |
| 59 | const targetPath = "/target.jsonl" |
| 60 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 61 | {Name: "old", Path: oldPath, Current: true}, |
| 62 | {Name: "target", Path: targetPath}, |
| 63 | }) |
| 64 | kernel := &fakeRemoteKernel{ |
| 65 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 66 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 67 | } |
| 68 | seedBridgeTestHost(t, "box") |
| 69 | a := &App{remoteRuntime: kernel} |
| 70 | cleanupRemoteTabPumps(t, a) |
| 71 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{}) |
| 72 | fs.mu.Lock() |
| 73 | fs.failEnter = "session is already leased by another process" |
| 74 | fs.mu.Unlock() |
| 75 | a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target") |
| 76 | a.remoteTabMu.Lock() |
| 77 | got := a.remoteTabs[meta.ID].routing.currentPath |
| 78 | a.remoteTabMu.Unlock() |
| 79 | if got != oldPath { |
| 80 | t.Fatalf("foreground route after rejected resume = %q, want %q", got, oldPath) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestRemoteResumeBuffersTargetFramesUntilPostCommit(t *testing.T) { |
| 85 | const oldPath = "/old.jsonl" |
| 86 | const targetPath = "/target.jsonl" |
| 87 | feed := make(chan string, 4) |
| 88 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{ |
| 89 | {Name: "old", Path: oldPath, Current: true}, |
| 90 | {Name: "target", Path: targetPath, Running: true}, |
| 91 | }) |
| 92 | fs.mu.Lock() |
| 93 | fs.eventFrames = []string{`{"kind":"ready","sessionPath":"/old.jsonl"}`} |
| 94 | fs.eventFeed = feed |
| 95 | fs.resumeStarted = make(chan string, 1) |
| 96 | fs.resumeRelease = make(chan struct{}) |
| 97 | started, release := fs.resumeStarted, fs.resumeRelease |
| 98 | fs.mu.Unlock() |
| 99 | t.Cleanup(func() { |
| 100 | select { |
| 101 | case <-release: |
| 102 | default: |
| 103 | close(release) |
| 104 | } |
| 105 | }) |
| 106 | kernel := &fakeRemoteKernel{ |
| 107 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 108 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 109 | } |
| 110 | seedBridgeTestHost(t, "box") |
| 111 | log := &eventLog{} |
| 112 | a := &App{remoteRuntime: kernel, remoteEventHook: log.add} |
| 113 | cleanupRemoteTabPumps(t, a) |
| 114 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{}) |
| 115 | eventPrefix := "remote-tab:" + meta.ID + ":event" |
| 116 | readyPrefix := "remote-tab:" + meta.ID + ":state" |
| 117 | waitForRemoteEventCount(t, log, readyPrefix, 2) |
| 118 | eventsBefore, readyBefore := log.count(eventPrefix), log.count(readyPrefix) |
| 119 | done := make(chan struct{}) |
| 120 | go func() { |
| 121 | a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target") |
| 122 | close(done) |
| 123 | }() |
| 124 | select { |
| 125 | case <-started: |
| 126 | case <-time.After(time.Second): |
| 127 | t.Fatal("resume request did not start") |
| 128 | } |
| 129 | // Until /resume commits, Serve's authoritative status is still the old |
| 130 | // foreground. A poll from the runtime watchdog must not roll the provisional |
| 131 | // target route back and create a second target ready barrier. |
| 132 | _, _ = a.RemoteTabStatus(meta.ID) |
| 133 | a.remoteTabMu.Lock() |
| 134 | provisionalPath := a.remoteTabs[meta.ID].routing.currentPath |
| 135 | rehydratingPath := a.remoteTabs[meta.ID].routing.rehydratingPath |
| 136 | a.remoteTabMu.Unlock() |
| 137 | if provisionalPath != targetPath || rehydratingPath != targetPath { |
| 138 | t.Fatalf("old status rolled back provisional route: current/rehydrating = %q/%q", provisionalPath, rehydratingPath) |
| 139 | } |
| 140 | feed <- `{"kind":"approval_request","approval":{"id":"target-approval"},"sessionPath":"/target.jsonl","sessionCurrent":true}` |
| 141 | feed <- `{"kind":"text","text":"first retained delta","sessionPath":"/target.jsonl","sessionCurrent":true}` |
| 142 | feed <- `{"kind":"notice","text":"second retained notice","sessionPath":"/target.jsonl","sessionCurrent":true}` |
| 143 | deadline := time.Now().Add(time.Second) |
| 144 | for { |
| 145 | a.remoteTabMu.Lock() |
| 146 | pending := len(a.remoteTabs[meta.ID].pendingEvents) |
| 147 | buffered := len(a.remoteTabs[meta.ID].routing.rehydratingFrames) |
| 148 | a.remoteTabMu.Unlock() |
| 149 | if pending == 1 && buffered == 3 { |
| 150 | break |
| 151 | } |
| 152 | select { |
| 153 | case <-done: |
| 154 | t.Fatal("resume returned before the test released its response") |
| 155 | default: |
| 156 | } |
| 157 | if time.Now().After(deadline) { |
| 158 | t.Fatalf("target frames were not retained while /resume was pending: pending=%d buffered=%d log=%v", pending, buffered, log.recorded()) |
| 159 | } |
| 160 | time.Sleep(time.Millisecond) |
| 161 | } |
| 162 | if got := log.count(eventPrefix); got != eventsBefore { |
| 163 | t.Fatalf("provisional target frame reached the old transcript: events %d -> %d, log=%v", eventsBefore, got, log.recorded()) |
| 164 | } |
| 165 | close(release) |
| 166 | select { |
| 167 | case <-done: |
| 168 | case <-time.After(time.Second): |
| 169 | t.Fatal("resume did not finish") |
| 170 | } |
| 171 | if got := log.count(readyPrefix); got != readyBefore+1 { |
| 172 | t.Fatalf("committed resume emitted %d ready barriers, want %d: %v", got, readyBefore+1, log.recorded()) |
| 173 | } |
| 174 | events := log.recorded() |
| 175 | readyIndex, approvalIndex, firstIndex, secondIndex := -1, -1, -1, -1 |
| 176 | for i, got := range events { |
| 177 | if strings.HasPrefix(got, readyPrefix+" ") { |
| 178 | readyIndex = i |
| 179 | } |
| 180 | if strings.Contains(got, `"id":"target-approval"`) { |
| 181 | approvalIndex = i |
| 182 | } |
| 183 | if strings.Contains(got, `"text":"first retained delta"`) { |
| 184 | firstIndex = i |
| 185 | } |
| 186 | if strings.Contains(got, `"text":"second retained notice"`) { |
| 187 | secondIndex = i |
| 188 | } |
| 189 | } |
| 190 | if readyIndex < 0 || approvalIndex <= readyIndex || firstIndex <= approvalIndex || secondIndex <= firstIndex { |
| 191 | t.Fatalf("buffered target frames were not replayed in order after ready: %v", events) |
| 192 | } |
| 193 | a.remoteTabMu.Lock() |
| 194 | pending := len(a.remoteTabs[meta.ID].pendingEvents) |
| 195 | rehydrating := a.remoteTabs[meta.ID].routing.rehydratingPath |
| 196 | a.remoteTabMu.Unlock() |
| 197 | if pending != 1 || rehydrating != "" { |
| 198 | t.Fatalf("committed target pending/rehydrating = %d/%q, want 1/empty", pending, rehydrating) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestRemoteNewBusyKeepsCurrentSessionReady(t *testing.T) { |
| 203 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved", Current: true}}) |
| 204 | kernel := &fakeRemoteKernel{ |
| 205 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 206 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 207 | } |
| 208 | seedBridgeTestHost(t, "box") |
| 209 | a := &App{remoteRuntime: kernel} |
| 210 | cleanupRemoteTabPumps(t, a) |
| 211 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "saved"}) |
| 212 | a.remoteTabMu.Lock() |
| 213 | previousTitle := a.remoteTabs[meta.ID].topicTitle |
| 214 | previousSession := a.remoteTabs[meta.ID].session |
| 215 | previousRoute := a.remoteTabs[meta.ID].routing.currentPath |
| 216 | previousRuntime := a.remoteTabs[meta.ID].runtime |
| 217 | a.remoteTabMu.Unlock() |
| 218 | fs.mu.Lock() |
| 219 | fs.failEnter = "cannot start a new session while a turn is running" |
| 220 | fs.mu.Unlock() |
| 221 | if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}); err == nil || !strings.Contains(err.Error(), "while a turn is running") { |
| 222 | t.Fatalf("busy new-session error = %v", err) |
| 223 | } |
| 224 | a.remoteTabMu.Lock() |
| 225 | tab := a.remoteTabs[meta.ID] |
| 226 | state, message, title, client := tab.state, tab.err, tab.topicTitle, tab.client |
| 227 | session, route, runtime := tab.session, tab.routing.currentPath, tab.runtime |
| 228 | a.remoteTabMu.Unlock() |
| 229 | if state != "ready" || message != "" || title != previousTitle || client == nil { |
| 230 | t.Fatalf("busy new-session state/error/title/client = %q/%q/%q/%v, want ready current attachment", state, message, title, client) |
| 231 | } |
| 232 | if session != previousSession || route != previousRoute || !reflect.DeepEqual(runtime, previousRuntime) { |
| 233 | t.Fatalf("busy new-session changed current identity/runtime: session=%+v route=%q runtime=%+v", session, route, runtime) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func TestRemoteResumeLeaseConflictFailsAttach(t *testing.T) { |
| 238 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}}) |
| 239 | fs.mu.Lock() |
| 240 | fs.failEnter = "this session is in use by another Reasonix window or process" |
| 241 | fs.mu.Unlock() |
| 242 | kernel := &fakeRemoteKernel{ |
| 243 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 244 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 245 | } |
| 246 | seedBridgeTestHost(t, "box") |
| 247 | a := &App{remoteRuntime: kernel} |
| 248 | cleanupRemoteTabPumps(t, a) |
| 249 | meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "saved"}) |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | waitForTabState(t, a, meta.ID, "error") |
| 254 | a.remoteTabMu.Lock() |
| 255 | state, message, client := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err, a.remoteTabs[meta.ID].client |
| 256 | a.remoteTabMu.Unlock() |
| 257 | if state != "error" || !strings.Contains(message, "session is in use") || client != nil { |
| 258 | t.Fatalf("lease-conflict attach state/error/client = %q/%q/%v", state, message, client) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestRemoteResumeListFailureKeepsCurrentAttachmentReady(t *testing.T) { |
| 263 | fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}}) |
| 264 | kernel := &fakeRemoteKernel{ |
| 265 | statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, |
| 266 | ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret", |
| 267 | } |
| 268 | seedBridgeTestHost(t, "box") |
| 269 | a := &App{remoteRuntime: kernel} |
| 270 | cleanupRemoteTabPumps(t, a) |
| 271 | meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true}) |
| 272 | fs.mu.Lock() |
| 273 | fs.failSessions = true |
| 274 | fs.mu.Unlock() |
| 275 | a.resumeRemoteTabSession(meta.ID, "saved") |
| 276 | a.remoteTabMu.Lock() |
| 277 | state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err |
| 278 | a.remoteTabMu.Unlock() |
| 279 | if state != "ready" || !strings.Contains(message, "Could not open remote session") { |
| 280 | t.Fatalf("list failure state/error = %q/%q, want ready non-terminal notice", state, message) |
| 281 | } |
| 282 | } |
| 283 |