session.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close