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