返回 DeepSeek-Reasonix
session_runtime.go
根目录 / desktop / session_runtime.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "os"
10 "strings"
11 "time"
12
13 "reasonix/internal/agent"
14 )
15
16 type SessionRuntimePhase string
17
18 const (
19 sessionRuntimeStarting SessionRuntimePhase = "starting"
20 sessionRuntimeReady SessionRuntimePhase = "ready"
21 sessionRuntimeLeaseBlocked SessionRuntimePhase = "lease_blocked"
22 sessionRuntimeFailed SessionRuntimePhase = "failed"
23 sessionRuntimeClosing SessionRuntimePhase = "closing"
24 )
25
26 type SessionRuntimeIssue struct {
27 Code string `json:"code"`
28 Message string `json:"message"`
29 Retryable bool `json:"retryable"`
30 HolderPID int `json:"holderPid,omitempty"`
31 HolderHost string `json:"holderHost,omitempty"`
32 AcquiredAt string `json:"acquiredAt,omitempty"`
33 }
34
35 type SessionRuntimeView struct {
36 Phase SessionRuntimePhase `json:"phase"`
37 Epoch string `json:"epoch"`
38 Issue *SessionRuntimeIssue `json:"issue,omitempty"`
39 }
40
41 // desktopSessionRuntime is the process-local ownership record for one writable
42 // session. WorkspaceTab still carries compatibility projections of controller
43 // and lease fields while the desktop code migrates, but this registry is the
44 // authority that prevents a second local build from competing for the same
45 // session path.
46 //
47 // All fields are guarded by App.mu.
48 type desktopSessionRuntime struct {
49 ID string
50 Key string
51 Epoch string
52 Phase SessionRuntimePhase
53 Issue *SessionRuntimeIssue
54 Owner *WorkspaceTab
55 readyCh chan struct{}
56 }
57
58 func newSessionRuntimeID(prefix string) string {
59 var b [12]byte
60 if _, err := rand.Read(b[:]); err == nil {
61 return prefix + "_" + hex.EncodeToString(b[:])
62 }
63 return prefix + "_" + time.Now().UTC().Format("20060102150405.000000000")
64 }
65
66 func cloneSessionRuntimeIssue(issue *SessionRuntimeIssue) *SessionRuntimeIssue {
67 if issue == nil {
68 return nil
69 }
70 copy := *issue
71 return &copy
72 }
73
74 func sessionRuntimeIssueForError(err error) *SessionRuntimeIssue {
75 if err == nil {
76 return nil
77 }
78 issue := &SessionRuntimeIssue{
79 Code: "startup_failed",
80 Message: userFacingSessionLeaseError("", err).Error(),
81 Retryable: false,
82 }
83 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
84 return issue
85 }
86 issue.Code = "session_lease_held"
87 issue.Retryable = true
88 var leaseErr *agent.SessionLeaseError
89 if errors.As(err, &leaseErr) && leaseErr != nil && leaseErr.Info != nil {
90 issue.HolderPID = leaseErr.Info.PID
91 issue.HolderHost = strings.TrimSpace(leaseErr.Info.Hostname)
92 if !leaseErr.Info.AcquiredAt.IsZero() {
93 issue.AcquiredAt = leaseErr.Info.AcquiredAt.UTC().Format(time.RFC3339)
94 }
95 }
96 return issue
97 }
98
99 func (a *App) newSessionRuntimeLocked(tab *WorkspaceTab, key string) *desktopSessionRuntime {
100 if existing := a.runtimeBySessionKey[key]; key != "" && existing != nil && existing.Owner != tab {
101 // A second tab may begin restoring the same persisted path before it
102 // reaches claimSessionRuntime. Do not overwrite the first starting
103 // placeholder; the later build will wait for and attach to it.
104 key = ""
105 }
106 rt := &desktopSessionRuntime{
107 ID: newSessionRuntimeID("runtime"),
108 Key: key,
109 Epoch: newSessionRuntimeID("epoch"),
110 Phase: sessionRuntimeStarting,
111 Owner: tab,
112 readyCh: make(chan struct{}),
113 }
114 if tab != nil && tab.Ctrl != nil && tab.Ready {
115 rt.Phase = sessionRuntimeReady
116 closeRuntimeReadyChannelLocked(rt)
117 }
118 if a.runtimeByID == nil {
119 a.runtimeByID = map[string]*desktopSessionRuntime{}
120 }
121 if a.runtimeBySessionKey == nil {
122 a.runtimeBySessionKey = map[string]*desktopSessionRuntime{}
123 }
124 a.runtimeByID[rt.ID] = rt
125 if key != "" {
126 a.runtimeBySessionKey[key] = rt
127 }
128 if tab != nil {
129 tab.runtimeID = rt.ID
130 if tab.sink != nil {
131 tab.sink.setRuntimeEpoch(rt.Epoch)
132 }
133 }
134 return rt
135 }
136
137 func (a *App) runtimeForTabLocked(tab *WorkspaceTab) *desktopSessionRuntime {
138 if tab == nil || strings.TrimSpace(tab.runtimeID) == "" {
139 return nil
140 }
141 rt := a.runtimeByID[tab.runtimeID]
142 if rt == nil || rt.Owner != tab {
143 return nil
144 }
145 return rt
146 }
147
148 func (a *App) runtimeOwnerLiveLocked(rt *desktopSessionRuntime) bool {
149 if rt == nil || rt.Owner == nil {
150 return false
151 }
152 if a.tabs[rt.Owner.ID] == rt.Owner {
153 return true
154 }
155 for _, detached := range a.detachedSessions {
156 if detached == rt.Owner {
157 return true
158 }
159 }
160 return false
161 }
162
163 func (a *App) removeSessionRuntimeMappingsLocked(rt *desktopSessionRuntime) {
164 if rt == nil {
165 return
166 }
167 for key, candidate := range a.runtimeBySessionKey {
168 if candidate == rt {
169 delete(a.runtimeBySessionKey, key)
170 }
171 }
172 delete(a.runtimeByID, rt.ID)
173 }
174
175 func closeRuntimeReadyChannelLocked(rt *desktopSessionRuntime) {
176 if rt == nil || rt.readyCh == nil {
177 return
178 }
179 close(rt.readyCh)
180 rt.readyCh = nil
181 }
182
183 func (a *App) setSessionRuntimePhaseLocked(tab *WorkspaceTab, phase SessionRuntimePhase, err error) {
184 if tab == nil {
185 return
186 }
187 rt := a.runtimeForTabLocked(tab)
188 if rt == nil {
189 key := sessionRuntimeKey(tab.SessionPath)
190 if existing := a.runtimeBySessionKey[key]; key != "" && existing != nil && existing.Owner != tab {
191 return
192 }
193 rt = a.newSessionRuntimeLocked(tab, key)
194 }
195 rt.Phase = phase
196 rt.Issue = sessionRuntimeIssueForError(err)
197 if phase == sessionRuntimeStarting {
198 rt.Issue = nil
199 if rt.readyCh == nil {
200 rt.readyCh = make(chan struct{})
201 }
202 } else {
203 closeRuntimeReadyChannelLocked(rt)
204 }
205 if tab.sink != nil {
206 tab.sink.setRuntimeEpoch(rt.Epoch)
207 }
208 }
209
210 func (a *App) advanceSessionRuntimeEpochLocked(tab *WorkspaceTab) string {
211 if tab == nil {
212 return ""
213 }
214 rt := a.runtimeForTabLocked(tab)
215 if rt == nil {
216 rt = a.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.SessionPath))
217 }
218 rt.Epoch = newSessionRuntimeID("epoch")
219 rt.Phase = sessionRuntimeReady
220 rt.Issue = nil
221 closeRuntimeReadyChannelLocked(rt)
222 if tab.sink != nil {
223 tab.sink.setRuntimeEpoch(rt.Epoch)
224 }
225 return rt.Epoch
226 }
227
228 func (a *App) sessionRuntimeViewLocked(tab *WorkspaceTab) SessionRuntimeView {
229 if tab == nil {
230 return SessionRuntimeView{Phase: sessionRuntimeStarting}
231 }
232 if rt := a.runtimeForTabLocked(tab); rt != nil {
233 return SessionRuntimeView{
234 Phase: rt.Phase,
235 Epoch: rt.Epoch,
236 Issue: cloneSessionRuntimeIssue(rt.Issue),
237 }
238 }
239 view := SessionRuntimeView{Phase: sessionRuntimeStarting}
240 switch {
241 case tab.Ctrl != nil && tab.Ready:
242 view.Phase = sessionRuntimeReady
243 case tab.StartupErrLeaseHeld:
244 view.Phase = sessionRuntimeLeaseBlocked
245 view.Issue = sessionRuntimeIssueForError(&agent.SessionLeaseError{})
246 case strings.TrimSpace(tab.StartupErr) != "":
247 view.Phase = sessionRuntimeFailed
248 view.Issue = &SessionRuntimeIssue{Code: "startup_failed", Message: tab.StartupErr}
249 }
250 return view
251 }
252
253 func (a *App) bindSessionRuntimeKeyLocked(tab *WorkspaceTab, path string) bool {
254 if tab == nil {
255 return false
256 }
257 key := sessionRuntimeKey(path)
258 if key == "" {
259 return true
260 }
261 if existing := a.runtimeBySessionKey[key]; existing != nil && existing.Owner != tab {
262 return false
263 }
264 rt := a.runtimeForTabLocked(tab)
265 if rt == nil {
266 a.newSessionRuntimeLocked(tab, key)
267 return true
268 }
269 if rt.Key != "" && rt.Key != key && a.runtimeBySessionKey[rt.Key] == rt {
270 delete(a.runtimeBySessionKey, rt.Key)
271 }
272 rt.Key = key
273 a.runtimeBySessionKey[key] = rt
274 return true
275 }
276
277 type sessionRuntimePathTransition struct {
278 runtime *desktopSessionRuntime
279 owner *WorkspaceTab
280 oldKey string
281 targetKey string
282 expectedEpoch string
283 }
284
285 func (a *App) reserveSessionRuntimePath(tab *WorkspaceTab, path string) (sessionRuntimePathTransition, error) {
286 targetKey := sessionRuntimeKey(path)
287 if tab == nil || targetKey == "" {
288 return sessionRuntimePathTransition{}, nil
289 }
290 a.mu.Lock()
291 defer a.mu.Unlock()
292 if existing := a.runtimeBySessionKey[targetKey]; existing != nil && existing.Owner != tab {
293 return sessionRuntimePathTransition{}, fmt.Errorf("%w: local runtime already owns session", agent.ErrSessionLeaseHeld)
294 }
295 rt := a.runtimeForTabLocked(tab)
296 if rt == nil {
297 // A path transition must retain the source identity until commit. Using
298 // targetKey here would make a failed first rebind forget the still-live
299 // source controller and its lease.
300 rt = a.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionPath()))
301 }
302 transition := sessionRuntimePathTransition{
303 runtime: rt,
304 owner: tab,
305 oldKey: rt.Key,
306 targetKey: targetKey,
307 expectedEpoch: rt.Epoch,
308 }
309 // Keep the old key mapped until the lease rebind succeeds. The target alias
310 // prevents another local startup from claiming it during the off-lock file
311 // operation.
312 a.runtimeBySessionKey[targetKey] = rt
313 return transition, nil
314 }
315
316 func (a *App) commitSessionRuntimePath(transition sessionRuntimePathTransition) {
317 if transition.runtime == nil || transition.targetKey == "" {
318 return
319 }
320 a.mu.Lock()
321 defer a.mu.Unlock()
322 rt := transition.runtime
323 if transition.oldKey != "" && transition.oldKey != transition.targetKey && a.runtimeBySessionKey[transition.oldKey] == rt {
324 delete(a.runtimeBySessionKey, transition.oldKey)
325 }
326 rt.Key = transition.targetKey
327 a.runtimeBySessionKey[transition.targetKey] = rt
328 }
329
330 // commitSessionRuntimePathLocked commits a previously reserved path only when
331 // the same runtime generation still owns both aliases. It lets controller
332 // swaps make the registry update part of their single App.mu commit.
333 func (a *App) commitSessionRuntimePathLocked(transition sessionRuntimePathTransition) bool {
334 if transition.runtime == nil || transition.targetKey == "" {
335 return false
336 }
337 if !a.sessionRuntimePathTransitionValidLocked(transition) {
338 return false
339 }
340 rt := transition.runtime
341 if transition.oldKey != "" && transition.oldKey != transition.targetKey && a.runtimeBySessionKey[transition.oldKey] == rt {
342 delete(a.runtimeBySessionKey, transition.oldKey)
343 }
344 rt.Key = transition.targetKey
345 a.runtimeBySessionKey[transition.targetKey] = rt
346 return true
347 }
348
349 func (a *App) sessionRuntimePathTransitionValidLocked(transition sessionRuntimePathTransition) bool {
350 if transition.runtime == nil || transition.targetKey == "" {
351 return false
352 }
353 rt := transition.runtime
354 if a.runtimeByID[rt.ID] != rt ||
355 rt.Owner != transition.owner ||
356 rt.Epoch != transition.expectedEpoch ||
357 (rt.Key != transition.oldKey && rt.Key != transition.targetKey) ||
358 a.runtimeBySessionKey[transition.targetKey] != rt {
359 return false
360 }
361 return true
362 }
363
364 func (a *App) rollbackSessionRuntimePath(transition sessionRuntimePathTransition) {
365 if transition.runtime == nil || transition.targetKey == "" || transition.targetKey == transition.oldKey {
366 return
367 }
368 a.mu.Lock()
369 if a.runtimeBySessionKey[transition.targetKey] == transition.runtime {
370 delete(a.runtimeBySessionKey, transition.targetKey)
371 }
372 a.mu.Unlock()
373 }
374
375 // claimSessionRuntime reserves path for tab, or waits for/attaches the existing
376 // local runtime. The caller owns its not-yet-published candidate controller; a
377 // true return means that candidate must be closed because tab now uses the
378 // already registered runtime.
379 func (a *App) claimSessionRuntime(tab *WorkspaceTab, path string, ctx context.Context) bool {
380 key := sessionRuntimeKey(path)
381 if tab == nil || key == "" {
382 return false
383 }
384 for {
385 a.mu.Lock()
386 if tab.removed || a.tabs[tab.ID] != tab {
387 a.mu.Unlock()
388 return false
389 }
390 rt := a.runtimeBySessionKey[key]
391 if rt != nil && !a.runtimeOwnerLiveLocked(rt) {
392 a.removeSessionRuntimeMappingsLocked(rt)
393 rt = nil
394 }
395 switch {
396 case rt == nil:
397 // Detached runtimes created before the admission registry was
398 // populated are a compatibility edge. Attach them before claiming
399 // the key so applyRuntimeTab can publish one authoritative runtime
400 // instead of leaving behind an unused placeholder.
401 if detached := a.detachedSessions[key]; detached != nil && detached.Ctrl != nil {
402 a.mu.Unlock()
403 return a.attachExistingSessionRuntime(tab, path, a.ctx)
404 }
405 a.bindSessionRuntimeKeyLocked(tab, path)
406 a.mu.Unlock()
407 return false
408 case rt.Owner == tab:
409 a.mu.Unlock()
410 // The target may own the starting placeholder while a legacy
411 // visible/detached runtime for the same session predates the
412 // registry. Let attachExistingSessionRuntime adopt that usable
413 // controller; otherwise this remains the owner build.
414 return a.attachExistingSessionRuntime(tab, path, a.ctx)
415 case rt.Phase == sessionRuntimeStarting && rt.readyCh != nil:
416 wait := rt.readyCh
417 a.mu.Unlock()
418 select {
419 case <-wait:
420 continue
421 case <-ctx.Done():
422 return false
423 case <-time.After(250 * time.Millisecond):
424 // Re-check owner liveness even when a superseded build exited
425 // before publishing a terminal phase.
426 continue
427 }
428 default:
429 a.mu.Unlock()
430 return a.attachExistingSessionRuntime(tab, path, a.ctx)
431 }
432 }
433 }
434
435 func (a *App) releaseSessionRuntimeLocked(tab *WorkspaceTab) {
436 rt := a.runtimeForTabLocked(tab)
437 if rt == nil {
438 if tab != nil {
439 tab.runtimeID = ""
440 }
441 return
442 }
443 rt.Phase = sessionRuntimeClosing
444 closeRuntimeReadyChannelLocked(rt)
445 a.removeSessionRuntimeMappingsLocked(rt)
446 if tab != nil {
447 tab.runtimeID = ""
448 }
449 }
450
451 func sameCurrentProcessLease(err error) bool {
452 var leaseErr *agent.SessionLeaseError
453 if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil {
454 return false
455 }
456 if leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID() {
457 return false
458 }
459 host, _ := os.Hostname()
460 return strings.TrimSpace(leaseErr.Info.Hostname) == strings.TrimSpace(host)
461 }
462
463 // sessionParentLive reports whether a desktop tab or detached runtime in this
464 // process currently owns, or is still building, the requested session. It is
465 // intentionally checked before stale-subagent cleanup probes the durable lease:
466 // a starting tab has published SessionPath but may not have bound that lease yet.
467 func (a *App) sessionParentLive(sessionPath string) bool {
468 return a.sessionParentLiveForBuild(sessionPath, nil)
469 }
470
471 // subagentParentProbeForBuild excludes an initial build's own unbound tab: that
472 // build can safely repair its crash leftovers before it binds the session lease.
473 // Other live tabs remain protected from the sweep.
474 func (a *App) subagentParentProbeForBuild(building *WorkspaceTab) func(string) bool {
475 return func(sessionPath string) bool {
476 return a.sessionParentLiveForBuild(sessionPath, building)
477 }
478 }
479
480 func (a *App) sessionParentLiveForBuild(sessionPath string, building *WorkspaceTab) bool {
481 key := sessionRuntimeKey(sessionPath)
482 if a == nil || key == "" {
483 return false
484 }
485
486 a.mu.RLock()
487 defer a.mu.RUnlock()
488 if rt := a.runtimeBySessionKey[key]; rt != nil && a.runtimeOwnerLiveLocked(rt) &&
489 !(rt.Owner == building && building.Ctrl == nil) {
490 return true
491 }
492 liveTab := func(tab *WorkspaceTab) bool {
493 if tab == nil {
494 return false
495 }
496 if tab == building && tab.Ctrl == nil {
497 return false
498 }
499 if sessionRuntimeKey(tab.SessionPath) == key {
500 return true
501 }
502 return tab.Ctrl != nil && sessionRuntimeKey(tab.Ctrl.SessionPath()) == key
503 }
504 for _, tab := range a.tabs {
505 if liveTab(tab) {
506 return true
507 }
508 }
509 for _, tab := range a.detachedSessions {
510 if liveTab(tab) {
511 return true
512 }
513 }
514 return false
515 }
516
516 lines GO