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