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/domain/layout"
21	"github.com/bnema/vev/internal/ports"
22	"github.com/bnema/vev/internal/protocol"
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.snapshots.wake,
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	if p.prestarted {
900		// A claimed spare keeps the reader it started with.
901		return
902	}
903	// Scheduler ownership was removed; this launch creates exactly one reader.
904	d.sessWg.Go(func() { d.readPanePTY(p) })
905}
906
907// detachIfCurrent publishes terminal attachment invalidation through the attachment
908// gate. Freeze/drain happens before routing or session locks are acquired.
909func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
910	return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
911}
912
913// detachIfCurrentTransport is detachIfCurrent with an optional exact-link
914// fence. When expected.transport is non-nil, a rebound incarnation of the same
915// attachment object cannot be detached by a stale connection loop.
916func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool {
917	if sess == nil || ac == nil {
918		return false
919	}
920	frozen := freezeAttachmentEffectGates(ac)
921	defer frozen.unfreeze()
922	if d.afterDetachAttachmentEffectsFrozen != nil {
923		d.afterDetachAttachmentEffectsFrozen()
924	}
925	d.notices.routingMu.Lock()
926	sess.mu.Lock()
927	current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
928	if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
929		current = false
930	}
931	if current {
932		current = detachFrozenAttachmentLocked(sess, ac)
933	}
934	sess.mu.Unlock()
935	d.notices.routingMu.Unlock()
936	if current {
937		// Terminal detach ends any daemon-owned suspension expiry. The clear runs
938		// outside routingMu so it never inverts the daemon -> routing lock order.
939		d.clearSuspendedExpiry(ac)
940	}
941	return current
942}
943
944// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
945// whose admitted send failed. A queued stale error cannot detach a later
946// attachment publication or a rebound transport on the same attachment object.
947func (d *Daemon) detachIfAttachmentCurrent(token attachmentCapability) bool {
948	return d.detachIfAttachmentCurrentUntil(token, nil)
949}
950
951func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done func() <-chan struct{}) bool {
952	if token.sess == nil || token.ac == nil || token.transport.transport == nil {
953		return false
954	}
955	// Attachments without the session's optional primary render lease still
956	// own an independent connection lifecycle and detach through the same exact
957	// transport/generation fence.
958	if token.lease == nil {
959		return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
960	}
961	frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
962	defer frozen.unfreeze()
963	if !frozen.acquired || !frozen.drained {
964		return false
965	}
966	if d.afterDetachAttachmentEffectsFrozen != nil {
967		d.afterDetachAttachmentEffectsFrozen()
968	}
969	d.mu.Lock()
970	core := token.sess.core()
971	if core == nil || d.sessions[core.id] != token.sess {
972		d.mu.Unlock()
973		return false
974	}
975	d.notices.routingMu.Lock()
976	core.mu.Lock()
977	coordinator := core.coordinator.Load()
978	if coordinator != nil {
979		coordinator.mu.Lock()
980	}
981	current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
982	if current {
983		current = detachFrozenAttachmentLocked(token.sess, token.ac)
984	}
985	if coordinator != nil {
986		coordinator.mu.Unlock()
987	}
988	core.mu.Unlock()
989	d.notices.routingMu.Unlock()
990	d.mu.Unlock()
991	if current {
992		// The suspension expiry is daemon-scoped; clear it only after every other
993		// architecture lock is released.
994		d.clearSuspendedExpiry(token.ac)
995	}
996	return current
997}
998
999func (d *Daemon) renameSession(sess *session, name string) error {
1000	if name == "" {
1001		return errSessionNameRequired
1002	}
1003	if err := domain.ValidateSessionName(name); err != nil {
1004		return err
1005	}
1006
1007	// Hold daemon then session state across the durable identity write. The
1008	// incarnation-keyed snapshot namespace is never relocated.
1009	d.mu.Lock()
1010	if taken := d.findByNameLocked(name); taken != nil && taken != sess {
1011		d.mu.Unlock()
1012		return errSessionNameInUse
1013	}
1014	sess.mu.Lock()
1015	if stopped, ok := d.inactive[name]; ok && stopped.name != sess.name {
1016		sess.mu.Unlock()
1017		d.mu.Unlock()
1018		return errSessionNameInUse
1019	}
1020	oldName := sess.name
1021	wasEphemeral := sess.ephemeral
1022	createdAt := sess.createdAt
1023	priorIncarnation := sess.incarnation
1024	if wasEphemeral {
1025		var err error
1026		createdAt, err = d.allocateLifecycleCreatedAtLocked()
1027		if err != nil {
1028			sess.mu.Unlock()
1029			d.mu.Unlock()
1030			return err
1031		}
1032		if sess.incarnation == (domain.IncarnationID{}) {
1033			sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
1034			if err != nil {
1035				sess.mu.Unlock()
1036				d.mu.Unlock()
1037				return fmt.Errorf("generate durable identity: %w", err)
1038			}
1039		}
1040	}
1041	lastUsedSeq := sess.mruAt.Load()
1042	record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1043	record.Name = name
1044	record.CreatedAt = createdAt
1045	record.LastUsedSeq = lastUsedSeq
1046	rollback := func(err error) error {
1047		sess.incarnation = priorIncarnation
1048		sess.mu.Unlock()
1049		d.mu.Unlock()
1050		return err
1051	}
1052	// Durable named renames are one atomic catalogue batch. The incarnation-keyed
1053	// snapshot namespace is intentionally untouched.
1054	if (wasEphemeral || oldName != name) && d.persistEnabled {
1055		if d.recovery == nil {
1056			return rollback(errors.New("durable session authority is not configured"))
1057		}
1058		{
1059			var committed domain.CatalogueRecord
1060			var err error
1061			if wasEphemeral {
1062				committed, err = d.recovery.Create(d.serveCtx, record)
1063			} else {
1064				committed, err = d.recovery.Rename(d.serveCtx, oldName, name)
1065			}
1066			if err != nil {
1067				return rollback(err)
1068			}
1069			record = committed
1070		}
1071	}
1072
1073	delete(d.inactive, oldName)
1074	delete(d.inactive, name)
1075	sess.name = name
1076	sess.createdAt = createdAt
1077	sess.incarnation = record.IncarnationID
1078	sess.ephemeral = false
1079	sess.snapEligible.Store(name != "")
1080	sess.mu.Unlock()
1081	d.mu.Unlock()
1082	if wasEphemeral {
1083		sess.snapshotMu.Lock()
1084		sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
1085		sess.snapshotMu.Unlock()
1086	}
1087	markSnapshotDirty(sess)
1088	d.publishRenamedSessionRouteIdentity(sess)
1089	return nil
1090}
1091
1092// publishRenamedSessionRouteIdentity updates each ready client's exact route
1093// target after a session rename. A renamed session retains its lifecycle but
1094// its old name is no longer attachable, so clients must replace—not retain—the
1095// prior route record.
1096func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
1097	for _, ac := range sess.snapshotAttachments() {
1098		if ac.deferRouteIdentityUntilSnapshot() {
1099			continue
1100		}
1101		token := sess.captureAttachmentCapability(ac, ac.transport())
1102		if token.ac == nil {
1103			continue
1104		}
1105		effect, admitted := ac.beginAttachmentEffect(token)
1106		if !admitted {
1107			continue
1108		}
1109		err := d.sendCommittedRouteIdentityForAttachment(effect)
1110		capability := effect.capability()
1111		effect.End()
1112		if err != nil {
1113			d.detachOnAttachmentSendError(capability, capability.transport.transport)
1114		}
1115	}
1116}
1117
1118func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
1119	if tb == nil {
1120		return errors.New("tab required")
1121	}
1122	d.mu.Lock()
1123	if d.sessions[sess.id] != sess {
1124		d.mu.Unlock()
1125		return errors.New("daemon: session closed")
1126	}
1127	sess.mu.Lock()
1128	if !slices.Contains(sess.tabs, tb) {
1129		sess.mu.Unlock()
1130		d.mu.Unlock()
1131		return errors.New("tab not found")
1132	}
1133	tb.name = name
1134	if !sess.ephemeral {
1135		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1136	}
1137	sess.mu.Unlock()
1138	d.mu.Unlock()
1139	return nil
1140}
1141
1142func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord {
1143	if len(tabs) == 0 {
1144		return nil
1145	}
1146	records := make([]domain.CatalogueTabRecord, len(tabs))
1147	for i, tb := range tabs {
1148		if tb == nil {
1149			continue
1150		}
1151		records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name}
1152	}
1153	return records
1154}
1155
1156func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
1157	createdAt := s.createdAt
1158	tabNames := make([]string, len(s.tabs))
1159	lastCustom := -1
1160	for i, tb := range s.tabs {
1161		tabNames[i] = tb.name
1162		if tb.name != "" {
1163			lastCustom = i
1164		}
1165	}
1166	if lastCustom == -1 {
1167		tabNames = nil
1168	} else {
1169		tabNames = tabNames[:lastCustom+1]
1170	}
1171	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)}
1172}
1173
1174// closeTab is the public close boundary for callers that are not already
1175// inside session.runMutation. Mutation dispatchers use closeTabLocked.
1176func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1177	if sess == nil {
1178		return layout.ErrNotFound
1179	}
1180	sess.dispatchMu.Lock()
1181	defer sess.dispatchMu.Unlock()
1182	return d.closeTabLocked(sess, tb, repaint)
1183}
1184
1185// closeTabLocked requires sess.dispatchMu before entering daemon or session
1186// locks. Existing runMutation callers use it to avoid nested dispatch locking.
1187func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
1188	return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
1189}
1190
1191func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffect) error {
1192	if sess == nil || tb == nil {
1193		return layout.ErrNotFound
1194	}
1195	d.mu.Lock()
1196	if d.sessions[sess.id] != sess {
1197		d.mu.Unlock()
1198		return errors.New("daemon: session closed")
1199	}
1200	sess.mu.Lock()
1201	idx := -1
1202	for i, w := range sess.tabs {
1203		if w == tb {
1204			idx = i
1205			break
1206		}
1207	}
1208	if idx == -1 {
1209		sess.mu.Unlock()
1210		d.mu.Unlock()
1211		return errors.New("tab not found")
1212	}
1213	if len(sess.tabs) == 1 {
1214		name := sess.name
1215		sess.mu.Unlock()
1216		d.mu.Unlock()
1217		var err error
1218		if effect != nil {
1219			effect.bindActionEnd(d, "close-tab")
1220			err = d.killSessionForAttachment(sess, protocol.ReasonSessionKilled, true, effect, "close-tab")
1221		} else {
1222			err = d.killSession(sess, protocol.ReasonSessionKilled, true)
1223		}
1224		if err != nil {
1225			d.log.Warn("closing last tab failed", "session", name, "err", err)
1226			d.reportError(sess, closeTabFailureNotice(err))
1227			return err
1228		}
1229		d.log.Info("tab closed", "session", name, "last", true)
1230		return nil
1231	}
1232	ringing := tb.attention
1233	sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx)
1234	sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1235	attachments := sess.snapshotAttachmentsLocked()
1236	name := sess.name
1237	if !sess.ephemeral {
1238		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1239	}
1240	sess.invalidateViewsLocked()
1241	sess.mu.Unlock()
1242	d.mu.Unlock()
1243	d.log.Info("tab closed", "session", name)
1244	markSnapshotDirty(sess)
1245
1246	tb.mu.Lock()
1247	panes := tb.panesSnapshot()
1248	tb.mu.Unlock()
1249	for _, ac := range attachments {
1250		ac.pruneCaptureFrames(panes...)
1251	}
1252	if tb.cancel != nil {
1253		tb.cancel()
1254	}
1255	for _, ac := range attachments {
1256		d.teardownFloating(tb, ac)
1257	}
1258	if rc := sess.renderCoordinator(); rc != nil {
1259		for _, p := range panes {
1260			rc.noteSyncPaneRemoved(p)
1261		}
1262	}
1263	tb.closeAllPanes()
1264	if repaint {
1265		for _, ac := range attachments {
1266			d.invalidateRender(sess, ac, true, "session.go")
1267		}
1268	}
1269	if ringing {
1270		d.repaintAllAttachedClients()
1271	}
1272	return nil
1273}
1274
1275// closeTabFailureNotice classifies a failed last-tab close for the attached
1276// client. A participants-changed abort removed nothing and is safe to retry, so
1277// it reports that retryable outcome instead of the snapshot-write failure the
1278// other teardown errors carry.
1279func closeTabFailureNotice(err error) error {
1280	if errors.Is(err, errSessionKillParticipantsChanged) {
1281		return domain.UserErr(domain.NoticeSessionUnavailable,
1282			"couldn't close tab: session changed during the close; try again", err)
1283	}
1284	return domain.UserErr(domain.NoticeSnapshotSaturated,
1285		"couldn't close tab: session state not yet saved; try again", err)
1286}
1287
1288// ptyReader drains child output into the VT screen and pokes the dirty channel
1289// (non-blocking: a full channel already means a render is pending). On any read
1290
1291// beginSnapshotPurge commits a logical named-session kill before its live
1292// identity or persisted metadata is removed. It never runs under daemon or
1293// session locks.
1294func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
1295	if d.persistEnabled && d.recovery == nil {
1296		return errors.New("durable session authority is not configured")
1297	}
1298	// The lifecycle coordinator performs the complete ordered protocol after
1299	// snapshot writers have joined.
1300	return nil
1301}
1302
1303// finishSnapshotPurge removes catalogue metadata first, then deletes the
1304// incarnation directory. Background garbage collection removes the directory if
1305// the second step is interrupted.
1306func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, incarnation domain.IncarnationID, createdAt int64) error {
1307	if !d.persistEnabled {
1308		return nil
1309	}
1310	if d.recovery == nil {
1311		return errors.New("durable session authority is not configured")
1312	}
1313	if ctx == nil {
1314		ctx = d.serveCtx
1315	}
1316	purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout)
1317	defer cancel()
1318	return d.recovery.DeleteExact(purgeCtx, name, incarnation, createdAt)
1319}
1320
1321// retryStoppedPurge completes a previously closed session's durable purge.
1322// It keeps the stopped record hidden until all sources and metadata cross their
1323// deletion boundaries.
1324func (d *Daemon) retryStoppedPurge(name string) error {
1325	return d.retryStoppedPurgeContext(d.serveCtx, name)
1326}
1327
1328func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1329	return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt)
1330}
1331
1332func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1333	return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil)
1334}
1335
1336func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1337	d.mu.Lock()
1338	stopped, ok := d.inactive[name]
1339	if !ok {
1340		d.mu.Unlock()
1341		return nil
1342	}
1343	incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation
1344	createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt
1345	if incarnationChanged || createdAtChanged {
1346		d.mu.Unlock()
1347		return nil
1348	}
1349	stopped.purging = true
1350	d.inactive[name] = stopped
1351	d.mu.Unlock()
1352
1353	if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
1354		return err
1355	}
1356	if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation, stopped.createdAt); err != nil {
1357		return err
1358	}
1359	d.mu.Lock()
1360	if current, ok := d.inactive[name]; ok && current.purging && current.sameLifecycle(stopped) {
1361		delete(d.inactive, name)
1362	}
1363	d.mu.Unlock()
1364	d.reconcileAllRouteHistories()
1365	return nil
1366}
1367
1368// killSession removes a session and tears down its resources. It is
1369// idempotent: only the caller that wins the registry delete acts. Final
1370// session removal is independent of daemon shutdown: an empty registry leaves
1371// the daemon serving (closing is untouched) so a later create/restore can
1372// occupy it again, and only an explicit shutdownAll ends the daemon.
1373//
1374// Teardown ordering matters: context cancel and pty.Close run first and
1375// unconditionally — never gated behind a client send. The Detached notice is
1376// delivered asynchronously under the drain deadline.
1377func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1378	return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil)
1379}
1380
1381type sessionKillAdmission struct {
1382	capability attachmentCapability
1383	effect     *attachmentEffect
1384	action     string
1385}
1386
1387type sessionKillParticipants struct {
1388	attachments map[*attachedClient]struct{}
1389	transports  map[*attachedClient]transportSnapshot
1390	effectGates []*attachedClient
1391	interrupts  []attachmentTransportInterrupt
1392	source      *session
1393	sourceToken *attachmentCapability
1394	target      *session
1395	// requireEphemeral aborts the kill if the target stopped being ephemeral
1396	// (a concurrent rename) between the snapshot and publication.
1397	requireEphemeral bool
1398}
1399
1400func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error {
1401	if effect == nil {
1402		return errAttachmentTransition
1403	}
1404	return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action})
1405}
1406
1407// snapshotSessionKillParticipants captures attachment membership under the canonical
1408// architecture locks, but releases every lock before any attachment gate is frozen or
1409// drained. The initiator is part of the same deduplicated global gate set even
1410// when it belongs to a different source session.
1411func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, condition sessionKillCondition) (sessionKillParticipants, bool) {
1412	snapshot := sessionKillParticipants{target: target, requireEphemeral: condition.ephemeral}
1413	if admission != nil {
1414		token := admission.capability
1415		if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1416			return sessionKillParticipants{}, false
1417		}
1418		snapshot.source = token.sess
1419		if snapshot.source == nil {
1420			return sessionKillParticipants{}, false
1421		}
1422		snapshot.sourceToken = &admission.capability
1423	}
1424
1425	d.mu.Lock()
1426	if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1427		d.mu.Unlock()
1428		return sessionKillParticipants{}, false
1429	}
1430	d.notices.routingMu.Lock()
1431	source := snapshot.source
1432	if source == nil {
1433		source = target
1434	}
1435	unlockSessions := lockAttachmentSessions(source, target)
1436	if condition.empty && (len(target.attachments) != 0 || d.sessionHasPendingResumeLocked(target)) ||
1437		condition.ephemeral && !target.ephemeral {
1438		unlockSessions()
1439		d.notices.routingMu.Unlock()
1440		d.mu.Unlock()
1441		return sessionKillParticipants{}, false
1442	}
1443	snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1444	snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1445	for ac := range target.attachments {
1446		snapshot.attachments[ac] = struct{}{}
1447		snapshot.transports[ac] = ac.transportSnapshot()
1448	}
1449
1450	unlockSessions()
1451	d.notices.routingMu.Unlock()
1452	d.mu.Unlock()
1453
1454	seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
1455	add := func(ac *attachedClient) {
1456		if ac == nil {
1457			return
1458		}
1459		if _, duplicate := seen[ac]; duplicate {
1460			return
1461		}
1462		seen[ac] = struct{}{}
1463		snapshot.effectGates = append(snapshot.effectGates, ac)
1464	}
1465	if admission != nil {
1466		add(admission.capability.ac)
1467	}
1468	for ac := range snapshot.attachments {
1469		add(ac)
1470	}
1471	for ac, transport := range snapshot.transports {
1472		if transport.transport != nil {
1473			snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
1474		}
1475	}
1476	return snapshot, true
1477}
1478
1479// sessionKillTargetRemoved reports whether the exact target session has already
1480// left the live registry, so a post-freeze mismatch is an already-removed no-op
1481// rather than a participant change that left work undone.
1482func (d *Daemon) sessionKillTargetRemoved(target *session) bool {
1483	if target == nil {
1484		return true
1485	}
1486	d.mu.Lock()
1487	defer d.mu.Unlock()
1488	return d.sessions[target.id] != target
1489}
1490
1491// sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1492// target-membership validation while every discovered attachment gate is frozen.
1493func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1494	d.mu.Lock()
1495	defer d.mu.Unlock()
1496	if d.sessions[snapshot.target.id] != snapshot.target ||
1497		snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1498		return false
1499	}
1500	d.notices.routingMu.Lock()
1501	defer d.notices.routingMu.Unlock()
1502	source := snapshot.source
1503	if source == nil {
1504		source = snapshot.target
1505	}
1506	sourceCoordinator := source.renderCoordinator()
1507	targetCoordinator := snapshot.target.renderCoordinator()
1508	unlockSessions := lockAttachmentSessions(source, snapshot.target)
1509	defer unlockSessions()
1510	unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator)
1511	defer unlockCoordinators()
1512	return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator)
1513}
1514
1515// sessionKillParticipantsCurrentLocked requires the ordered source/target
1516// session and coordinator locks.
1517func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1518	target := snapshot.target
1519	if snapshot.requireEphemeral && !target.ephemeral {
1520		return false
1521	}
1522	if len(target.attachments) != len(snapshot.attachments) {
1523		return false
1524	}
1525	for ac := range snapshot.attachments {
1526		if _, current := target.attachments[ac]; !current {
1527			return false
1528		}
1529	}
1530
1531	for ac, transport := range snapshot.transports {
1532		if !ac.transportSnapshotCurrent(transport) {
1533			return false
1534		}
1535	}
1536	if snapshot.sourceToken == nil {
1537		return true
1538	}
1539	token := *snapshot.sourceToken
1540	return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator)
1541}
1542
1543// stopInMemoryLifecycle cancels every session producer and closes every PTY
1544// independently of teardown ownership. Repository work may remain blocked, but
1545// repeated shutdown passes cannot leave pane readers or launches running.
1546func (s *session) stopInMemoryLifecycle() {
1547	if s == nil {
1548		return
1549	}
1550	s.lifecycleStopOnce.Do(func() {
1551		s.stopFloatingLaunches()
1552		s.closeFloatingSpare()
1553		if s.cancel != nil {
1554			s.cancel()
1555		}
1556		s.mu.Lock()
1557		tabs := append([]*tab(nil), s.tabs...)
1558		s.mu.Unlock()
1559		for _, tb := range tabs {
1560			tb.closeAllPanes()
1561		}
1562	})
1563}
1564
1565func (s *session) teardownChangeLocked() chan struct{} {
1566	if s.teardownChanged == nil {
1567		s.teardownChanged = make(chan struct{})
1568	}
1569	return s.teardownChanged
1570}
1571
1572func (s *session) signalTeardownChangedLocked() {
1573	close(s.teardownChangeLocked())
1574	s.teardownChanged = make(chan struct{})
1575}
1576
1577// reserveShutdownTeardown makes preservation the authoritative terminal
1578// disposition before shutdown cancels PTYs. PTY EOF may still win teardown
1579// ownership, but it can no longer reinterpret daemon cancellation as a natural
1580// final-shell exit and purge the named session.
1581func (s *session) reserveShutdownTeardown() {
1582	if s == nil {
1583		return
1584	}
1585	s.teardownMu.Lock()
1586	s.shutdownTeardownRequested = true
1587	s.signalTeardownChangedLocked()
1588	s.teardownMu.Unlock()
1589}
1590
1591// beginTeardown waits for ownership without holding teardownMu across teardown.
1592// A false result means Serve's shared shutdown budget expired while another
1593// lifecycle path owned the session. Ordinary callers wait and retry ownership,
1594// preserving failed-teardown and stopped-purge retry semantics.
1595func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1596	_, _, acquired := s.beginTeardownRequest(deadline, 0, false)
1597	return acquired
1598}
1599
1600// beginTeardownRequest acquires teardown ownership and resolves its terminal
1601// disposition atomically with shutdown reservation. This is the sole boundary
1602// allowed to turn a teardown trigger into preserve-versus-purge policy.
1603func (s *session) beginTeardownRequest(deadline *snapshotShutdownDeadline, reason uint8, purge bool) (uint8, bool, bool) {
1604	for {
1605		if deadline != nil {
1606			select {
1607			case <-deadline.Done():
1608				return reason, purge, false
1609			default:
1610			}
1611		}
1612
1613		s.teardownMu.Lock()
1614		if !s.teardownActive && s.moveReservations == 0 {
1615			if deadline != nil {
1616				select {
1617				case <-deadline.Done():
1618					s.teardownMu.Unlock()
1619					return reason, purge, false
1620				default:
1621				}
1622			}
1623			s.teardownActive = true
1624			s.teardownDone = make(chan struct{})
1625			s.signalTeardownChangedLocked()
1626			if s.shutdownTeardownRequested {
1627				reason = protocol.ReasonServerShutdown
1628				purge = false
1629			}
1630			s.teardownMu.Unlock()
1631			return reason, purge, true
1632		}
1633		s.teardownWaiters++
1634		s.signalTeardownChangedLocked()
1635		changed := s.teardownChangeLocked()
1636		s.teardownMu.Unlock()
1637
1638		timedOut := false
1639		if deadline == nil {
1640			<-changed
1641		} else {
1642			select {
1643			case <-changed:
1644			case <-deadline.Done():
1645				timedOut = true
1646			}
1647		}
1648		s.teardownMu.Lock()
1649		s.teardownWaiters--
1650		s.signalTeardownChangedLocked()
1651		s.teardownMu.Unlock()
1652		if timedOut {
1653			return reason, purge, false
1654		}
1655	}
1656}
1657
1658func (s *session) finishTeardown() {
1659	s.teardownMu.Lock()
1660	s.teardownActive = false
1661	close(s.teardownDone)
1662	s.teardownDone = nil
1663	s.signalTeardownChangedLocked()
1664	s.teardownMu.Unlock()
1665}
1666
1667// errSessionKillDeadline reports that a caller-supplied teardown deadline
1668// expired before killSessionWithSnapshotDeadlineAndCondition froze every
1669// attachment gate, acquired teardown ownership, or finished joining the
1670// session's snapshot publication. The session is left live and registered
1671// exactly as it was: a purge's snapshot quarantine is rolled back, restoring
1672// eligibility and dirty scheduling for the surviving session, so the abort is
1673// retry state rather than a lifecycle mutation. It is deliberately distinct
1674// from a completed teardown: a bounded purge or shutdown must report the skip as
1675// a typed failure or an incomplete verdict instead of misreporting success.
1676// Callers that pass no deadline never observe it.
1677var errSessionKillDeadline = errors.New("session teardown deadline exceeded before ownership")
1678
1679// errSessionKillParticipantsChanged reports that a concurrent detach or token
1680// resume changed the participant set between the pre-freeze snapshot and a
1681// post-freeze revalidation, so the kill aborted before freezing every gate or
1682// before publishing terminal ownership. The session was left untouched. It is
1683// deliberately distinct from a completed teardown so a bounded purge can retry
1684// against a fresh snapshot instead of counting the abort as a purged unit, and
1685// so a caller that does not retry still sees that nothing was removed.
1686var errSessionKillParticipantsChanged = errors.New("session kill participants changed after snapshot")
1687
1688// sessionKillRetryMessage is the user-facing, retryable outcome for a teardown
1689// a concurrent detach or token resume invalidated before it removed anything.
1690// The sentinel above is internal bookkeeping, so every user-facing path (a
1691// direct kill or a close-tab command) maps it to this message instead of
1692// leaking the sentinel text or a permanent-failure verdict.
1693const sessionKillRetryMessage = "session changed during the operation; try again"
1694
1695// teardownDeadlineElapsed reports whether the supplied deadline has already
1696// expired. A nil deadline never counts as elapsed.
1697func teardownDeadlineElapsed(deadline *snapshotShutdownDeadline) bool {
1698	if deadline == nil {
1699		return false
1700	}
1701	select {
1702	case <-deadline.Done():
1703		return true
1704	default:
1705		return false
1706	}
1707}
1708
1709// killSessionWithSnapshotDeadline shares Serve's shutdown budget with its
1710// terminal checkpoint and coordinator join. A timed-out repository call keeps
1711// only immutable capture state; it never observes closed worker channels or
1712// session-owned cache that teardown has released.
1713//
1714// A deadline that wins before this call freezes every attachment gate or
1715// acquires teardown ownership returns errSessionKillDeadline, so a bounded
1716// caller can distinguish a skip from a completed teardown. An already-removed
1717// target is a no-op; a target that is still registered while its participant
1718// set, transport incarnation, or source capability changed after the snapshot
1719// returns errSessionKillParticipantsChanged, so an aborted teardown is never
1720// mistaken for a completed one. Ordinary callers that pass nil preserve their
1721// previous behavior exactly.
1722func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1723	return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, deadline, admission, sessionKillCondition{})
1724}
1725
1726// sessionKillCondition narrows a kill to a target state that is checked under
1727// the architecture locks at snapshot and again at publication.
1728type sessionKillCondition struct {
1729	// empty requires no attachment and no parked or parking resume credential.
1730	empty bool
1731	// ephemeral requires the target to still be an ephemeral session.
1732	ephemeral bool
1733}
1734
1735// killSessionIfEmpty tears down sess only while its exact registry entry has no
1736// attachments and no parked or parking resume credential. Membership is
1737// captured under the normal architecture locks and fenced again at publication, so an attachment winning either side survives.
1738func (d *Daemon) killSessionIfEmpty(sess *session, reason uint8, purge bool) error {
1739	return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, nil, nil, sessionKillCondition{empty: true})
1740}
1741
1742// killEphemeralSessionIfEmpty is killSessionIfEmpty that also refuses a target
1743// renamed into a named session after the caller's check.
1744func (d *Daemon) killEphemeralSessionIfEmpty(sess *session, reason uint8) error {
1745	return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, false, nil, nil, sessionKillCondition{empty: true, ephemeral: true})
1746}
1747
1748func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission, condition sessionKillCondition) error {
1749	if sess == nil {
1750		return nil
1751	}
1752	// Discover every attachment whose membership can be invalidated before freezing
1753	// any one of them. The common gate helper deduplicates and freezes the whole
1754	// immutable identity set in the same global order used by transitions.
1755	participants, ok := d.snapshotSessionKillParticipants(sess, admission, condition)
1756	if !ok {
1757		return nil
1758	}
1759	if admission != nil {
1760		d.endActionAttachmentEffect(admission.effect, admission.action)
1761	}
1762	action := ""
1763	if admission != nil {
1764		action = admission.action
1765	}
1766	if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1767		d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
1768	}
1769	var drainDone func() <-chan struct{}
1770	var drainDeadline *attachmentEffectDrainDeadline
1771	if deadline != nil {
1772		drainDone = deadline.Done
1773	} else {
1774		drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
1775		drainDone = drainDeadline.Done
1776		defer drainDeadline.stop()
1777	}
1778	frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1779		if d.afterAttachmentEffectGateFrozen != nil {
1780			d.afterAttachmentEffectGateFrozen(action, ac)
1781		}
1782	}}, participants.effectGates...)
1783	defer func() { frozen.unfreeze() }()
1784	if !frozen.acquired {
1785		// The acquisition helper has already rolled back exactly the gates it
1786		// owned. Do not enter architecture locks or publish terminal ownership
1787		// changes unless the complete canonical participant set is frozen.
1788		if teardownDeadlineElapsed(deadline) {
1789			return errSessionKillDeadline
1790		}
1791		return nil
1792	}
1793	if !d.sessionKillParticipantsCurrent(participants) {
1794		if d.sessionKillTargetRemoved(sess) {
1795			// The exact target already left the registry, so there is nothing left
1796			// to tear down: an already-removed session is a no-op, not a
1797			// participant change.
1798			return nil
1799		}
1800		return errSessionKillParticipantsChanged
1801	}
1802
1803	// Pane EOF, explicit close, and daemon shutdown can all converge on the same
1804	// session. Only one owner may create the terminal mutation and checkpoint;
1805	// later callers observe the completed registry transition instead of
1806	// manufacturing a newer generation that the owner will quarantine. A
1807	// shutdown duplicate abandons this wait when the shared budget expires.
1808	var acquired bool
1809	reason, purge, acquired = sess.beginTeardownRequest(deadline, reason, purge)
1810	if !acquired {
1811		// beginTeardownRequest only fails to acquire when the deadline expired
1812		// (without one it waits until ownership is available), so the session is
1813		// still untouched and the skip must stay observable.
1814		if deadline != nil {
1815			return errSessionKillDeadline
1816		}
1817		return nil
1818	}
1819	defer sess.finishTeardown()
1820	// Capture eligibility before any coordinator quarantine flips it. Every
1821	// post-quarantine abort that leaves this session registered restores exactly
1822	// this value, so a surviving session resumes checkpoint scheduling instead of
1823	// staying permanently frozen. The rollback is gated on the same teardown
1824	// ownership as finishTeardown and on the exact registry entry, so a completed
1825	// removal never resurrects scheduling.
1826	wasEligible := sess.snapEligible.Load()
1827	quarantined := false
1828	defer func() {
1829		if quarantined && d.sessionRegistered(sess) {
1830			rollbackSnapshotQuarantine(sess, wasEligible)
1831		}
1832	}()
1833
1834	d.mu.Lock()
1835	current := d.sessions[sess.id]
1836	d.mu.Unlock()
1837	if current != sess {
1838		if purge {
1839			sess.mu.Lock()
1840			name := sess.name
1841			sess.mu.Unlock()
1842			return d.retryStoppedPurge(name)
1843		}
1844		return nil
1845	}
1846
1847	ringing := sess.anyAttention()
1848	sess.mu.Lock()
1849	isEphemeral := sess.ephemeral
1850	name := sess.name
1851	incarnation := sess.incarnation
1852	sess.mu.Unlock()
1853	// Join snapshot publication before changing live identity or deleting the
1854	// incarnation directory, so an older publication cannot recreate it. A shared
1855	// caller deadline bounds the join: a repository call that ignores cancellation
1856	// must never hold purge admission open, so an expired budget leaves the unit
1857	// live and reports the typed skip instead.
1858	if purge && !isEphemeral {
1859		if err := d.beginSnapshotPurge(name, incarnation); err != nil {
1860			return err
1861		}
1862		if d.snapshotRepository != nil {
1863			quarantineDone := quarantineSnapshotCoordinator(sess)
1864			quarantined = true
1865			if deadline != nil {
1866				select {
1867				case <-quarantineDone:
1868				case <-deadline.Done():
1869					return errSessionKillDeadline
1870				}
1871			} else {
1872				<-quarantineDone
1873			}
1874		}
1875	}
1876	if !purge && !isEphemeral && d.persistEnabled {
1877		d.refreshSessionCwd(sess)
1878	}
1879	var terminalSnapshotErr error
1880	retainSnapshotRetry := false
1881	if d.snapsEnabled && !isEphemeral && !purge {
1882		markSnapshotDirty(sess)
1883		sess.snapshotMu.Lock()
1884		terminalGeneration := sess.snapshotGeneration
1885		sess.snapshotMu.Unlock()
1886		if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) {
1887			sess.mu.Lock()
1888			name := sess.name
1889			sess.mu.Unlock()
1890			terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
1891			if reason != protocol.ReasonServerShutdown {
1892				return terminalSnapshotErr
1893			}
1894			// Preserve any already-queued routine capture until the blocked
1895			// worker can safely discard it. This retains the dirty generation
1896			// and forced retry intent without adding another queue entry.
1897			retainSnapshotRetry = true
1898			d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
1899		}
1900	}
1901	if !isEphemeral {
1902		// Joining first preserves byte ownership for a publication that is still
1903		// encoding while this session is being torn down. Once Serve's shared
1904		// budget expires, the worker is cancelled by its one final join instead;
1905		// keep the cache reachable because an uncooperative writer may still be
1906		// encoding immutable state.
1907		var quarantineDone <-chan struct{}
1908		if retainSnapshotRetry {
1909			quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
1910		} else {
1911			quarantineDone = quarantineSnapshotCoordinator(sess)
1912		}
1913		quarantined = true
1914		joined := true
1915		if deadline != nil {
1916			select {
1917			case <-quarantineDone:
1918			case <-deadline.Done():
1919				joined = false
1920			}
1921		} else {
1922			<-quarantineDone
1923		}
1924		if joined {
1925			sess.snapshotMu.Lock()
1926			sess.snapshotChunkCache = nil
1927			sess.snapshotMu.Unlock()
1928		}
1929		if d.afterSessionKillQuarantine != nil {
1930			d.afterSessionKillQuarantine(sess)
1931		}
1932	}
1933	// Revalidate the exact source capability, target lifecycle, and attachment
1934	// memberships under canonical architecture locks immediately before
1935	// publication. A changed preflight aborts without mutating either lifecycle.
1936	d.mu.Lock()
1937	if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
1938		d.mu.Unlock()
1939		return nil
1940	}
1941	d.notices.routingMu.Lock()
1942	source := participants.source
1943	if source == nil {
1944		source = sess
1945	}
1946	sourceCoordinator := source.renderCoordinator()
1947	targetCoordinator := sess.renderCoordinator()
1948	unlockSessions := lockAttachmentSessions(source, sess)
1949	unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator)
1950	if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) {
1951		unlockCoordinators()
1952		unlockSessions()
1953		d.notices.routingMu.Unlock()
1954		d.mu.Unlock()
1955		return errSessionKillParticipantsChanged
1956	}
1957
1958	// d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1959	// here. Registry removal and every attachment invalidation are one publication
1960	// while the complete immutable gate set remains frozen.
1961	if !d.unregisterSessionLocked(sess) {
1962		unlockCoordinators()
1963		unlockSessions()
1964		d.notices.routingMu.Unlock()
1965		d.mu.Unlock()
1966		return errAttachmentTransition
1967	}
1968	d.clearBarScriptsForSession(sess.id)
1969	d.purgeParkingForSessionLocked(sess)
1970	parkedRetirements := d.purgeParkedForSessionLocked(sess)
1971	attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
1972	capture := func(attached *attachedClient) {
1973		if attached == nil {
1974			return
1975		}
1976		attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1977		attached.setSession(nil)
1978		attached.invalidateFrozenAttachmentCapability()
1979	}
1980	for attached := range sess.attachments {
1981		capture(attached)
1982	}
1983	clear(sess.attachments)
1984	d.purgeSuspendedForSessionLocked(sess)
1985	stoppedName := sess.name
1986	stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1987	ephemeral := sess.ephemeral
1988	unlockCoordinators()
1989	unlockSessions()
1990	d.notices.routingMu.Unlock()
1991	if !ephemeral {
1992		stopped := inactiveSessionFromRecord(stoppedRecord, protocol.SessionDown, nil)
1993		stopped.purging = purge
1994		d.inactive[stoppedName] = stopped
1995	}
1996	d.mu.Unlock()
1997	d.finishParkedAttachmentRetirements(parkedRetirements)
1998	frozen.unfreeze()
1999	frozen = attachmentTransitionGuard{}
2000	d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
2001
2002	var purgeErr error
2003	if rc := sess.renderCoordinator(); rc != nil {
2004		// Terminal teardown has two phases: this session owner first prevents any
2005		// new worker registration and detaches all tokens, then stops and waits
2006		// outside coordinator callbacks and session locks.
2007		rc.beginSessionTeardown().finish()
2008		rc.waitForTimerWorkers()
2009	}
2010	for _, attachment := range attachments {
2011		attachment.ac.clearCaptureFrames()
2012	}
2013
2014	// Prevent queued launches from entering Open, cancel the parent context, and
2015	// close PTYs. Serve may already have performed this phase while repository
2016	// work owned by this teardown was blocked.
2017	sess.stopInMemoryLifecycle()
2018	sess.mu.Lock()
2019	tabs := append([]*tab(nil), sess.tabs...)
2020	clipFiles := sess.clipFiles
2021	sess.clipFiles = nil
2022	sess.mu.Unlock()
2023	for _, tb := range tabs {
2024		for _, attachment := range attachments {
2025			d.teardownFloating(tb, attachment.ac)
2026		}
2027	}
2028	for _, path := range clipFiles {
2029		if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
2030			d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
2031		}
2032	}
2033	// The coordinator and all panes are now stopped, so no producer can publish
2034	// another generation after this destructive source deletion. Keep the hidden
2035	// stopped record when deletion fails or is still pending so a repeated live
2036	// kill can retry. The purge-owned exact delete is bounded: a repository
2037	// delete that ignores cancellation is detached behind a name/incarnation/
2038	// createdAt fence rather than holding purgeAllMu, admission, or control
2039	// responses.
2040	if !ephemeral && purge {
2041		if err := d.finishLiveDurablePurge(deadline, stoppedRecord); err != nil {
2042			purgeErr = errors.Join(purgeErr, err)
2043			d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
2044		}
2045	}
2046	if purge && purgeErr == nil && isEphemeral {
2047		// An ephemeral purge owns no durable record, but the remaining sessions'
2048		// route histories may still reference it.
2049		d.reconcileAllRouteHistories()
2050	}
2051	if ringing {
2052		d.repaintAllAttachedClients()
2053	}
2054
2055	for _, attachment := range attachments {
2056		// The transport is still available after membership publication. Retire
2057		// attachment-owned terminal output before sending Detached.
2058		d.cleanupAttachmentOutput(attachment.ac)
2059		d.notifyDetachedSnapshotAsync(attachment, reason)
2060	}
2061	return errors.Join(purgeErr, terminalSnapshotErr)
2062}
2063
2064// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
2065// d.mu.
2066func (d *Daemon) allocEphemeralNameLocked() string {
2067	used := make(map[string]struct{}, len(d.sessions)+len(d.inactive))
2068	for _, s := range d.sessions {
2069		if s != nil {
2070			used[s.name] = struct{}{}
2071		}
2072	}
2073	for name := range d.inactive {
2074		used[name] = struct{}{}
2075	}
2076	for i := 0; ; i++ {
2077		n := strconv.Itoa(i)
2078		if _, taken := used[n]; !taken {
2079			return n
2080		}
2081	}
2082}
2083
2084func (d *Daemon) findByNameLocked(name string) *session {
2085	for _, s := range d.sessions {
2086		if s != nil && s.name == name {
2087			return s
2088		}
2089	}
2090	return nil
2091}
2092
2093func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
2094	if d.findByNameLocked(name) != nil {
2095		return true
2096	}
2097	if _, ok := d.inactive[name]; ok {
2098		return true
2099	}
2100	_, reserved := d.creating[name]
2101	return reserved
2102}
2103
2104func (d *Daemon) cwdSampler(ctx context.Context) {
2105	t := d.clock.NewTimer(5 * time.Second)
2106	defer t.Stop()
2107	for {
2108		select {
2109		case <-ctx.Done():
2110			return
2111		case <-t.C():
2112		}
2113		if d.proc != nil {
2114			d.refreshNamedSessionCwds()
2115		}
2116		if err := d.flushCatalogue(); err != nil {
2117			d.log.Warn("flushing session catalogue failed", "err", err)
2118		}
2119		t.Reset(5 * time.Second)
2120	}
2121}
2122
2123func (d *Daemon) refreshNamedSessionCwds() {
2124	d.mu.Lock()
2125	sessions := d.sessionsSnapshotLocked()
2126	d.mu.Unlock()
2127	for _, sess := range sessions {
2128		sess.mu.Lock()
2129		ephemeral := sess.ephemeral
2130		sess.mu.Unlock()
2131		if !ephemeral {
2132			d.refreshSessionCwd(sess)
2133		}
2134	}
2135}
2136
2137func (d *Daemon) refreshSessionCwd(sess *session) {
2138	if d.proc == nil {
2139		return
2140	}
2141	tb := sess.firstTab()
2142	if tb == nil {
2143		return
2144	}
2145	tb.mu.Lock()
2146	p := tb.focusedPane()
2147	tb.mu.Unlock()
2148	if p == nil {
2149		return
2150	}
2151	cwd, err := d.proc.Cwd(p.pty.Pid())
2152	if err != nil || cwd == "" {
2153		return
2154	}
2155	d.mu.Lock()
2156	if d.sessions[sess.id] != sess {
2157		d.mu.Unlock()
2158		return
2159	}
2160	sess.mu.Lock()
2161	if sess.cwd == cwd {
2162		sess.mu.Unlock()
2163		d.mu.Unlock()
2164		return
2165	}
2166	sess.cwd = cwd
2167	if !sess.ephemeral {
2168		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
2169	}
2170	sess.mu.Unlock()
2171	d.mu.Unlock()
2172	markSnapshotDirty(sess)
2173}
2174
2175func copyEnvironment(env []string) []string {
2176	return append([]string(nil), env...)
2177}
2178
2179func environmentEntry(entry string) (name, value string, ok bool) {
2180	name, value, ok = strings.Cut(entry, "=")
2181	return name, value, ok
2182}
2183
2184const defaultShellCommand = "/bin/sh"
2185const defaultShellTitle = "sh"
2186
2187type shellLaunchSpec struct {
2188	command string
2189	args    []string
2190	title   string
2191}
2192
2193// shellLaunch resolves the command and display fallback for one normal PTY.
2194// Both values come from the same client environment snapshot (or explicit
2195// daemon override), so headless catalogues identify the shell they actually run.
2196func (d *Daemon) shellLaunch(env []string) shellLaunchSpec {
2197	command := shellFromEnvironment(env)
2198	var args []string
2199	if d.shellOverride {
2200		command = d.shell
2201		args = append([]string(nil), d.shellArgs...)
2202	}
2203	return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)}
2204}
2205
2206func shellDisplayName(command string) string {
2207	title := filepath.Base(command)
2208	if title == "." || title == string(filepath.Separator) || title == "" {
2209		return command
2210	}
2211	return title
2212}
2213
2214func shellFromEnvironment(env []string) string {
2215	shell := ""
2216	for _, entry := range env {
2217		if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
2218			shell = value
2219		}
2220	}
2221	if shell == "" {
2222		return defaultShellCommand
2223	}
2224	return shell
2225}
2226
2227// childEnvFrom preserves the supplied environment byte-for-byte and in order,
2228// except for exact reserved variable names which vev owns. TERM starts with the
2229// compatibility default; the PTY adapter selects the host-available profile.
2230func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
2231	out := make([]string, 0, len(env)+4)
2232	for _, entry := range env {
2233		key, _, ok := environmentEntry(entry)
2234		if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
2235			continue
2236		}
2237		out = append(out, entry)
2238	}
2239	out = append(out, "TERM=xterm-256color", "COLORTERM=truecolor")
2240	return append(out,
2241		"TERM_PROGRAM=vev",
2242		"VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
2243	)
2244}

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