1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "context"
6 "crypto/rand"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "slices"
12 "strconv"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "time"
17
18 vt "github.com/bnema/vev-vt"
19 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"
21 "github.com/bnema/vev/internal/protocol"
22 "github.com/bnema/vev/internal/usecase/layout"
23)
24
25var (
26 errSessionNameRequired = errors.New("name required")
27 errSessionNameInUse = errors.New("name already in use")
28)
29
30type session struct {
31 sessionCore
32
33 ctx context.Context
34 cancel context.CancelFunc
35
36 // dispatchMu serializes user-initiated mutating command dispatch (palette
37 // and vev-cmd control) for this session, so a one-shot command cannot
38 // resolve focus against one state and mutate a later one. Lock order:
39 // dispatchMu strictly before d.mu, sess.mu, or any tab mu.
40 dispatchMu sync.Mutex
41 // teardownMu guards teardown ownership and is the outermost per-session
42 // lifecycle lock: never acquire it while holding daemon, session, snapshot,
43 // tab, or pane locks. The owner performs teardown without holding teardownMu;
44 // duplicate daemon-shutdown callers can therefore bound their ownership wait
45 // with Serve's shared snapshot deadline.
46 teardownMu sync.Mutex
47 teardownActive bool
48 teardownDone chan struct{}
49 moveReservations uint
50 teardownWaiters uint
51 teardownChanged chan struct{}
52 shutdownTeardownRequested bool
53 lifecycleStopOnce sync.Once
54 // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
55 // and clipboard queue state.
56 // restoreDone remains attached to a restored session after it is published in
57 // the live registry, so racing attaches cannot bypass restoration completion.
58 restoreDone chan struct{}
59 themeMu sync.Mutex
60 tabs []*tab
61 clipboardQueue []clipboardForward
62 clipboardWorkerRunning bool
63 cwd string
64 // env is the authoritative immutable environment snapshot for future PTY children.
65 // It is guarded by mu and is always copied on ingress and egress.
66 env []string
67 // envProvisional marks an env that came from the daemon's own process
68 // rather than a client: a snapshot restore, a daemon-owned resume of a
69 // stopped session, or a session created or resumed from a provisional
70 // one. The first client-owned environment then replaces it (see
71 // adoptClientEnvironmentLocked for the display-group exception); later
72 // ones refresh only session-bound variables. Guarded by mu.
73 envProvisional bool
74 snapDirty atomic.Bool
75 snapEligible atomic.Bool
76 // snapshotMu serializes mutation revisions and repository publication
77 // generations with worker completion. It is intentionally independent from
78 // mu: persistence never holds session state locks while encoding or writing.
79 snapshotMu sync.Mutex
80 snapshotGeneration uint64 // newest mutation revision
81 snapshotPublishedGeneration uint64 // newest repository generation
82 snapshotPublishedCheckpoint *domain.CheckpointRef
83 snapshotPublishedMutationRevision uint64
84 snapshotNextEligibleAt time.Time
85 // The coordinator state below is guarded by snapshotMu. A capture can be
86 // queued globally or in flight, never more than one of each per session.
87 // Quarantine cancels the session publication context before destructive
88 // repository operations and publicationDone joins a started publication.
89 snapshotPending bool
90 snapshotPendingCaptures uint
91 // snapshotForcedGeneration is the newest mutation revision a forced
92 // checkpoint must publish. It survives an older routine capture so worker
93 // completion can enqueue exactly one forced successor.
94 snapshotForcedGeneration uint64
95 snapshotQueuedCapture *snapshotCapture
96 snapshotInFlightCapture *snapshotCapture
97 snapshotPublicationDone chan struct{}
98 snapshotPublicationContext context.Context
99 snapshotPublicationCancel context.CancelFunc
100 snapshotQuarantined bool
101 // snapshotChunkCache retains encoded sealed history only for this named
102 // session. snapshotMu owns it so cache touches never contend with pane
103 // state or mutate bytes retained by queued/in-flight publications.
104 snapshotChunkCache *snapshotChunkCache
105 snapshotChanged chan struct{}
106 snapshotWake chan struct{}
107 // syncGen makes synchronized-output watchdog generations unique across all
108 // panes in this session.
109 syncGen atomic.Uint64
110 // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish
111 // transactions. It is deliberately not an architecture lock and may span
112 // external PTY.Resize calls.
113 layoutApplyMu sync.Mutex
114 // clipFiles records clipboard-image-transfer temp file paths (see
115 // clipboard.go) written for this session, removed best-effort in
116 // killSession.
117 clipFiles []string
118
119 // floatingLaunchMu is ownership synchronization for external floating
120 // launches. It is separate from mu so PTYFactory.Open never holds a
121 // session, tab, or daemon architecture lock.
122 floatingLaunchMu sync.Mutex
123 floatingLaunchStopping bool
124 floatingLaunches map[*floatingLaunch]struct{}
125}
126
127func (s *session) nameSnapshot() string {
128 if s == nil {
129 return ""
130 }
131 s.mu.Lock()
132 defer s.mu.Unlock()
133 return s.name
134}
135
136// tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
137type tab struct {
138 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
139
140 // layoutGeneration invalidates prepared geometry whenever live tab layout
141 // state changes. layoutApplyMu serializes the lock-free PTY apply boundary.
142 layoutGeneration uint64
143 layoutApplyMu sync.Mutex
144 // layoutRetryMu owns the one bounded delayed retry worker for accepted
145 // tiled-layout degradation. Its context is derived from ctx, so tab/session
146 // teardown cancels a waiting retry before it can touch PTY state.
147 layoutRetryMu sync.Mutex
148 layoutRetryCancel context.CancelFunc
149 layoutRetryRunning bool
150
151 stableID string
152 name string
153 tree *layout.Tree
154 panes map[layout.PaneID]*pane
155 nextPaneID int
156 size domain.Size
157 ctx context.Context
158 cancel context.CancelFunc
159 // floating is independent from the normal layout tree and pane map.
160 floating floatingSlot
161
162 // attention fields are guarded by the owning session.mu.
163 attention bool
164 attentionAt time.Time
165 attentionVisiblePaint bool
166}
167
168// attachedClient is a client currently attached to a session's tab. rend is
169// its private renderer shadow (so each client gets output minimised against
170// what it has actually seen). sendMu serialises the two senders — the render
171// coordinator and the connection handler — so the transport's single-writer
172
173func (d *Daemon) touchMRU(entry *session) {
174 if d == nil || entry == nil || entry.core() == nil {
175 return
176 }
177 core := entry.core()
178 seq := d.mruSeq.Add(1)
179 for {
180 old := core.mruAt.Load()
181 if old >= seq {
182 return
183 }
184 if core.mruAt.CompareAndSwap(old, seq) {
185 break
186 }
187 }
188 entry.mu.Lock()
189 if !entry.ephemeral {
190 d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
191 }
192 entry.mu.Unlock()
193}
194
195func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
196 return d.createSessionLockedWithMode(name, ephemeral, cwd, geometry, env, restoredTabNames...)
197}
198
199// resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks
200// lifecycle and resumability after catalogue I/O, which intentionally runs
201// without d.mu.
202func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, geometry domain.Geometry, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {
203 return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, geometry, env, &expectedInactive, nil, restoredTabNames...)
204}
205
206func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
207 return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, geometry, env, nil, nil, restoredTabNames...)
208}
209
210type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool
211
212func (s inactiveSession) matchesRecord(record domain.CatalogueRecord) bool {
213 canonical := inactiveSessionFromRecord(record, s.state, s.restoreDone)
214 return s.sameLifecycle(canonical) && s.cwd == canonical.cwd &&
215 slices.Equal(s.tabNames, canonical.tabNames) && slices.Equal(s.tabRecords, canonical.tabRecords) &&
216 s.record.DegradedReason == canonical.record.DegradedReason
217}
218
219func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *inactiveSession, authoritative domain.CatalogueRecord, authoritativeExists bool, validate inactiveResumeValidator) bool {
220 if expected == nil {
221 return true
222 }
223 if !exists || !current.sameLifecycle(*expected) || !current.canResume() {
224 return false
225 }
226 if authoritativeExists && !current.matchesRecord(authoritative) {
227 return false
228 }
229 return validate == nil || validate(current, authoritative, authoritativeExists)
230}
231
232func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
233 env = copyEnvironment(env)
234 geometry = geometry.NormalizePixels()
235 sz := geometry.Size
236 if _, reserved := d.creating[name]; reserved {
237 return nil, errSessionNameInUse
238 }
239 d.creating[name] = struct{}{}
240 defer delete(d.creating, name)
241 stopped, resuming := d.inactive[name]
242 if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, domain.CatalogueRecord{}, false, nil) {
243 return nil, errAttachmentTransition
244 }
245 var authoritative domain.CatalogueRecord
246 var authoritativeExists bool
247 if !ephemeral && resuming && d.persistEnabled {
248 d.mu.Unlock()
249 var err error
250 authoritative, authoritativeExists, err = d.catalogueRecord(name)
251 d.mu.Lock()
252 if err != nil {
253 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't read session catalogue", err)
254 }
255 if d.closing {
256 return nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
257 }
258 stopped, resuming = d.inactive[name]
259 if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, authoritative, authoritativeExists, validateInactive) {
260 return nil, errAttachmentTransition
261 }
262 } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) {
263 return nil, errAttachmentTransition
264 }
265 var createdAt int64
266 var incarnation domain.IncarnationID
267 if !ephemeral {
268 if resuming {
269 // A stopped session is the same lifecycle when resumed; it must retain
270 // the persisted identity rather than receive a fresh timestamp.
271 createdAt = stopped.createdAt
272 incarnation = stopped.incarnation
273 if authoritativeExists {
274 createdAt = authoritative.CreatedAt
275 incarnation = authoritative.IncarnationID
276 }
277 if createdAt > d.lastAllocatedCreatedAt {
278 d.lastAllocatedCreatedAt = createdAt
279 }
280 } else {
281 var err error
282 createdAt, err = d.allocateLifecycleCreatedAtLocked()
283 if err != nil {
284 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
285 }
286 }
287 }
288 if incarnation == (domain.IncarnationID{}) {
289 var err error
290 incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
291 if err != nil {
292 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate lifecycle identity: %w", err))
293 }
294 }
295
296 if d.ptys == nil {
297 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
298 }
299 tbSize := contentSize(sz)
300 initialPaneGeometry := scaleSharedPTYGeometry(geometry, tbSize)
301 var names []string
302 var tabRecords []domain.CatalogueTabRecord
303 if resuming && len(stopped.tabRecords) != 0 {
304 tabRecords = append([]domain.CatalogueTabRecord(nil), stopped.tabRecords...)
305 names = make([]string, len(tabRecords))
306 for i, record := range tabRecords {
307 names[i] = record.Name
308 }
309 } else if len(restoredTabNames) > 0 {
310 names = append([]string(nil), restoredTabNames[0]...)
311 }
312 tabs := make([]*tab, 0, max(1, len(names)))
313 for i := range max(1, len(names)) {
314 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
315 if i < len(tabRecords) && tabRecords[i].StableID != "" {
316 tabStableID = string(tabRecords[i].StableID)
317 }
318 if err != nil {
319 closeTabs(tabs)
320 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
321 }
322 launch := d.shellLaunch(env)
323 lifetime := d.newPaneProcessLifetime(d.serveCtx)
324 pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialPaneGeometry)
325 if err != nil {
326 lifetime.abort()
327 if pty != nil {
328 _ = pty.Close()
329 }
330 closeTabs(tabs)
331 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
332 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
333 }
334 appliedPaneGeometry := initialPaneGeometry
335 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
336 if pane := tb.focusedPane(); pane != nil {
337 pane.geometry = appliedPaneGeometry
338 setScreenGeometry(pane.screen, appliedPaneGeometry)
339 }
340 if !lifetime.publish(tb.focusedPane()) {
341 lifetime.abort()
342 _ = pty.Close()
343 closeTabs(tabs)
344 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())
345 }
346 if i < len(names) {
347 tb.name = names[i]
348 }
349 tabs = append(tabs, tb)
350 }
351
352 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
353 d.nextID++
354
355 sctx, cancel := context.WithCancel(d.serveCtx)
356 for _, tb := range tabs {
357 tb.ctx, tb.cancel = context.WithCancel(sctx)
358 }
359 lastUsedSeq := uint64(0)
360 if !ephemeral && resuming {
361 lastUsedSeq = stopped.lastUsedSeq
362 }
363 if lastUsedSeq == 0 {
364 lastUsedSeq = d.mruSeq.Add(1)
365 }
366 sess := &session{
367 sessionCore: sessionCore{
368 id: id,
369 name: name,
370 ephemeral: ephemeral,
371 createdAt: createdAt,
372 incarnation: incarnation,
373 },
374 ctx: sctx,
375 cancel: cancel,
376 tabs: tabs,
377 cwd: cwd,
378 env: env,
379 snapshotWake: d.snapshotWake,
380 }
381 if !ephemeral && name != "" {
382 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
383 }
384 sess.mruAt.Store(lastUsedSeq)
385 sess.snapEligible.Store(!ephemeral && name != "")
386 // These tabs are still private construction state. Publish every tiled
387 // owner before the session enters d.sessions or any PTY reader starts.
388 for _, tb := range tabs {
389 publishTiledPaneOwners(sess, tb)
390 }
391 var rollbackDurable func() error
392 if !ephemeral && d.persistEnabled {
393 record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, TabRecords: tabRecordsFromTabs(tabs)}
394 if d.recovery == nil {
395 closeTabs(tabs)
396 cancel()
397 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
398 }
399 var err error
400 if resuming && authoritativeExists {
401 err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
402 if err == nil {
403 rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) }
404 err = d.catalogue.Sync()
405 }
406 } else {
407 record, err = d.recovery.Create(d.serveCtx, record)
408 if err == nil {
409 rollbackDurable = func() error {
410 current, ok, recordErr := d.catalogue.Record(name)
411 if recordErr != nil {
412 return recordErr
413 }
414 if !ok {
415 return nil
416 }
417 if current.IncarnationID != record.IncarnationID {
418 return errors.New("daemon: created session authority changed before rollback")
419 }
420 return d.catalogue.Delete(name)
421 }
422 }
423 }
424 if err != nil {
425 if rollbackDurable != nil {
426 err = errors.Join(err, rollbackDurable())
427 }
428 closeTabs(tabs)
429 cancel()
430 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
431 }
432 sess.incarnation = record.IncarnationID
433 if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {
434 checkpoint := *authoritative.Committed
435 sess.snapshotPublishedGeneration = checkpoint.Generation
436 sess.snapshotPublishedCheckpoint = &checkpoint
437 }
438 }
439 if !d.registerSessionLocked(sess) {
440 err := errors.New("daemon: session registry rejected local session")
441 if rollbackDurable != nil {
442 err = errors.Join(err, rollbackDurable())
443 }
444 closeTabs(tabs)
445 cancel()
446 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
447 }
448 if !ephemeral {
449 // Keep stopped lifecycle authority intact until runtime publication and
450 // its durable side effects have both succeeded.
451 delete(d.inactive, name)
452 }
453 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
454 for i, tb := range tabs {
455 d.log.Info("tab created", "session", name, "tab", i)
456 d.startTabGoroutines(sess, tb)
457 }
458 return sess, nil
459}
460
461func closeTabs(tabs []*tab) {
462 for _, tb := range tabs {
463 tb.closeAllPanes()
464 }
465}
466
467func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
468 if name == "" {
469 return errSessionNameRequired
470 }
471 if err := domain.ValidateSessionName(name); err != nil {
472 return err
473 }
474 geometry := ac.geometrySnapshot()
475 d.mu.Lock()
476 if d.closing {
477 d.mu.Unlock()
478 return errors.New("daemon is shutting down")
479 }
480 if d.purgeAdmissionClosedLocked() {
481 d.mu.Unlock()
482 return errPurgeAdmissionClosed
483 }
484 if d.nameLiveOrStoppedLocked(name) {
485 d.mu.Unlock()
486 return errSessionNameInUse
487 }
488 from.mu.Lock()
489 cwd := from.cwd
490 env, provisional := from.environmentSeedLocked()
491 _, attached := from.attachments[ac]
492 from.mu.Unlock()
493 if !attached {
494 d.mu.Unlock()
495 return errors.New("client detached")
496 }
497
498 newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)
499 if err == nil && provisional {
500 markEnvironmentProvisional(newSess)
501 }
502 d.mu.Unlock()
503 if err != nil {
504 return err
505 }
506 // The newly created target is registered before the attachment transition, but
507 // remains headless. Freeze/drain must occur with no daemon/routing/session
508 // lock held; the transition revalidates both lifecycles before publication.
509 transition, err := d.transitionAttachment(attachmentTransitionRequest{
510 source: from,
511 target: newSess,
512 next: ac,
513
514 expectedTransport: ac.transportSnapshot(),
515 ready: true,
516 })
517 if err != nil {
518 _ = d.killSession(newSess, protocol.ReasonSessionKilled, true)
519 return err
520 }
521
522 d.touchMRU(newSess)
523 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
524 d.deferAttachmentTransitionCleanups(transition)
525 d.firstPaintForTransition(transition.published)
526 return nil
527}
528
529// createSessionAndSwitchForAttachment creates no target until the initiating role
530// ticket has ended and the exact source capability is frozen and revalidated.
531func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, name string) error {
532 if name == "" {
533 return errSessionNameRequired
534 }
535 if err := domain.ValidateSessionName(name); err != nil {
536 return err
537 }
538 if effect == nil || effect.sess == nil || effect.ac == nil {
539 return errAttachmentTransition
540 }
541 effect.bindActionEnd(d, "create-session")
542 effect.End()
543 capability := effect.capability()
544
545 var created *session
546 transition, err := d.transitionAttachment(attachmentTransitionRequest{
547 source: capability.sess, next: capability.ac,
548
549 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",
550 ready: true,
551 createTargetLocked: func() (*session, error) {
552 source := capability.sess
553 if source == nil {
554 return nil, errAttachmentTransition
555 }
556 if d.closing {
557 return nil, errors.New("daemon is shutting down")
558 }
559 if d.purgeAdmissionClosedLocked() {
560 return nil, errPurgeAdmissionClosed
561 }
562 if d.nameLiveOrStoppedLocked(name) {
563 return nil, errSessionNameInUse
564 }
565 source.mu.Lock()
566 cwd := source.cwd
567 env, provisional := source.environmentSeedLocked()
568 source.mu.Unlock()
569 var createErr error
570 created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env)
571 if createErr == nil && provisional {
572 markEnvironmentProvisional(created)
573 }
574 return created, createErr
575 },
576 })
577 if err != nil {
578 if created != nil {
579 _ = d.killSession(created, protocol.ReasonSessionKilled, true)
580 }
581 return err
582 }
583
584 d.touchMRU(created)
585 d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable)
586 d.deferAttachmentTransitionCleanups(transition)
587 d.firstPaintForTransition(transition.published)
588 return nil
589}
590
591func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
592 geometry := ac.geometrySnapshot()
593 d.mu.Lock()
594 if d.closing {
595 d.mu.Unlock()
596 return errors.New("daemon is shutting down")
597 }
598 if d.purgeAdmissionClosedLocked() {
599 d.mu.Unlock()
600 return errPurgeAdmissionClosed
601 }
602 from.mu.Lock()
603 cwd := from.cwd
604 env, provisional := from.environmentSeedLocked()
605 _, attached := from.attachments[ac]
606 from.mu.Unlock()
607 if !attached {
608 d.mu.Unlock()
609 return errors.New("client detached")
610 }
611 name := d.allocEphemeralNameLocked()
612 newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)
613 if err == nil && provisional {
614 markEnvironmentProvisional(newSess)
615 }
616 d.mu.Unlock()
617 if err != nil {
618 return err
619 }
620 transition, err := d.transitionAttachment(attachmentTransitionRequest{
621 source: from, target: newSess, next: ac,
622 expectedTransport: ac.transportSnapshot(), ready: true,
623 })
624 if err != nil {
625 _ = d.killSession(newSess, protocol.ReasonSessionKilled, true)
626 return err
627 }
628 d.touchMRU(newSess)
629 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
630 d.deferAttachmentTransitionCleanups(transition)
631 d.firstPaintForTransition(transition.published)
632 return nil
633}
634
635func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachmentEffect) error {
636 if effect == nil || effect.sess == nil || effect.ac == nil {
637 return errAttachmentTransition
638 }
639 effect.bindActionEnd(d, "create-ephemeral-session")
640 effect.End()
641 capability := effect.capability()
642
643 var created *session
644 transition, err := d.transitionAttachment(attachmentTransitionRequest{
645 source: capability.sess, next: capability.ac,
646 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",
647 ready: true,
648 createTargetLocked: func() (*session, error) {
649 if capability.sess == nil || d.closing {
650 return nil, errAttachmentTransition
651 }
652 if d.purgeAdmissionClosedLocked() {
653 return nil, errPurgeAdmissionClosed
654 }
655 source := capability.sess
656 source.mu.Lock()
657 cwd := source.cwd
658 env, provisional := source.environmentSeedLocked()
659 source.mu.Unlock()
660 name := d.allocEphemeralNameLocked()
661 var createErr error
662 created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env)
663 if createErr == nil && provisional {
664 markEnvironmentProvisional(created)
665 }
666 return created, createErr
667 },
668 })
669 if err != nil {
670 if created != nil {
671 _ = d.killSession(created, protocol.ReasonSessionKilled, true)
672 }
673 return err
674 }
675 d.touchMRU(created)
676 d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable)
677 d.deferAttachmentTransitionCleanups(transition)
678 d.firstPaintForTransition(transition.published)
679 return nil
680}
681
682func (d *Daemon) createTab(sess *session, sz domain.Size) error {
683 return d.createTabForAttachment(sess, nil, sz)
684}
685
686func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ domain.Size) error {
687 sess.mu.Lock()
688 name := sess.name
689 cwd := sess.cwd
690 env := copyEnvironment(sess.env)
691 attachments := sess.snapshotAttachmentsLocked()
692 sess.mu.Unlock()
693 tbSize := contentSize(sess.fullViewportSize())
694 claimGeometry := domain.Geometry{Size: tbSize}
695 if source, ok := sess.geometry.sourceSnapshot(sess); ok {
696 claimGeometry = scaleSharedPTYGeometry(source.geometry, tbSize)
697 } else if ac != nil {
698 claimGeometry = scaleSharedPTYGeometry(ac.geometrySnapshot(), tbSize)
699 }
700 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
701 if err != nil {
702 return err
703 }
704 launch := d.shellLaunch(env)
705 lifetime := d.newPaneProcessLifetime(sess.ctx)
706 pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, claimGeometry)
707 if err != nil {
708 lifetime.abort()
709 if pty != nil {
710 _ = pty.Close()
711 }
712 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
713 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
714 }
715 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
716 if pane := tb.focusedPane(); pane != nil {
717 pane.geometry = claimGeometry
718 setScreenGeometry(pane.screen, claimGeometry)
719 }
720 themeClient := ac
721 if themeClient == nil && len(attachments) != 0 {
722 themeClient = attachments[0]
723 }
724 if themeClient != nil {
725 t := d.effectiveTheme(themeClient.getClientTheme())
726 tb.mu.Lock()
727 p := tb.focusedPane()
728 tb.mu.Unlock()
729 if p != nil {
730 p.mu.Lock()
731 applyPaneThemeLocked(p, t, false)
732 p.mu.Unlock()
733 }
734 }
735 d.mu.Lock()
736 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
737 d.mu.Unlock()
738 lifetime.abort()
739 _ = pty.Close()
740 return errors.New("daemon: session closed")
741 }
742 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
743 sess.mu.Lock()
744 p := tb.focusedPane()
745 if !lifetime.publish(p) {
746 sess.mu.Unlock()
747 d.mu.Unlock()
748 lifetime.abort()
749 _ = pty.Close()
750 return errors.New("daemon: session closed")
751 }
752 // Publish ownership before appending the tab to the visible session.
753 publishPaneOwner(p, sess, tb, 0)
754 sess.tabs = append(sess.tabs, tb)
755 tabIndex := len(sess.tabs) - 1
756 if !sess.ephemeral {
757 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
758 }
759 if ac != nil {
760 sess.activateAttachmentViewLocked(ac, tabIndex)
761 }
762 sess.mu.Unlock()
763 d.mu.Unlock()
764 d.log.Info("tab created", "session", name, "tab", tabIndex)
765 d.startTabGoroutines(sess, tb)
766 d.activateTab(sess, tb)
767 markSnapshotDirty(sess)
768 return nil
769}
770
771func newTab(pty ports.PTY, sz domain.Size) *tab {
772 return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz)
773}
774
775func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
776 return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig()))
777}
778
779func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab {
780 id := layout.PaneID("pane-1")
781 p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig)
782 return &tab{
783 stableID: tabStableID,
784 tree: layout.NewTree(id),
785 panes: map[layout.PaneID]*pane{id: p},
786 nextPaneID: 2,
787 size: sz,
788 }
789}
790
791func (tb *tab) bumpLayoutGenerationLocked() {
792 tb.layoutGeneration++
793}
794
795func (tb *tab) focusedPane() *pane {
796 if tb == nil || tb.tree == nil || tb.panes == nil {
797 return nil
798 }
799 return tb.panes[tb.tree.Focus]
800}
801
802func (tb *tab) panesSnapshot() []*pane {
803 if tb == nil {
804 return nil
805 }
806 out := make([]*pane, 0, len(tb.panes))
807 for _, p := range tb.panes {
808 out = append(out, p)
809 }
810 return out
811}
812
813func (tb *tab) closeAllPanes() {
814 tb.mu.Lock()
815 panes := tb.panesSnapshot()
816 // Invalidate before cancellation so a concurrently unblocked floating
817 // reader cannot reap a subsequently reused slot.
818 floating := tb.takeFloatingLocked()
819 tb.mu.Unlock()
820 if floating != nil {
821 panes = append(panes, floating)
822 }
823 for _, p := range panes {
824 closePaneProcess(p)
825 }
826}
827
828// tabChromeRows is the vertical chrome (tab bar + status bar) between a
829// client viewport and a tab's content area.
830const tabChromeRows = 2
831
832func tabSize(clientSize domain.Size) domain.Size {
833 return contentSize(clientSize)
834}
835
836// contentSize maps the negotiated viewport to PTY geometry.
837func contentSize(clientSize domain.Size) domain.Size {
838 if !clientSize.Valid() {
839 clientSize = defaultSize
840 }
841 rows := max(clientSize.Rows-tabChromeRows, 1)
842 return domain.Size{Cols: clientSize.Cols, Rows: rows}
843}
844
845// fullViewportSize derives a full client-equivalent viewport from the active
846// tab's retained content size, for actions that need a viewport while no
847// client is attached. It falls back to defaultSize when no valid size exists.
848func (s *session) fullViewportSize() domain.Size {
849 s.mu.Lock()
850 var tb *tab
851 if len(s.tabs) != 0 {
852 tb = s.tabs[0]
853 }
854 s.mu.Unlock()
855
856 var content domain.Size
857 if tb != nil {
858 tb.mu.Lock()
859 content = tb.size
860 tb.mu.Unlock()
861 }
862 if !content.Valid() {
863 return defaultSize
864 }
865 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
866}
867
868func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
869 tb.mu.Lock()
870 panes := tb.panesSnapshot()
871 tb.mu.Unlock()
872 for _, p := range panes {
873 d.startPaneGoroutines(sess, tb, p)
874 }
875}
876
877func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
878 if p == nil {
879 return
880 }
881 owner := p.ownerSnapshot()
882 if owner == nil {
883 // Teardown may win after publication but before reader startup. In that
884 // case the process is already closed and no ownerless reader may start.
885 return
886 }
887 if owner.session != sess || owner.tab != tb {
888 return
889 }
890 d.applyHistoryLimitsToPane(p)
891 sess.mu.Lock()
892 name := sess.name
893 sess.mu.Unlock()
894 d.log.Info("pane created", "session", name, "pane", p.id)
895 // Scheduler ownership was removed; this launch creates exactly one reader.
896 d.sessWg.Add(1)
897 go d.readPanePTY(p)
898}
899
900// detachIfCurrent publishes terminal attachment invalidation through the attachment
901// gate. Freeze/drain happens before routing or session locks are acquired.
902func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
903 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
904}
905
906// detachIfCurrentTransport is detachIfCurrent with an optional exact-link
907// fence. When expected.transport is non-nil, a rebound incarnation of the same
908// attachment object cannot be detached by a stale connection loop.
909func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool {
910 if sess == nil || ac == nil {
911 return false
912 }
913 frozen := freezeAttachmentEffectGates(ac)
914 defer frozen.unfreeze()
915 if d.afterDetachAttachmentEffectsFrozen != nil {
916 d.afterDetachAttachmentEffectsFrozen()
917 }
918 d.notices.routingMu.Lock()
919 sess.mu.Lock()
920 current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
921 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
922 current = false
923 }
924 if current {
925 current = detachFrozenAttachmentLocked(sess, ac)
926 }
927 sess.mu.Unlock()
928 d.notices.routingMu.Unlock()
929 if current {
930 // Terminal detach ends any daemon-owned suspension expiry. The clear runs
931 // outside routingMu so it never inverts the daemon -> routing lock order.
932 d.clearSuspendedExpiry(ac)
933 }
934 return current
935}
936
937// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
938// whose admitted send failed. A queued stale error cannot detach a later
939// attachment publication or a rebound transport on the same attachment object.
940func (d *Daemon) detachIfAttachmentCurrent(token attachmentCapability) bool {
941 return d.detachIfAttachmentCurrentUntil(token, nil)
942}
943
944func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done func() <-chan struct{}) bool {
945 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
946 return false
947 }
948 // Attachments without the session's optional primary render lease still
949 // own an independent connection lifecycle and detach through the same exact
950 // transport/generation fence.
951 if token.lease == nil {
952 return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
953 }
954 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
955 defer frozen.unfreeze()
956 if !frozen.acquired || !frozen.drained {
957 return false
958 }
959 if d.afterDetachAttachmentEffectsFrozen != nil {
960 d.afterDetachAttachmentEffectsFrozen()
961 }
962 d.mu.Lock()
963 core := token.sess.core()
964 if core == nil || d.sessions[core.id] != token.sess {
965 d.mu.Unlock()
966 return false
967 }
968 d.notices.routingMu.Lock()
969 core.mu.Lock()
970 coordinator := core.coordinator.Load()
971 if coordinator != nil {
972 coordinator.mu.Lock()
973 }
974 current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
975 if current {
976 current = detachFrozenAttachmentLocked(token.sess, token.ac)
977 }
978 if coordinator != nil {
979 coordinator.mu.Unlock()
980 }
981 core.mu.Unlock()
982 d.notices.routingMu.Unlock()
983 d.mu.Unlock()
984 if current {
985 // The suspension expiry is daemon-scoped; clear it only after every other
986 // architecture lock is released.
987 d.clearSuspendedExpiry(token.ac)
988 }
989 return current
990}
991
992func (d *Daemon) renameSession(sess *session, name string) error {
993 if name == "" {
994 return errSessionNameRequired
995 }
996 if err := domain.ValidateSessionName(name); err != nil {
997 return err
998 }
999
1000 // Hold daemon then session state across the durable identity write. The
1001 // incarnation-keyed snapshot namespace is never relocated.
1002 d.mu.Lock()
1003 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
1004 d.mu.Unlock()
1005 return errSessionNameInUse
1006 }
1007 sess.mu.Lock()
1008 if stopped, ok := d.inactive[name]; ok && stopped.name != sess.name {
1009 sess.mu.Unlock()
1010 d.mu.Unlock()
1011 return errSessionNameInUse
1012 }
1013 oldName := sess.name
1014 wasEphemeral := sess.ephemeral
1015 createdAt := sess.createdAt
1016 priorIncarnation := sess.incarnation
1017 if wasEphemeral {
1018 var err error
1019 createdAt, err = d.allocateLifecycleCreatedAtLocked()
1020 if err != nil {
1021 sess.mu.Unlock()
1022 d.mu.Unlock()
1023 return err
1024 }
1025 if sess.incarnation == (domain.IncarnationID{}) {
1026 sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
1027 if err != nil {
1028 sess.mu.Unlock()
1029 d.mu.Unlock()
1030 return fmt.Errorf("generate durable identity: %w", err)
1031 }
1032 }
1033 }
1034 lastUsedSeq := sess.mruAt.Load()
1035 record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1036 record.Name = name
1037 record.CreatedAt = createdAt
1038 record.LastUsedSeq = lastUsedSeq
1039 rollback := func(err error) error {
1040 sess.incarnation = priorIncarnation
1041 sess.mu.Unlock()
1042 d.mu.Unlock()
1043 return err
1044 }
1045 // Durable named renames are one atomic catalogue batch. The incarnation-keyed
1046 // snapshot namespace is intentionally untouched.
1047 if (wasEphemeral || oldName != name) && d.persistEnabled {
1048 if d.recovery == nil {
1049 return rollback(errors.New("durable session authority is not configured"))
1050 }
1051 {
1052 var committed domain.CatalogueRecord
1053 var err error
1054 if wasEphemeral {
1055 committed, err = d.recovery.Create(d.serveCtx, record)
1056 } else {
1057 committed, err = d.recovery.Rename(d.serveCtx, oldName, name)
1058 }
1059 if err != nil {
1060 return rollback(err)
1061 }
1062 record = committed
1063 }
1064 }
1065
1066 delete(d.inactive, oldName)
1067 delete(d.inactive, name)
1068 sess.name = name
1069 sess.createdAt = createdAt
1070 sess.incarnation = record.IncarnationID
1071 sess.ephemeral = false
1072 sess.snapEligible.Store(name != "")
1073 sess.mu.Unlock()
1074 d.mu.Unlock()
1075 if wasEphemeral {
1076 sess.snapshotMu.Lock()
1077 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
1078 sess.snapshotMu.Unlock()
1079 }
1080 markSnapshotDirty(sess)
1081 d.publishRenamedSessionRouteIdentity(sess)
1082 return nil
1083}
1084
1085// publishRenamedSessionRouteIdentity updates each ready client's exact route
1086// target after a session rename. A renamed session retains its lifecycle but
1087// its old name is no longer attachable, so clients must replace—not retain—the
1088// prior route record.
1089func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
1090 for _, ac := range sess.snapshotAttachments() {
1091 if ac.deferRouteIdentityUntilSnapshot() {
1092 continue
1093 }
1094 token := sess.captureAttachmentCapability(ac, ac.transport())
1095 if token.ac == nil {
1096 continue
1097 }
1098 effect, admitted := ac.beginAttachmentEffect(token)
1099 if !admitted {
1100 continue
1101 }
1102 err := d.sendCommittedRouteIdentityForAttachment(effect)
1103 capability := effect.capability()
1104 effect.End()
1105 if err != nil {
1106 d.detachOnAttachmentSendError(capability, capability.transport.transport)
1107 }
1108 }
1109}
1110
1111func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
1112 if tb == nil {
1113 return errors.New("tab required")
1114 }
1115 d.mu.Lock()
1116 if d.sessions[sess.id] != sess {
1117 d.mu.Unlock()
1118 return errors.New("daemon: session closed")
1119 }
1120 sess.mu.Lock()
1121 if !slices.Contains(sess.tabs, tb) {
1122 sess.mu.Unlock()
1123 d.mu.Unlock()
1124 return errors.New("tab not found")
1125 }
1126 tb.name = name
1127 if !sess.ephemeral {
1128 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1129 }
1130 sess.mu.Unlock()
1131 d.mu.Unlock()
1132 return nil
1133}
1134
1135func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord {
1136 if len(tabs) == 0 {
1137 return nil
1138 }
1139 records := make([]domain.CatalogueTabRecord, len(tabs))
1140 for i, tb := range tabs {
1141 if tb == nil {
1142 continue
1143 }
1144 records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name}
1145 }
1146 return records
1147}
1148
1149func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
1150 createdAt := s.createdAt
1151 tabNames := make([]string, len(s.tabs))
1152 lastCustom := -1
1153 for i, tb := range s.tabs {
1154 tabNames[i] = tb.name
1155 if tb.name != "" {
1156 lastCustom = i
1157 }
1158 }
1159 if lastCustom == -1 {
1160 tabNames = nil
1161 } else {
1162 tabNames = tabNames[:lastCustom+1]
1163 }
1164 return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, TabRecords: tabRecordsFromTabs(s.tabs)}
1165}
1166
1167// closeTab is the public close boundary for callers that are not already
1168// inside session.runMutation. Mutation dispatchers use closeTabLocked.
1169func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1170 if sess == nil {
1171 return layout.ErrNotFound
1172 }
1173 sess.dispatchMu.Lock()
1174 defer sess.dispatchMu.Unlock()
1175 return d.closeTabLocked(sess, tb, repaint)
1176}
1177
1178// closeTabLocked requires sess.dispatchMu before entering daemon or session
1179// locks. Existing runMutation callers use it to avoid nested dispatch locking.
1180func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
1181 return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
1182}
1183
1184func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffect) error {
1185 if sess == nil || tb == nil {
1186 return layout.ErrNotFound
1187 }
1188 d.mu.Lock()
1189 if d.sessions[sess.id] != sess {
1190 d.mu.Unlock()
1191 return errors.New("daemon: session closed")
1192 }
1193 sess.mu.Lock()
1194 idx := -1
1195 for i, w := range sess.tabs {
1196 if w == tb {
1197 idx = i
1198 break
1199 }
1200 }
1201 if idx == -1 {
1202 sess.mu.Unlock()
1203 d.mu.Unlock()
1204 return errors.New("tab not found")
1205 }
1206 if len(sess.tabs) == 1 {
1207 name := sess.name
1208 sess.mu.Unlock()
1209 d.mu.Unlock()
1210 var err error
1211 if effect != nil {
1212 effect.bindActionEnd(d, "close-tab")
1213 err = d.killSessionForAttachment(sess, protocol.ReasonSessionKilled, true, effect, "close-tab")
1214 } else {
1215 err = d.killSession(sess, protocol.ReasonSessionKilled, true)
1216 }
1217 if err != nil {
1218 d.log.Warn("closing last tab failed", "session", name, "err", err)
1219 d.reportError(sess, closeTabFailureNotice(err))
1220 return err
1221 }
1222 d.log.Info("tab closed", "session", name, "last", true)
1223 return nil
1224 }
1225 ringing := tb.attention
1226 sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx)
1227 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1228 attachments := sess.snapshotAttachmentsLocked()
1229 name := sess.name
1230 if !sess.ephemeral {
1231 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1232 }
1233 sess.invalidateViewsLocked()
1234 sess.mu.Unlock()
1235 d.mu.Unlock()
1236 d.log.Info("tab closed", "session", name)
1237 markSnapshotDirty(sess)
1238
1239 tb.mu.Lock()
1240 panes := tb.panesSnapshot()
1241 tb.mu.Unlock()
1242 for _, ac := range attachments {
1243 ac.pruneCaptureFrames(panes...)
1244 }
1245 if tb.cancel != nil {
1246 tb.cancel()
1247 }
1248 for _, ac := range attachments {
1249 d.teardownFloating(tb, ac)
1250 }
1251 if rc := sess.renderCoordinator(); rc != nil {
1252 for _, p := range panes {
1253 rc.noteSyncPaneRemoved(p)
1254 }
1255 }
1256 tb.closeAllPanes()
1257 if repaint {
1258 for _, ac := range attachments {
1259 d.invalidateRender(sess, ac, true, "session.go")
1260 }
1261 }
1262 if ringing {
1263 d.repaintAllAttachedClients()
1264 }
1265 return nil
1266}
1267
1268// closeTabFailureNotice classifies a failed last-tab close for the attached
1269// client. A participants-changed abort removed nothing and is safe to retry, so
1270// it reports that retryable outcome instead of the snapshot-write failure the
1271// other teardown errors carry.
1272func closeTabFailureNotice(err error) error {
1273 if errors.Is(err, errSessionKillParticipantsChanged) {
1274 return domain.UserErr(domain.NoticeSessionUnavailable,
1275 "couldn't close tab: session changed during the close; try again", err)
1276 }
1277 return domain.UserErr(domain.NoticeSnapshotSaturated,
1278 "couldn't close tab: session state not yet saved; try again", err)
1279}
1280
1281// ptyReader drains child output into the VT screen and pokes the dirty channel
1282// (non-blocking: a full channel already means a render is pending). On any read
1283
1284// beginSnapshotPurge commits a logical named-session kill before its live
1285// identity or persisted metadata is removed. It never runs under daemon or
1286// session locks.
1287func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
1288 if d.persistEnabled && d.recovery == nil {
1289 return errors.New("durable session authority is not configured")
1290 }
1291 // The lifecycle coordinator performs the complete ordered protocol after
1292 // snapshot writers have joined.
1293 return nil
1294}
1295
1296// finishSnapshotPurge removes catalogue metadata first, then deletes the
1297// incarnation directory. Background garbage collection removes the directory if
1298// the second step is interrupted.
1299func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, incarnation domain.IncarnationID, createdAt int64) error {
1300 if !d.persistEnabled {
1301 return nil
1302 }
1303 if d.recovery == nil {
1304 return errors.New("durable session authority is not configured")
1305 }
1306 if ctx == nil {
1307 ctx = d.serveCtx
1308 }
1309 purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout)
1310 defer cancel()
1311 return d.recovery.DeleteExact(purgeCtx, name, incarnation, createdAt)
1312}
1313
1314// retryStoppedPurge completes a previously closed session's durable purge.
1315// It keeps the stopped record hidden until all sources and metadata cross their
1316// deletion boundaries.
1317func (d *Daemon) retryStoppedPurge(name string) error {
1318 return d.retryStoppedPurgeContext(d.serveCtx, name)
1319}
1320
1321func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1322 return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt)
1323}
1324
1325func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1326 return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil)
1327}
1328
1329func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1330 d.mu.Lock()
1331 stopped, ok := d.inactive[name]
1332 if !ok {
1333 d.mu.Unlock()
1334 return nil
1335 }
1336 incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation
1337 createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt
1338 if incarnationChanged || createdAtChanged {
1339 d.mu.Unlock()
1340 return nil
1341 }
1342 stopped.purging = true
1343 d.inactive[name] = stopped
1344 d.mu.Unlock()
1345
1346 if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
1347 return err
1348 }
1349 if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation, stopped.createdAt); err != nil {
1350 return err
1351 }
1352 d.mu.Lock()
1353 if current, ok := d.inactive[name]; ok && current.purging && current.sameLifecycle(stopped) {
1354 delete(d.inactive, name)
1355 }
1356 d.mu.Unlock()
1357 d.reconcileAllRouteHistories()
1358 return nil
1359}
1360
1361// killSession removes a session and tears down its resources. It is
1362// idempotent: only the caller that wins the registry delete acts. Final
1363// session removal is independent of daemon shutdown: an empty registry leaves
1364// the daemon serving (closing is untouched) so a later create/restore can
1365// occupy it again, and only an explicit shutdownAll ends the daemon.
1366//
1367// Teardown ordering matters: context cancel and pty.Close run first and
1368// unconditionally — never gated behind a client send. The Detached notice is
1369// delivered asynchronously under the drain deadline.
1370func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1371 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil)
1372}
1373
1374type sessionKillAdmission struct {
1375 capability attachmentCapability
1376 effect *attachmentEffect
1377 action string
1378}
1379
1380type sessionKillParticipants struct {
1381 attachments map[*attachedClient]struct{}
1382 transports map[*attachedClient]transportSnapshot
1383 effectGates []*attachedClient
1384 interrupts []attachmentTransportInterrupt
1385 source *session
1386 sourceToken *attachmentCapability
1387 target *session
1388 // requireEphemeral aborts the kill if the target stopped being ephemeral
1389 // (a concurrent rename) between the snapshot and publication.
1390 requireEphemeral bool
1391}
1392
1393func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error {
1394 if effect == nil {
1395 return errAttachmentTransition
1396 }
1397 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action})
1398}
1399
1400// snapshotSessionKillParticipants captures attachment membership under the canonical
1401// architecture locks, but releases every lock before any attachment gate is frozen or
1402// drained. The initiator is part of the same deduplicated global gate set even
1403// when it belongs to a different source session.
1404func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, condition sessionKillCondition) (sessionKillParticipants, bool) {
1405 snapshot := sessionKillParticipants{target: target, requireEphemeral: condition.ephemeral}
1406 if admission != nil {
1407 token := admission.capability
1408 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1409 return sessionKillParticipants{}, false
1410 }
1411 snapshot.source = token.sess
1412 if snapshot.source == nil {
1413 return sessionKillParticipants{}, false
1414 }
1415 snapshot.sourceToken = &admission.capability
1416 }
1417
1418 d.mu.Lock()
1419 if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1420 d.mu.Unlock()
1421 return sessionKillParticipants{}, false
1422 }
1423 d.notices.routingMu.Lock()
1424 source := snapshot.source
1425 if source == nil {
1426 source = target
1427 }
1428 unlockSessions := lockAttachmentSessions(source, target)
1429 if condition.empty && (len(target.attachments) != 0 || d.sessionHasPendingResumeLocked(target)) ||
1430 condition.ephemeral && !target.ephemeral {
1431 unlockSessions()
1432 d.notices.routingMu.Unlock()
1433 d.mu.Unlock()
1434 return sessionKillParticipants{}, false
1435 }
1436 snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1437 snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1438 for ac := range target.attachments {
1439 snapshot.attachments[ac] = struct{}{}
1440 snapshot.transports[ac] = ac.transportSnapshot()
1441 }
1442
1443 unlockSessions()
1444 d.notices.routingMu.Unlock()
1445 d.mu.Unlock()
1446
1447 seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
1448 add := func(ac *attachedClient) {
1449 if ac == nil {
1450 return
1451 }
1452 if _, duplicate := seen[ac]; duplicate {
1453 return
1454 }
1455 seen[ac] = struct{}{}
1456 snapshot.effectGates = append(snapshot.effectGates, ac)
1457 }
1458 if admission != nil {
1459 add(admission.capability.ac)
1460 }
1461 for ac := range snapshot.attachments {
1462 add(ac)
1463 }
1464 for ac, transport := range snapshot.transports {
1465 if transport.transport != nil {
1466 snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
1467 }
1468 }
1469 return snapshot, true
1470}
1471
1472// sessionKillTargetRemoved reports whether the exact target session has already
1473// left the live registry, so a post-freeze mismatch is an already-removed no-op
1474// rather than a participant change that left work undone.
1475func (d *Daemon) sessionKillTargetRemoved(target *session) bool {
1476 if target == nil {
1477 return true
1478 }
1479 d.mu.Lock()
1480 defer d.mu.Unlock()
1481 return d.sessions[target.id] != target
1482}
1483
1484// sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1485// target-membership validation while every discovered attachment gate is frozen.
1486func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1487 d.mu.Lock()
1488 defer d.mu.Unlock()
1489 if d.sessions[snapshot.target.id] != snapshot.target ||
1490 snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1491 return false
1492 }
1493 d.notices.routingMu.Lock()
1494 defer d.notices.routingMu.Unlock()
1495 source := snapshot.source
1496 if source == nil {
1497 source = snapshot.target
1498 }
1499 sourceCoordinator := source.renderCoordinator()
1500 targetCoordinator := snapshot.target.renderCoordinator()
1501 unlockSessions := lockAttachmentSessions(source, snapshot.target)
1502 defer unlockSessions()
1503 unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator)
1504 defer unlockCoordinators()
1505 return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator)
1506}
1507
1508// sessionKillParticipantsCurrentLocked requires the ordered source/target
1509// session and coordinator locks.
1510func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1511 target := snapshot.target
1512 if snapshot.requireEphemeral && !target.ephemeral {
1513 return false
1514 }
1515 if len(target.attachments) != len(snapshot.attachments) {
1516 return false
1517 }
1518 for ac := range snapshot.attachments {
1519 if _, current := target.attachments[ac]; !current {
1520 return false
1521 }
1522 }
1523
1524 for ac, transport := range snapshot.transports {
1525 if !ac.transportSnapshotCurrent(transport) {
1526 return false
1527 }
1528 }
1529 if snapshot.sourceToken == nil {
1530 return true
1531 }
1532 token := *snapshot.sourceToken
1533 return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator)
1534}
1535
1536// stopInMemoryLifecycle cancels every session producer and closes every PTY
1537// independently of teardown ownership. Repository work may remain blocked, but
1538// repeated shutdown passes cannot leave pane readers or launches running.
1539func (s *session) stopInMemoryLifecycle() {
1540 if s == nil {
1541 return
1542 }
1543 s.lifecycleStopOnce.Do(func() {
1544 s.stopFloatingLaunches()
1545 if s.cancel != nil {
1546 s.cancel()
1547 }
1548 s.mu.Lock()
1549 tabs := append([]*tab(nil), s.tabs...)
1550 s.mu.Unlock()
1551 for _, tb := range tabs {
1552 tb.closeAllPanes()
1553 }
1554 })
1555}
1556
1557func (s *session) teardownChangeLocked() chan struct{} {
1558 if s.teardownChanged == nil {
1559 s.teardownChanged = make(chan struct{})
1560 }
1561 return s.teardownChanged
1562}
1563
1564func (s *session) signalTeardownChangedLocked() {
1565 close(s.teardownChangeLocked())
1566 s.teardownChanged = make(chan struct{})
1567}
1568
1569// reserveShutdownTeardown makes preservation the authoritative terminal
1570// disposition before shutdown cancels PTYs. PTY EOF may still win teardown
1571// ownership, but it can no longer reinterpret daemon cancellation as a natural
1572// final-shell exit and purge the named session.
1573func (s *session) reserveShutdownTeardown() {
1574 if s == nil {
1575 return
1576 }
1577 s.teardownMu.Lock()
1578 s.shutdownTeardownRequested = true
1579 s.signalTeardownChangedLocked()
1580 s.teardownMu.Unlock()
1581}
1582
1583// beginTeardown waits for ownership without holding teardownMu across teardown.
1584// A false result means Serve's shared shutdown budget expired while another
1585// lifecycle path owned the session. Ordinary callers wait and retry ownership,
1586// preserving failed-teardown and stopped-purge retry semantics.
1587func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1588 _, _, acquired := s.beginTeardownRequest(deadline, 0, false)
1589 return acquired
1590}
1591
1592// beginTeardownRequest acquires teardown ownership and resolves its terminal
1593// disposition atomically with shutdown reservation. This is the sole boundary
1594// allowed to turn a teardown trigger into preserve-versus-purge policy.
1595func (s *session) beginTeardownRequest(deadline *snapshotShutdownDeadline, reason uint8, purge bool) (uint8, bool, bool) {
1596 for {
1597 if deadline != nil {
1598 select {
1599 case <-deadline.Done():
1600 return reason, purge, false
1601 default:
1602 }
1603 }
1604
1605 s.teardownMu.Lock()
1606 if !s.teardownActive && s.moveReservations == 0 {
1607 if deadline != nil {
1608 select {
1609 case <-deadline.Done():
1610 s.teardownMu.Unlock()
1611 return reason, purge, false
1612 default:
1613 }
1614 }
1615 s.teardownActive = true
1616 s.teardownDone = make(chan struct{})
1617 s.signalTeardownChangedLocked()
1618 if s.shutdownTeardownRequested {
1619 reason = protocol.ReasonServerShutdown
1620 purge = false
1621 }
1622 s.teardownMu.Unlock()
1623 return reason, purge, true
1624 }
1625 s.teardownWaiters++
1626 s.signalTeardownChangedLocked()
1627 changed := s.teardownChangeLocked()
1628 s.teardownMu.Unlock()
1629
1630 timedOut := false
1631 if deadline == nil {
1632 <-changed
1633 } else {
1634 select {
1635 case <-changed:
1636 case <-deadline.Done():
1637 timedOut = true
1638 }
1639 }
1640 s.teardownMu.Lock()
1641 s.teardownWaiters--
1642 s.signalTeardownChangedLocked()
1643 s.teardownMu.Unlock()
1644 if timedOut {
1645 return reason, purge, false
1646 }
1647 }
1648}
1649
1650func (s *session) finishTeardown() {
1651 s.teardownMu.Lock()
1652 s.teardownActive = false
1653 close(s.teardownDone)
1654 s.teardownDone = nil
1655 s.signalTeardownChangedLocked()
1656 s.teardownMu.Unlock()
1657}
1658
1659// errSessionKillDeadline reports that a caller-supplied teardown deadline
1660// expired before killSessionWithSnapshotDeadlineAndCondition froze every
1661// attachment gate, acquired teardown ownership, or finished joining the
1662// session's snapshot publication. The session is left live and registered
1663// exactly as it was: a purge's snapshot quarantine is rolled back, restoring
1664// eligibility and dirty scheduling for the surviving session, so the abort is
1665// retry state rather than a lifecycle mutation. It is deliberately distinct
1666// from a completed teardown: a bounded purge or shutdown must report the skip as
1667// a typed failure or an incomplete verdict instead of misreporting success.
1668// Callers that pass no deadline never observe it.
1669var errSessionKillDeadline = errors.New("session teardown deadline exceeded before ownership")
1670
1671// errSessionKillParticipantsChanged reports that a concurrent detach or token
1672// resume changed the participant set between the pre-freeze snapshot and a
1673// post-freeze revalidation, so the kill aborted before freezing every gate or
1674// before publishing terminal ownership. The session was left untouched. It is
1675// deliberately distinct from a completed teardown so a bounded purge can retry
1676// against a fresh snapshot instead of counting the abort as a purged unit, and
1677// so a caller that does not retry still sees that nothing was removed.
1678var errSessionKillParticipantsChanged = errors.New("session kill participants changed after snapshot")
1679
1680// sessionKillRetryMessage is the user-facing, retryable outcome for a teardown
1681// a concurrent detach or token resume invalidated before it removed anything.
1682// The sentinel above is internal bookkeeping, so every user-facing path (a
1683// direct kill or a close-tab command) maps it to this message instead of
1684// leaking the sentinel text or a permanent-failure verdict.
1685const sessionKillRetryMessage = "session changed during the operation; try again"
1686
1687// teardownDeadlineElapsed reports whether the supplied deadline has already
1688// expired. A nil deadline never counts as elapsed.
1689func teardownDeadlineElapsed(deadline *snapshotShutdownDeadline) bool {
1690 if deadline == nil {
1691 return false
1692 }
1693 select {
1694 case <-deadline.Done():
1695 return true
1696 default:
1697 return false
1698 }
1699}
1700
1701// killSessionWithSnapshotDeadline shares Serve's shutdown budget with its
1702// terminal checkpoint and coordinator join. A timed-out repository call keeps
1703// only immutable capture state; it never observes closed worker channels or
1704// session-owned cache that teardown has released.
1705//
1706// A deadline that wins before this call freezes every attachment gate or
1707// acquires teardown ownership returns errSessionKillDeadline, so a bounded
1708// caller can distinguish a skip from a completed teardown. An already-removed
1709// target is a no-op; a target that is still registered while its participant
1710// set, transport incarnation, or source capability changed after the snapshot
1711// returns errSessionKillParticipantsChanged, so an aborted teardown is never
1712// mistaken for a completed one. Ordinary callers that pass nil preserve their
1713// previous behavior exactly.
1714func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1715 return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, deadline, admission, sessionKillCondition{})
1716}
1717
1718// sessionKillCondition narrows a kill to a target state that is checked under
1719// the architecture locks at snapshot and again at publication.
1720type sessionKillCondition struct {
1721 // empty requires no attachment and no parked or parking resume credential.
1722 empty bool
1723 // ephemeral requires the target to still be an ephemeral session.
1724 ephemeral bool
1725}
1726
1727// killSessionIfEmpty tears down sess only while its exact registry entry has no
1728// attachments and no parked or parking resume credential. Membership is
1729// captured under the normal architecture locks and fenced again at publication, so an attachment winning either side survives.
1730func (d *Daemon) killSessionIfEmpty(sess *session, reason uint8, purge bool) error {
1731 return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, nil, nil, sessionKillCondition{empty: true})
1732}
1733
1734// killEphemeralSessionIfEmpty is killSessionIfEmpty that also refuses a target
1735// renamed into a named session after the caller's check.
1736func (d *Daemon) killEphemeralSessionIfEmpty(sess *session, reason uint8) error {
1737 return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, false, nil, nil, sessionKillCondition{empty: true, ephemeral: true})
1738}
1739
1740func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission, condition sessionKillCondition) error {
1741 if sess == nil {
1742 return nil
1743 }
1744 // Discover every attachment whose membership can be invalidated before freezing
1745 // any one of them. The common gate helper deduplicates and freezes the whole
1746 // immutable identity set in the same global order used by transitions.
1747 participants, ok := d.snapshotSessionKillParticipants(sess, admission, condition)
1748 if !ok {
1749 return nil
1750 }
1751 if admission != nil {
1752 d.endActionAttachmentEffect(admission.effect, admission.action)
1753 }
1754 action := ""
1755 if admission != nil {
1756 action = admission.action
1757 }
1758 if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1759 d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
1760 }
1761 var drainDone func() <-chan struct{}
1762 var drainDeadline *attachmentEffectDrainDeadline
1763 if deadline != nil {
1764 drainDone = deadline.Done
1765 } else {
1766 drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
1767 drainDone = drainDeadline.Done
1768 defer drainDeadline.stop()
1769 }
1770 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1771 if d.afterAttachmentEffectGateFrozen != nil {
1772 d.afterAttachmentEffectGateFrozen(action, ac)
1773 }
1774 }}, participants.effectGates...)
1775 defer func() { frozen.unfreeze() }()
1776 if !frozen.acquired {
1777 // The acquisition helper has already rolled back exactly the gates it
1778 // owned. Do not enter architecture locks or publish terminal ownership
1779 // changes unless the complete canonical participant set is frozen.
1780 if teardownDeadlineElapsed(deadline) {
1781 return errSessionKillDeadline
1782 }
1783 return nil
1784 }
1785 if !d.sessionKillParticipantsCurrent(participants) {
1786 if d.sessionKillTargetRemoved(sess) {
1787 // The exact target already left the registry, so there is nothing left
1788 // to tear down: an already-removed session is a no-op, not a
1789 // participant change.
1790 return nil
1791 }
1792 return errSessionKillParticipantsChanged
1793 }
1794
1795 // Pane EOF, explicit close, and daemon shutdown can all converge on the same
1796 // session. Only one owner may create the terminal mutation and checkpoint;
1797 // later callers observe the completed registry transition instead of
1798 // manufacturing a newer generation that the owner will quarantine. A
1799 // shutdown duplicate abandons this wait when the shared budget expires.
1800 var acquired bool
1801 reason, purge, acquired = sess.beginTeardownRequest(deadline, reason, purge)
1802 if !acquired {
1803 // beginTeardownRequest only fails to acquire when the deadline expired
1804 // (without one it waits until ownership is available), so the session is
1805 // still untouched and the skip must stay observable.
1806 if deadline != nil {
1807 return errSessionKillDeadline
1808 }
1809 return nil
1810 }
1811 defer sess.finishTeardown()
1812 // Capture eligibility before any coordinator quarantine flips it. Every
1813 // post-quarantine abort that leaves this session registered restores exactly
1814 // this value, so a surviving session resumes checkpoint scheduling instead of
1815 // staying permanently frozen. The rollback is gated on the same teardown
1816 // ownership as finishTeardown and on the exact registry entry, so a completed
1817 // removal never resurrects scheduling.
1818 wasEligible := sess.snapEligible.Load()
1819 quarantined := false
1820 defer func() {
1821 if quarantined && d.sessionRegistered(sess) {
1822 rollbackSnapshotQuarantine(sess, wasEligible)
1823 }
1824 }()
1825
1826 d.mu.Lock()
1827 current := d.sessions[sess.id]
1828 d.mu.Unlock()
1829 if current != sess {
1830 if purge {
1831 sess.mu.Lock()
1832 name := sess.name
1833 sess.mu.Unlock()
1834 return d.retryStoppedPurge(name)
1835 }
1836 return nil
1837 }
1838
1839 ringing := sess.anyAttention()
1840 sess.mu.Lock()
1841 isEphemeral := sess.ephemeral
1842 name := sess.name
1843 incarnation := sess.incarnation
1844 sess.mu.Unlock()
1845 // Join snapshot publication before changing live identity or deleting the
1846 // incarnation directory, so an older publication cannot recreate it. A shared
1847 // caller deadline bounds the join: a repository call that ignores cancellation
1848 // must never hold purge admission open, so an expired budget leaves the unit
1849 // live and reports the typed skip instead.
1850 if purge && !isEphemeral {
1851 if err := d.beginSnapshotPurge(name, incarnation); err != nil {
1852 return err
1853 }
1854 if d.snapshotRepository != nil {
1855 quarantineDone := quarantineSnapshotCoordinator(sess)
1856 quarantined = true
1857 if deadline != nil {
1858 select {
1859 case <-quarantineDone:
1860 case <-deadline.Done():
1861 return errSessionKillDeadline
1862 }
1863 } else {
1864 <-quarantineDone
1865 }
1866 }
1867 }
1868 if !purge && !isEphemeral && d.persistEnabled {
1869 d.refreshSessionCwd(sess)
1870 }
1871 var terminalSnapshotErr error
1872 retainSnapshotRetry := false
1873 if d.snapsEnabled && !isEphemeral && !purge {
1874 markSnapshotDirty(sess)
1875 sess.snapshotMu.Lock()
1876 terminalGeneration := sess.snapshotGeneration
1877 sess.snapshotMu.Unlock()
1878 if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) {
1879 sess.mu.Lock()
1880 name := sess.name
1881 sess.mu.Unlock()
1882 terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
1883 if reason != protocol.ReasonServerShutdown {
1884 return terminalSnapshotErr
1885 }
1886 // Preserve any already-queued routine capture until the blocked
1887 // worker can safely discard it. This retains the dirty generation
1888 // and forced retry intent without adding another queue entry.
1889 retainSnapshotRetry = true
1890 d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
1891 }
1892 }
1893 if !isEphemeral {
1894 // Joining first preserves byte ownership for a publication that is still
1895 // encoding while this session is being torn down. Once Serve's shared
1896 // budget expires, the worker is cancelled by its one final join instead;
1897 // keep the cache reachable because an uncooperative writer may still be
1898 // encoding immutable state.
1899 var quarantineDone <-chan struct{}
1900 if retainSnapshotRetry {
1901 quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
1902 } else {
1903 quarantineDone = quarantineSnapshotCoordinator(sess)
1904 }
1905 quarantined = true
1906 joined := true
1907 if deadline != nil {
1908 select {
1909 case <-quarantineDone:
1910 case <-deadline.Done():
1911 joined = false
1912 }
1913 } else {
1914 <-quarantineDone
1915 }
1916 if joined {
1917 sess.snapshotMu.Lock()
1918 sess.snapshotChunkCache = nil
1919 sess.snapshotMu.Unlock()
1920 }
1921 if d.afterSessionKillQuarantine != nil {
1922 d.afterSessionKillQuarantine(sess)
1923 }
1924 }
1925 // Revalidate the exact source capability, target lifecycle, and attachment
1926 // memberships under canonical architecture locks immediately before
1927 // publication. A changed preflight aborts without mutating either lifecycle.
1928 d.mu.Lock()
1929 if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
1930 d.mu.Unlock()
1931 return nil
1932 }
1933 d.notices.routingMu.Lock()
1934 source := participants.source
1935 if source == nil {
1936 source = sess
1937 }
1938 sourceCoordinator := source.renderCoordinator()
1939 targetCoordinator := sess.renderCoordinator()
1940 unlockSessions := lockAttachmentSessions(source, sess)
1941 unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator)
1942 if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) {
1943 unlockCoordinators()
1944 unlockSessions()
1945 d.notices.routingMu.Unlock()
1946 d.mu.Unlock()
1947 return errSessionKillParticipantsChanged
1948 }
1949
1950 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1951 // here. Registry removal and every attachment invalidation are one publication
1952 // while the complete immutable gate set remains frozen.
1953 if !d.unregisterSessionLocked(sess) {
1954 unlockCoordinators()
1955 unlockSessions()
1956 d.notices.routingMu.Unlock()
1957 d.mu.Unlock()
1958 return errAttachmentTransition
1959 }
1960 d.clearBarScriptsForSession(sess.id)
1961 d.purgeParkingForSessionLocked(sess)
1962 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1963 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
1964 capture := func(attached *attachedClient) {
1965 if attached == nil {
1966 return
1967 }
1968 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1969 attached.setSession(nil)
1970 attached.invalidateFrozenAttachmentCapability()
1971 }
1972 for attached := range sess.attachments {
1973 capture(attached)
1974 }
1975 clear(sess.attachments)
1976 d.purgeSuspendedForSessionLocked(sess)
1977 stoppedName := sess.name
1978 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1979 ephemeral := sess.ephemeral
1980 unlockCoordinators()
1981 unlockSessions()
1982 d.notices.routingMu.Unlock()
1983 if !ephemeral {
1984 stopped := inactiveSessionFromRecord(stoppedRecord, protocol.SessionDown, nil)
1985 stopped.purging = purge
1986 d.inactive[stoppedName] = stopped
1987 }
1988 d.mu.Unlock()
1989 d.finishParkedAttachmentRetirements(parkedRetirements)
1990 frozen.unfreeze()
1991 frozen = attachmentTransitionGuard{}
1992 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1993
1994 var purgeErr error
1995 if rc := sess.renderCoordinator(); rc != nil {
1996 // Terminal teardown has two phases: this session owner first prevents any
1997 // new worker registration and detaches all tokens, then stops and waits
1998 // outside coordinator callbacks and session locks.
1999 rc.beginSessionTeardown().finish()
2000 rc.waitForTimerWorkers()
2001 }
2002 for _, attachment := range attachments {
2003 attachment.ac.clearCaptureFrames()
2004 }
2005
2006 // Prevent queued launches from entering Open, cancel the parent context, and
2007 // close PTYs. Serve may already have performed this phase while repository
2008 // work owned by this teardown was blocked.
2009 sess.stopInMemoryLifecycle()
2010 sess.mu.Lock()
2011 tabs := append([]*tab(nil), sess.tabs...)
2012 clipFiles := sess.clipFiles
2013 sess.clipFiles = nil
2014 sess.mu.Unlock()
2015 for _, tb := range tabs {
2016 for _, attachment := range attachments {
2017 d.teardownFloating(tb, attachment.ac)
2018 }
2019 }
2020 for _, path := range clipFiles {
2021 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
2022 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
2023 }
2024 }
2025 // The coordinator and all panes are now stopped, so no producer can publish
2026 // another generation after this destructive source deletion. Keep the hidden
2027 // stopped record when deletion fails or is still pending so a repeated live
2028 // kill can retry. The purge-owned exact delete is bounded: a repository
2029 // delete that ignores cancellation is detached behind a name/incarnation/
2030 // createdAt fence rather than holding purgeAllMu, admission, or control
2031 // responses.
2032 if !ephemeral && purge {
2033 if err := d.finishLiveDurablePurge(deadline, stoppedRecord); err != nil {
2034 purgeErr = errors.Join(purgeErr, err)
2035 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
2036 }
2037 }
2038 if purge && purgeErr == nil && isEphemeral {
2039 // An ephemeral purge owns no durable record, but the remaining sessions'
2040 // route histories may still reference it.
2041 d.reconcileAllRouteHistories()
2042 }
2043 if ringing {
2044 d.repaintAllAttachedClients()
2045 }
2046
2047 for _, attachment := range attachments {
2048 // The transport is still available after membership publication. Retire
2049 // attachment-owned terminal output before sending Detached.
2050 d.cleanupAttachmentOutput(attachment.ac)
2051 d.notifyDetachedSnapshotAsync(attachment, reason)
2052 }
2053 return errors.Join(purgeErr, terminalSnapshotErr)
2054}
2055
2056// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
2057// d.mu.
2058func (d *Daemon) allocEphemeralNameLocked() string {
2059 used := make(map[string]struct{}, len(d.sessions)+len(d.inactive))
2060 for _, s := range d.sessions {
2061 if s != nil {
2062 used[s.name] = struct{}{}
2063 }
2064 }
2065 for name := range d.inactive {
2066 used[name] = struct{}{}
2067 }
2068 for i := 0; ; i++ {
2069 n := strconv.Itoa(i)
2070 if _, taken := used[n]; !taken {
2071 return n
2072 }
2073 }
2074}
2075
2076func (d *Daemon) findByNameLocked(name string) *session {
2077 for _, s := range d.sessions {
2078 if s != nil && s.name == name {
2079 return s
2080 }
2081 }
2082 return nil
2083}
2084
2085func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
2086 if d.findByNameLocked(name) != nil {
2087 return true
2088 }
2089 if _, ok := d.inactive[name]; ok {
2090 return true
2091 }
2092 _, reserved := d.creating[name]
2093 return reserved
2094}
2095
2096func (d *Daemon) cwdSampler(ctx context.Context) {
2097 t := d.clock.NewTimer(5 * time.Second)
2098 defer t.Stop()
2099 for {
2100 select {
2101 case <-ctx.Done():
2102 return
2103 case <-t.C():
2104 }
2105 if d.procCwd != nil {
2106 d.refreshNamedSessionCwds()
2107 }
2108 if err := d.flushCatalogue(); err != nil {
2109 d.log.Warn("flushing session catalogue failed", "err", err)
2110 }
2111 t.Reset(5 * time.Second)
2112 }
2113}
2114
2115func (d *Daemon) refreshNamedSessionCwds() {
2116 d.mu.Lock()
2117 sessions := d.sessionsSnapshotLocked()
2118 d.mu.Unlock()
2119 for _, sess := range sessions {
2120 sess.mu.Lock()
2121 ephemeral := sess.ephemeral
2122 sess.mu.Unlock()
2123 if !ephemeral {
2124 d.refreshSessionCwd(sess)
2125 }
2126 }
2127}
2128
2129func (d *Daemon) refreshSessionCwd(sess *session) {
2130 if d.procCwd == nil {
2131 return
2132 }
2133 tb := sess.firstTab()
2134 if tb == nil {
2135 return
2136 }
2137 tb.mu.Lock()
2138 p := tb.focusedPane()
2139 tb.mu.Unlock()
2140 if p == nil {
2141 return
2142 }
2143 cwd, err := d.procCwd(p.pty.Pid())
2144 if err != nil || cwd == "" {
2145 return
2146 }
2147 d.mu.Lock()
2148 if d.sessions[sess.id] != sess {
2149 d.mu.Unlock()
2150 return
2151 }
2152 sess.mu.Lock()
2153 if sess.cwd == cwd {
2154 sess.mu.Unlock()
2155 d.mu.Unlock()
2156 return
2157 }
2158 sess.cwd = cwd
2159 if !sess.ephemeral {
2160 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
2161 }
2162 sess.mu.Unlock()
2163 d.mu.Unlock()
2164 markSnapshotDirty(sess)
2165}
2166
2167// childEnv retains the daemon-environment helper for daemon-local legacy callers.
2168// Interactive PTY launch paths use childEnvFrom with their session snapshot.
2169func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
2170 return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID)
2171}
2172
2173func copyEnvironment(env []string) []string {
2174 return append([]string(nil), env...)
2175}
2176
2177func environmentEntry(entry string) (name, value string, ok bool) {
2178 name, value, ok = strings.Cut(entry, "=")
2179 return name, value, ok
2180}
2181
2182const defaultShellCommand = "/bin/sh"
2183const defaultShellTitle = "sh"
2184
2185type shellLaunchSpec struct {
2186 command string
2187 args []string
2188 title string
2189}
2190
2191// shellLaunch resolves the command and display fallback for one normal PTY.
2192// Both values come from the same client environment snapshot (or explicit
2193// daemon override), so headless catalogues identify the shell they actually run.
2194func (d *Daemon) shellLaunch(env []string) shellLaunchSpec {
2195 command := shellFromEnvironment(env)
2196 var args []string
2197 if d.shellOverride {
2198 command = d.shell
2199 args = append([]string(nil), d.shellArgs...)
2200 }
2201 return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)}
2202}
2203
2204func shellDisplayName(command string) string {
2205 title := filepath.Base(command)
2206 if title == "." || title == string(filepath.Separator) || title == "" {
2207 return command
2208 }
2209 return title
2210}
2211
2212func shellFromEnvironment(env []string) string {
2213 shell := ""
2214 for _, entry := range env {
2215 if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
2216 shell = value
2217 }
2218 }
2219 if shell == "" {
2220 return defaultShellCommand
2221 }
2222 return shell
2223}
2224
2225// childEnvFrom preserves the supplied environment byte-for-byte and in order,
2226// except for exact reserved variable names which vev owns. TERM starts with the
2227// compatibility default; the PTY adapter selects the host-available profile.
2228func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
2229 out := make([]string, 0, len(env)+4)
2230 for _, entry := range env {
2231 key, _, ok := environmentEntry(entry)
2232 if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
2233 continue
2234 }
2235 out = append(out, entry)
2236 }
2237 out = append(out, "TERM=xterm-256color", "COLORTERM=truecolor")
2238 return append(out,
2239 "TERM_PROGRAM=vev",
2240 "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
2241 )
2242}