v0.7.0 4c16dd8

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

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