session.go RawHistoryFolder

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

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