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