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

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