// Package daemon holds vev's server-side session multiplexer use case. package daemon import ( "context" "crypto/rand" "errors" "fmt" "os" "path/filepath" "slices" "strconv" "strings" "sync" "sync/atomic" "time" vt "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/layout" ) var ( errSessionNameRequired = errors.New("name required") errSessionNameInUse = errors.New("name already in use") ) type session struct { sessionCore ctx context.Context cancel context.CancelFunc // dispatchMu serializes user-initiated mutating command dispatch (palette // and vev-cmd control) for this session, so a one-shot command cannot // resolve focus against one state and mutate a later one. Lock order: // dispatchMu strictly before d.mu, sess.mu, or any tab mu. dispatchMu sync.Mutex // teardownMu guards teardown ownership and is the outermost per-session // lifecycle lock: never acquire it while holding daemon, session, snapshot, // tab, or pane locks. The owner performs teardown without holding teardownMu; // duplicate daemon-shutdown callers can therefore bound their ownership wait // with Serve's shared snapshot deadline. teardownMu sync.Mutex teardownActive bool teardownDone chan struct{} moveReservations uint teardownWaiters uint teardownChanged chan struct{} shutdownTeardownRequested bool lifecycleStopOnce sync.Once // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles, // and clipboard queue state. // restoreDone remains attached to a restored session after it is published in // the live registry, so racing attaches cannot bypass restoration completion. restoreDone chan struct{} themeMu sync.Mutex tabs []*tab clipboardQueue []clipboardForward clipboardWorkerRunning bool cwd string // env is the authoritative immutable environment snapshot for future PTY children. // It is guarded by mu and is always copied on ingress and egress. env []string // envProvisional marks an env that came from the daemon's own process // rather than a client: a snapshot restore, a daemon-owned resume of a // stopped session, or a session created or resumed from a provisional // one. The first client-owned environment then replaces it (see // adoptClientEnvironmentLocked for the display-group exception); later // ones refresh only session-bound variables. Guarded by mu. envProvisional bool snapDirty atomic.Bool snapEligible atomic.Bool // snapshotMu serializes mutation revisions and repository publication // generations with worker completion. It is intentionally independent from // mu: persistence never holds session state locks while encoding or writing. snapshotMu sync.Mutex snapshotGeneration uint64 // newest mutation revision snapshotPublishedGeneration uint64 // newest repository generation snapshotPublishedCheckpoint *domain.CheckpointRef snapshotPublishedMutationRevision uint64 snapshotNextEligibleAt time.Time // The coordinator state below is guarded by snapshotMu. A capture can be // queued globally or in flight, never more than one of each per session. // Quarantine cancels the session publication context before destructive // repository operations and publicationDone joins a started publication. snapshotPending bool snapshotPendingCaptures uint // snapshotForcedGeneration is the newest mutation revision a forced // checkpoint must publish. It survives an older routine capture so worker // completion can enqueue exactly one forced successor. snapshotForcedGeneration uint64 snapshotQueuedCapture *snapshotCapture snapshotInFlightCapture *snapshotCapture snapshotPublicationDone chan struct{} snapshotPublicationContext context.Context snapshotPublicationCancel context.CancelFunc snapshotQuarantined bool // snapshotChunkCache retains encoded sealed history only for this named // session. snapshotMu owns it so cache touches never contend with pane // state or mutate bytes retained by queued/in-flight publications. snapshotChunkCache *snapshotChunkCache snapshotChanged chan struct{} snapshotWake chan struct{} // syncGen makes synchronized-output watchdog generations unique across all // panes in this session. syncGen atomic.Uint64 // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish // transactions. It is deliberately not an architecture lock and may span // external PTY.Resize calls. layoutApplyMu sync.Mutex // clipFiles records clipboard-image-transfer temp file paths (see // clipboard.go) written for this session, removed best-effort in // killSession. clipFiles []string // floatingLaunchMu is ownership synchronization for external floating // launches. It is separate from mu so PTYFactory.Open never holds a // session, tab, or daemon architecture lock. floatingLaunchMu sync.Mutex floatingLaunchStopping bool floatingLaunches map[*floatingLaunch]struct{} // floatingSpare is the session's single pre-started floating shell; see // floating_spare.go. Guarded by floatingLaunchMu. floatingSpare *floatingSpare floatingSpareStarting bool } func (s *session) nameSnapshot() string { if s == nil { return "" } s.mu.Lock() defer s.mu.Unlock() return s.name } // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. type tab struct { mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership // layoutGeneration invalidates prepared geometry whenever live tab layout // state changes. layoutApplyMu serializes the lock-free PTY apply boundary. layoutGeneration uint64 layoutApplyMu sync.Mutex // layoutRetryMu owns the one bounded delayed retry worker for accepted // tiled-layout degradation. Its context is derived from ctx, so tab/session // teardown cancels a waiting retry before it can touch PTY state. layoutRetryMu sync.Mutex layoutRetryCancel context.CancelFunc layoutRetryRunning bool stableID string name string tree *layout.Tree panes map[layout.PaneID]*pane nextPaneID int size domain.Size ctx context.Context cancel context.CancelFunc // floating is independent from the normal layout tree and pane map. floating floatingSlot // attention fields are guarded by the owning session.mu. attention bool attentionAt time.Time attentionVisiblePaint bool } // attachedClient is a client currently attached to a session's tab. rend is // its private renderer shadow (so each client gets output minimised against // what it has actually seen). sendMu serialises the two senders — the render // coordinator and the connection handler — so the transport's single-writer func (d *Daemon) touchMRU(entry *session) { if d == nil || entry == nil || entry.core() == nil { return } core := entry.core() seq := d.mruSeq.Add(1) for { old := core.mruAt.Load() if old >= seq { return } if core.mruAt.CompareAndSwap(old, seq) { break } } entry.mu.Lock() if !entry.ephemeral { d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate()) } entry.mu.Unlock() } func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) { return d.createSessionLockedWithMode(name, ephemeral, cwd, geometry, env, restoredTabNames...) } // resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks // lifecycle and resumability after catalogue I/O, which intentionally runs // without d.mu. func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, geometry domain.Geometry, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) { return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, geometry, env, &expectedInactive, nil, restoredTabNames...) } func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) { return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, geometry, env, nil, nil, restoredTabNames...) } type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool func (s inactiveSession) matchesRecord(record domain.CatalogueRecord) bool { canonical := inactiveSessionFromRecord(record, s.state, s.restoreDone) return s.sameLifecycle(canonical) && s.cwd == canonical.cwd && slices.Equal(s.tabNames, canonical.tabNames) && slices.Equal(s.tabRecords, canonical.tabRecords) && s.record.DegradedReason == canonical.record.DegradedReason } func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *inactiveSession, authoritative domain.CatalogueRecord, authoritativeExists bool, validate inactiveResumeValidator) bool { if expected == nil { return true } if !exists || !current.sameLifecycle(*expected) || !current.canResume() { return false } if authoritativeExists && !current.matchesRecord(authoritative) { return false } return validate == nil || validate(current, authoritative, authoritativeExists) } func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) { env = copyEnvironment(env) geometry = geometry.NormalizePixels() sz := geometry.Size if _, reserved := d.creating[name]; reserved { return nil, errSessionNameInUse } d.creating[name] = struct{}{} defer delete(d.creating, name) stopped, resuming := d.inactive[name] if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, domain.CatalogueRecord{}, false, nil) { return nil, errAttachmentTransition } var authoritative domain.CatalogueRecord var authoritativeExists bool if !ephemeral && resuming && d.persistEnabled { d.mu.Unlock() var err error authoritative, authoritativeExists, err = d.catalogueRecord(name) d.mu.Lock() if err != nil { return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't read session catalogue", err) } if d.closing { return nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"} } stopped, resuming = d.inactive[name] if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, authoritative, authoritativeExists, validateInactive) { return nil, errAttachmentTransition } } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) { return nil, errAttachmentTransition } var createdAt int64 var incarnation domain.IncarnationID if !ephemeral { if resuming { // A stopped session is the same lifecycle when resumed; it must retain // the persisted identity rather than receive a fresh timestamp. createdAt = stopped.createdAt incarnation = stopped.incarnation if authoritativeExists { createdAt = authoritative.CreatedAt incarnation = authoritative.IncarnationID } if createdAt > d.lastAllocatedCreatedAt { d.lastAllocatedCreatedAt = createdAt } } else { var err error createdAt, err = d.allocateLifecycleCreatedAtLocked() if err != nil { return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) } } } if incarnation == (domain.IncarnationID{}) { var err error incarnation, err = domain.NewSessionLifecycleID(rand.Reader) if err != nil { return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate lifecycle identity: %w", err)) } } if d.ptys == nil { return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) } tbSize := contentSize(sz) initialPaneGeometry := scaleSharedPTYGeometry(geometry, tbSize) var names []string var tabRecords []domain.CatalogueTabRecord if resuming && len(stopped.tabRecords) != 0 { tabRecords = append([]domain.CatalogueTabRecord(nil), stopped.tabRecords...) names = make([]string, len(tabRecords)) for i, record := range tabRecords { names[i] = record.Name } } else if len(restoredTabNames) > 0 { names = append([]string(nil), restoredTabNames[0]...) } tabs := make([]*tab, 0, max(1, len(names))) for i := range max(1, len(names)) { tabStableID, paneStableID, err := d.newTabPaneStableIDs() if i < len(tabRecords) && tabRecords[i].StableID != "" { tabStableID = string(tabRecords[i].StableID) } if err != nil { closeTabs(tabs) return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) } launch := d.shellLaunch(env) lifetime := d.newPaneProcessLifetime(d.serveCtx) pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialPaneGeometry) if err != nil { lifetime.abort() if pty != nil { _ = pty.Close() } closeTabs(tabs) d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err) } appliedPaneGeometry := initialPaneGeometry tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) if pane := tb.focusedPane(); pane != nil { pane.geometry = appliedPaneGeometry setScreenGeometry(pane.screen, appliedPaneGeometry) } if !lifetime.publish(tb.focusedPane()) { lifetime.abort() _ = pty.Close() closeTabs(tabs) return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err()) } if i < len(names) { tb.name = names[i] } tabs = append(tabs, tb) } id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) d.nextID++ sctx, cancel := context.WithCancel(d.serveCtx) for _, tb := range tabs { tb.ctx, tb.cancel = context.WithCancel(sctx) } lastUsedSeq := uint64(0) if !ephemeral && resuming { lastUsedSeq = stopped.lastUsedSeq } if lastUsedSeq == 0 { lastUsedSeq = d.mruSeq.Add(1) } sess := &session{ sessionCore: sessionCore{ id: id, name: name, ephemeral: ephemeral, createdAt: createdAt, incarnation: incarnation, }, ctx: sctx, cancel: cancel, tabs: tabs, cwd: cwd, env: env, snapshotWake: d.snapshotWake, } if !ephemeral && name != "" { sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) } sess.mruAt.Store(lastUsedSeq) sess.snapEligible.Store(!ephemeral && name != "") // These tabs are still private construction state. Publish every tiled // owner before the session enters d.sessions or any PTY reader starts. for _, tb := range tabs { publishTiledPaneOwners(sess, tb) } var rollbackDurable func() error if !ephemeral && d.persistEnabled { record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, TabRecords: tabRecordsFromTabs(tabs)} if d.recovery == nil { closeTabs(tabs) cancel() return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured")) } var err error if resuming && authoritativeExists { err = d.catalogue.UpdateMetadata(record.MetadataUpdate()) if err == nil { rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) } err = d.catalogue.Sync() } } else { record, err = d.recovery.Create(d.serveCtx, record) if err == nil { rollbackDurable = func() error { current, ok, recordErr := d.catalogue.Record(name) if recordErr != nil { return recordErr } if !ok { return nil } if current.IncarnationID != record.IncarnationID { return errors.New("daemon: created session authority changed before rollback") } return d.catalogue.Delete(name) } } } if err != nil { if rollbackDurable != nil { err = errors.Join(err, rollbackDurable()) } closeTabs(tabs) cancel() return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) } sess.incarnation = record.IncarnationID if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation { checkpoint := *authoritative.Committed sess.snapshotPublishedGeneration = checkpoint.Generation sess.snapshotPublishedCheckpoint = &checkpoint } } if !d.registerSessionLocked(sess) { err := errors.New("daemon: session registry rejected local session") if rollbackDurable != nil { err = errors.Join(err, rollbackDurable()) } closeTabs(tabs) cancel() return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) } if !ephemeral { // Keep stopped lifecycle authority intact until runtime publication and // its durable side effects have both succeeded. delete(d.inactive, name) } d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) for i, tb := range tabs { d.log.Info("tab created", "session", name, "tab", i) d.startTabGoroutines(sess, tb) } return sess, nil } func closeTabs(tabs []*tab) { for _, tb := range tabs { tb.closeAllPanes() } } func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { if name == "" { return errSessionNameRequired } if err := domain.ValidateSessionName(name); err != nil { return err } geometry := ac.geometrySnapshot() d.mu.Lock() if d.closing { d.mu.Unlock() return errors.New("daemon is shutting down") } if d.purgeAdmissionClosedLocked() { d.mu.Unlock() return errPurgeAdmissionClosed } if d.nameLiveOrStoppedLocked(name) { d.mu.Unlock() return errSessionNameInUse } from.mu.Lock() cwd := from.cwd env, provisional := from.environmentSeedLocked() _, attached := from.attachments[ac] from.mu.Unlock() if !attached { d.mu.Unlock() return errors.New("client detached") } newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env) if err == nil && provisional { markEnvironmentProvisional(newSess) } d.mu.Unlock() if err != nil { return err } // The newly created target is registered before the attachment transition, but // remains headless. Freeze/drain must occur with no daemon/routing/session // lock held; the transition revalidates both lifecycles before publication. transition, err := d.transitionAttachment(attachmentTransitionRequest{ source: from, target: newSess, next: ac, expectedTransport: ac.transportSnapshot(), ready: true, }) if err != nil { _ = d.killSession(newSess, protocol.ReasonSessionKilled, true) return err } d.touchMRU(newSess) d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) d.deferAttachmentTransitionCleanups(transition) d.firstPaintForTransition(transition.published) return nil } // createSessionAndSwitchForAttachment creates no target until the initiating role // ticket has ended and the exact source capability is frozen and revalidated. func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, name string) error { if name == "" { return errSessionNameRequired } if err := domain.ValidateSessionName(name); err != nil { return err } if effect == nil || effect.sess == nil || effect.ac == nil { return errAttachmentTransition } effect.bindActionEnd(d, "create-session") effect.End() capability := effect.capability() var created *session transition, err := d.transitionAttachment(attachmentTransitionRequest{ source: capability.sess, next: capability.ac, expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session", ready: true, createTargetLocked: func() (*session, error) { source := capability.sess if source == nil { return nil, errAttachmentTransition } if d.closing { return nil, errors.New("daemon is shutting down") } if d.purgeAdmissionClosedLocked() { return nil, errPurgeAdmissionClosed } if d.nameLiveOrStoppedLocked(name) { return nil, errSessionNameInUse } source.mu.Lock() cwd := source.cwd env, provisional := source.environmentSeedLocked() source.mu.Unlock() var createErr error created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env) if createErr == nil && provisional { markEnvironmentProvisional(created) } return created, createErr }, }) if err != nil { if created != nil { _ = d.killSession(created, protocol.ReasonSessionKilled, true) } return err } d.touchMRU(created) d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable) d.deferAttachmentTransitionCleanups(transition) d.firstPaintForTransition(transition.published) return nil } func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error { geometry := ac.geometrySnapshot() d.mu.Lock() if d.closing { d.mu.Unlock() return errors.New("daemon is shutting down") } if d.purgeAdmissionClosedLocked() { d.mu.Unlock() return errPurgeAdmissionClosed } from.mu.Lock() cwd := from.cwd env, provisional := from.environmentSeedLocked() _, attached := from.attachments[ac] from.mu.Unlock() if !attached { d.mu.Unlock() return errors.New("client detached") } name := d.allocEphemeralNameLocked() newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env) if err == nil && provisional { markEnvironmentProvisional(newSess) } d.mu.Unlock() if err != nil { return err } transition, err := d.transitionAttachment(attachmentTransitionRequest{ source: from, target: newSess, next: ac, expectedTransport: ac.transportSnapshot(), ready: true, }) if err != nil { _ = d.killSession(newSess, protocol.ReasonSessionKilled, true) return err } d.touchMRU(newSess) d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) d.deferAttachmentTransitionCleanups(transition) d.firstPaintForTransition(transition.published) return nil } func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachmentEffect) error { if effect == nil || effect.sess == nil || effect.ac == nil { return errAttachmentTransition } effect.bindActionEnd(d, "create-ephemeral-session") effect.End() capability := effect.capability() var created *session transition, err := d.transitionAttachment(attachmentTransitionRequest{ source: capability.sess, next: capability.ac, expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session", ready: true, createTargetLocked: func() (*session, error) { if capability.sess == nil || d.closing { return nil, errAttachmentTransition } if d.purgeAdmissionClosedLocked() { return nil, errPurgeAdmissionClosed } source := capability.sess source.mu.Lock() cwd := source.cwd env, provisional := source.environmentSeedLocked() source.mu.Unlock() name := d.allocEphemeralNameLocked() var createErr error created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env) if createErr == nil && provisional { markEnvironmentProvisional(created) } return created, createErr }, }) if err != nil { if created != nil { _ = d.killSession(created, protocol.ReasonSessionKilled, true) } return err } d.touchMRU(created) d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable) d.deferAttachmentTransitionCleanups(transition) d.firstPaintForTransition(transition.published) return nil } func (d *Daemon) createTab(sess *session, sz domain.Size) error { return d.createTabForAttachment(sess, nil, sz) } func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ domain.Size) error { sess.mu.Lock() name := sess.name cwd := sess.cwd env := copyEnvironment(sess.env) attachments := sess.snapshotAttachmentsLocked() sess.mu.Unlock() tbSize := contentSize(sess.fullViewportSize()) claimGeometry := domain.Geometry{Size: tbSize} if source, ok := sess.geometry.sourceSnapshot(sess); ok { claimGeometry = scaleSharedPTYGeometry(source.geometry, tbSize) } else if ac != nil { claimGeometry = scaleSharedPTYGeometry(ac.geometrySnapshot(), tbSize) } tabStableID, paneStableID, err := d.newTabPaneStableIDs() if err != nil { return err } launch := d.shellLaunch(env) lifetime := d.newPaneProcessLifetime(sess.ctx) pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, claimGeometry) if err != nil { lifetime.abort() if pty != nil { _ = pty.Close() } d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) } tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig()) if pane := tb.focusedPane(); pane != nil { pane.geometry = claimGeometry setScreenGeometry(pane.screen, claimGeometry) } themeClient := ac if themeClient == nil && len(attachments) != 0 { themeClient = attachments[0] } if themeClient != nil { t := d.effectiveTheme(themeClient.getClientTheme()) tb.mu.Lock() p := tb.focusedPane() tb.mu.Unlock() if p != nil { p.mu.Lock() applyPaneThemeLocked(p, t, false) p.mu.Unlock() } } d.mu.Lock() if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { d.mu.Unlock() lifetime.abort() _ = pty.Close() return errors.New("daemon: session closed") } tb.ctx, tb.cancel = context.WithCancel(sess.ctx) sess.mu.Lock() p := tb.focusedPane() if !lifetime.publish(p) { sess.mu.Unlock() d.mu.Unlock() lifetime.abort() _ = pty.Close() return errors.New("daemon: session closed") } // Publish ownership before appending the tab to the visible session. publishPaneOwner(p, sess, tb, 0) sess.tabs = append(sess.tabs, tb) tabIndex := len(sess.tabs) - 1 if !sess.ephemeral { d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) } if ac != nil { sess.activateAttachmentViewLocked(ac, tabIndex) } sess.mu.Unlock() d.mu.Unlock() d.log.Info("tab created", "session", name, "tab", tabIndex) d.startTabGoroutines(sess, tb) d.activateTab(sess, tb) markSnapshotDirty(sess) return nil } func newTab(pty ports.PTY, sz domain.Size) *tab { return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz) } func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig())) } func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab { id := layout.PaneID("pane-1") p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig) return &tab{ stableID: tabStableID, tree: layout.NewTree(id), panes: map[layout.PaneID]*pane{id: p}, nextPaneID: 2, size: sz, } } func (tb *tab) bumpLayoutGenerationLocked() { tb.layoutGeneration++ } func (tb *tab) focusedPane() *pane { if tb == nil || tb.tree == nil || tb.panes == nil { return nil } return tb.panes[tb.tree.Focus] } func (tb *tab) panesSnapshot() []*pane { if tb == nil { return nil } out := make([]*pane, 0, len(tb.panes)) for _, p := range tb.panes { out = append(out, p) } return out } func (tb *tab) closeAllPanes() { tb.mu.Lock() panes := tb.panesSnapshot() // Invalidate before cancellation so a concurrently unblocked floating // reader cannot reap a subsequently reused slot. floating := tb.takeFloatingLocked() tb.mu.Unlock() if floating != nil { panes = append(panes, floating) } for _, p := range panes { closePaneProcess(p) } } // tabChromeRows is the vertical chrome (tab bar + status bar) between a // client viewport and a tab's content area. const tabChromeRows = 2 func tabSize(clientSize domain.Size) domain.Size { return contentSize(clientSize) } // contentSize maps the negotiated viewport to PTY geometry. func contentSize(clientSize domain.Size) domain.Size { if !clientSize.Valid() { clientSize = defaultSize } rows := max(clientSize.Rows-tabChromeRows, 1) return domain.Size{Cols: clientSize.Cols, Rows: rows} } // fullViewportSize derives a full client-equivalent viewport from the active // tab's retained content size, for actions that need a viewport while no // client is attached. It falls back to defaultSize when no valid size exists. func (s *session) fullViewportSize() domain.Size { s.mu.Lock() var tb *tab if len(s.tabs) != 0 { tb = s.tabs[0] } s.mu.Unlock() var content domain.Size if tb != nil { tb.mu.Lock() content = tb.size tb.mu.Unlock() } if !content.Valid() { return defaultSize } return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows} } func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { tb.mu.Lock() panes := tb.panesSnapshot() tb.mu.Unlock() for _, p := range panes { d.startPaneGoroutines(sess, tb, p) } } func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { if p == nil { return } owner := p.ownerSnapshot() if owner == nil { // Teardown may win after publication but before reader startup. In that // case the process is already closed and no ownerless reader may start. return } if owner.session != sess || owner.tab != tb { return } d.applyHistoryLimitsToPane(p) sess.mu.Lock() name := sess.name sess.mu.Unlock() d.log.Info("pane created", "session", name, "pane", p.id) // Scheduler ownership was removed; this launch creates exactly one reader. d.sessWg.Add(1) go d.readPanePTY(p) } // detachIfCurrent publishes terminal attachment invalidation through the attachment // gate. Freeze/drain happens before routing or session locks are acquired. func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool { return d.detachIfCurrentTransport(sess, ac, transportSnapshot{}) } // detachIfCurrentTransport is detachIfCurrent with an optional exact-link // fence. When expected.transport is non-nil, a rebound incarnation of the same // attachment object cannot be detached by a stale connection loop. func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool { if sess == nil || ac == nil { return false } frozen := freezeAttachmentEffectGates(ac) defer frozen.unfreeze() if d.afterDetachAttachmentEffectsFrozen != nil { d.afterDetachAttachmentEffectsFrozen() } d.notices.routingMu.Lock() sess.mu.Lock() current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { current = false } if current { current = detachFrozenAttachmentLocked(sess, ac) } sess.mu.Unlock() d.notices.routingMu.Unlock() if current { // Terminal detach ends any daemon-owned suspension expiry. The clear runs // outside routingMu so it never inverts the daemon -> routing lock order. d.clearSuspendedExpiry(ac) } return current } // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport // whose admitted send failed. A queued stale error cannot detach a later // attachment publication or a rebound transport on the same attachment object. func (d *Daemon) detachIfAttachmentCurrent(token attachmentCapability) bool { return d.detachIfAttachmentCurrentUntil(token, nil) } func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done func() <-chan struct{}) bool { if token.sess == nil || token.ac == nil || token.transport.transport == nil { return false } // Attachments without the session's optional primary render lease still // own an independent connection lifecycle and detach through the same exact // transport/generation fence. if token.lease == nil { return d.detachIfCurrentTransport(token.sess, token.ac, token.transport) } frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac) defer frozen.unfreeze() if !frozen.acquired || !frozen.drained { return false } if d.afterDetachAttachmentEffectsFrozen != nil { d.afterDetachAttachmentEffectsFrozen() } d.mu.Lock() core := token.sess.core() if core == nil || d.sessions[core.id] != token.sess { d.mu.Unlock() return false } d.notices.routingMu.Lock() core.mu.Lock() coordinator := core.coordinator.Load() if coordinator != nil { coordinator.mu.Lock() } current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator) if current { current = detachFrozenAttachmentLocked(token.sess, token.ac) } if coordinator != nil { coordinator.mu.Unlock() } core.mu.Unlock() d.notices.routingMu.Unlock() d.mu.Unlock() if current { // The suspension expiry is daemon-scoped; clear it only after every other // architecture lock is released. d.clearSuspendedExpiry(token.ac) } return current } func (d *Daemon) renameSession(sess *session, name string) error { if name == "" { return errSessionNameRequired } if err := domain.ValidateSessionName(name); err != nil { return err } // Hold daemon then session state across the durable identity write. The // incarnation-keyed snapshot namespace is never relocated. d.mu.Lock() if taken := d.findByNameLocked(name); taken != nil && taken != sess { d.mu.Unlock() return errSessionNameInUse } sess.mu.Lock() if stopped, ok := d.inactive[name]; ok && stopped.name != sess.name { sess.mu.Unlock() d.mu.Unlock() return errSessionNameInUse } oldName := sess.name wasEphemeral := sess.ephemeral createdAt := sess.createdAt priorIncarnation := sess.incarnation if wasEphemeral { var err error createdAt, err = d.allocateLifecycleCreatedAtLocked() if err != nil { sess.mu.Unlock() d.mu.Unlock() return err } if sess.incarnation == (domain.IncarnationID{}) { sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader) if err != nil { sess.mu.Unlock() d.mu.Unlock() return fmt.Errorf("generate durable identity: %w", err) } } } lastUsedSeq := sess.mruAt.Load() record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))) record.Name = name record.CreatedAt = createdAt record.LastUsedSeq = lastUsedSeq rollback := func(err error) error { sess.incarnation = priorIncarnation sess.mu.Unlock() d.mu.Unlock() return err } // Durable named renames are one atomic catalogue batch. The incarnation-keyed // snapshot namespace is intentionally untouched. if (wasEphemeral || oldName != name) && d.persistEnabled { if d.recovery == nil { return rollback(errors.New("durable session authority is not configured")) } { var committed domain.CatalogueRecord var err error if wasEphemeral { committed, err = d.recovery.Create(d.serveCtx, record) } else { committed, err = d.recovery.Rename(d.serveCtx, oldName, name) } if err != nil { return rollback(err) } record = committed } } delete(d.inactive, oldName) delete(d.inactive, name) sess.name = name sess.createdAt = createdAt sess.incarnation = record.IncarnationID sess.ephemeral = false sess.snapEligible.Store(name != "") sess.mu.Unlock() d.mu.Unlock() if wasEphemeral { sess.snapshotMu.Lock() sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit) sess.snapshotMu.Unlock() } markSnapshotDirty(sess) d.publishRenamedSessionRouteIdentity(sess) return nil } // publishRenamedSessionRouteIdentity updates each ready client's exact route // target after a session rename. A renamed session retains its lifecycle but // its old name is no longer attachable, so clients must replace—not retain—the // prior route record. func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { for _, ac := range sess.snapshotAttachments() { if ac.deferRouteIdentityUntilSnapshot() { continue } token := sess.captureAttachmentCapability(ac, ac.transport()) if token.ac == nil { continue } effect, admitted := ac.beginAttachmentEffect(token) if !admitted { continue } err := d.sendCommittedRouteIdentityForAttachment(effect) capability := effect.capability() effect.End() if err != nil { d.detachOnAttachmentSendError(capability, capability.transport.transport) } } } func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { if tb == nil { return errors.New("tab required") } d.mu.Lock() if d.sessions[sess.id] != sess { d.mu.Unlock() return errors.New("daemon: session closed") } sess.mu.Lock() if !slices.Contains(sess.tabs, tb) { sess.mu.Unlock() d.mu.Unlock() return errors.New("tab not found") } tb.name = name if !sess.ephemeral { d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) } sess.mu.Unlock() d.mu.Unlock() return nil } func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord { if len(tabs) == 0 { return nil } records := make([]domain.CatalogueTabRecord, len(tabs)) for i, tb := range tabs { if tb == nil { continue } records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name} } return records } func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { createdAt := s.createdAt tabNames := make([]string, len(s.tabs)) lastCustom := -1 for i, tb := range s.tabs { tabNames[i] = tb.name if tb.name != "" { lastCustom = i } } if lastCustom == -1 { tabNames = nil } else { tabNames = tabNames[:lastCustom+1] } 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)} } // closeTab is the public close boundary for callers that are not already // inside session.runMutation. Mutation dispatchers use closeTabLocked. func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { if sess == nil { return layout.ErrNotFound } sess.dispatchMu.Lock() defer sess.dispatchMu.Unlock() return d.closeTabLocked(sess, tb, repaint) } // closeTabLocked requires sess.dispatchMu before entering daemon or session // locks. Existing runMutation callers use it to avoid nested dispatch locking. func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error { return d.closeTabLockedWithEffect(sess, tb, repaint, nil) } func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffect) error { if sess == nil || tb == nil { return layout.ErrNotFound } d.mu.Lock() if d.sessions[sess.id] != sess { d.mu.Unlock() return errors.New("daemon: session closed") } sess.mu.Lock() idx := -1 for i, w := range sess.tabs { if w == tb { idx = i break } } if idx == -1 { sess.mu.Unlock() d.mu.Unlock() return errors.New("tab not found") } if len(sess.tabs) == 1 { name := sess.name sess.mu.Unlock() d.mu.Unlock() var err error if effect != nil { effect.bindActionEnd(d, "close-tab") err = d.killSessionForAttachment(sess, protocol.ReasonSessionKilled, true, effect, "close-tab") } else { err = d.killSession(sess, protocol.ReasonSessionKilled, true) } if err != nil { d.log.Warn("closing last tab failed", "session", name, "err", err) d.reportError(sess, closeTabFailureNotice(err)) return err } d.log.Info("tab closed", "session", name, "last", true) return nil } ringing := tb.attention sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx) sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) attachments := sess.snapshotAttachmentsLocked() name := sess.name if !sess.ephemeral { d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) } sess.invalidateViewsLocked() sess.mu.Unlock() d.mu.Unlock() d.log.Info("tab closed", "session", name) markSnapshotDirty(sess) tb.mu.Lock() panes := tb.panesSnapshot() tb.mu.Unlock() for _, ac := range attachments { ac.pruneCaptureFrames(panes...) } if tb.cancel != nil { tb.cancel() } for _, ac := range attachments { d.teardownFloating(tb, ac) } if rc := sess.renderCoordinator(); rc != nil { for _, p := range panes { rc.noteSyncPaneRemoved(p) } } tb.closeAllPanes() if repaint { for _, ac := range attachments { d.invalidateRender(sess, ac, true, "session.go") } } if ringing { d.repaintAllAttachedClients() } return nil } // closeTabFailureNotice classifies a failed last-tab close for the attached // client. A participants-changed abort removed nothing and is safe to retry, so // it reports that retryable outcome instead of the snapshot-write failure the // other teardown errors carry. func closeTabFailureNotice(err error) error { if errors.Is(err, errSessionKillParticipantsChanged) { return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't close tab: session changed during the close; try again", err) } return domain.UserErr(domain.NoticeSnapshotSaturated, "couldn't close tab: session state not yet saved; try again", err) } // ptyReader drains child output into the VT screen and pokes the dirty channel // (non-blocking: a full channel already means a render is pending). On any read // beginSnapshotPurge commits a logical named-session kill before its live // identity or persisted metadata is removed. It never runs under daemon or // session locks. func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error { if d.persistEnabled && d.recovery == nil { return errors.New("durable session authority is not configured") } // The lifecycle coordinator performs the complete ordered protocol after // snapshot writers have joined. return nil } // finishSnapshotPurge removes catalogue metadata first, then deletes the // incarnation directory. Background garbage collection removes the directory if // the second step is interrupted. func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, incarnation domain.IncarnationID, createdAt int64) error { if !d.persistEnabled { return nil } if d.recovery == nil { return errors.New("durable session authority is not configured") } if ctx == nil { ctx = d.serveCtx } purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout) defer cancel() return d.recovery.DeleteExact(purgeCtx, name, incarnation, createdAt) } // retryStoppedPurge completes a previously closed session's durable purge. // It keeps the stopped record hidden until all sources and metadata cross their // deletion boundaries. func (d *Daemon) retryStoppedPurge(name string) error { return d.retryStoppedPurgeContext(d.serveCtx, name) } func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt) } func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error { return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil) } func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { d.mu.Lock() stopped, ok := d.inactive[name] if !ok { d.mu.Unlock() return nil } incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt if incarnationChanged || createdAtChanged { d.mu.Unlock() return nil } stopped.purging = true d.inactive[name] = stopped d.mu.Unlock() if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil { return err } if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation, stopped.createdAt); err != nil { return err } d.mu.Lock() if current, ok := d.inactive[name]; ok && current.purging && current.sameLifecycle(stopped) { delete(d.inactive, name) } d.mu.Unlock() d.reconcileAllRouteHistories() return nil } // killSession removes a session and tears down its resources. It is // idempotent: only the caller that wins the registry delete acts. Final // session removal is independent of daemon shutdown: an empty registry leaves // the daemon serving (closing is untouched) so a later create/restore can // occupy it again, and only an explicit shutdownAll ends the daemon. // // Teardown ordering matters: context cancel and pty.Close run first and // unconditionally — never gated behind a client send. The Detached notice is // delivered asynchronously under the drain deadline. func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil) } type sessionKillAdmission struct { capability attachmentCapability effect *attachmentEffect action string } type sessionKillParticipants struct { attachments map[*attachedClient]struct{} transports map[*attachedClient]transportSnapshot effectGates []*attachedClient interrupts []attachmentTransportInterrupt source *session sourceToken *attachmentCapability target *session // requireEphemeral aborts the kill if the target stopped being ephemeral // (a concurrent rename) between the snapshot and publication. requireEphemeral bool } func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error { if effect == nil { return errAttachmentTransition } return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action}) } // snapshotSessionKillParticipants captures attachment membership under the canonical // architecture locks, but releases every lock before any attachment gate is frozen or // drained. The initiator is part of the same deduplicated global gate set even // when it belongs to a different source session. func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, condition sessionKillCondition) (sessionKillParticipants, bool) { snapshot := sessionKillParticipants{target: target, requireEphemeral: condition.ephemeral} if admission != nil { token := admission.capability if token.sess == nil || token.ac == nil || token.transport.transport == nil { return sessionKillParticipants{}, false } snapshot.source = token.sess if snapshot.source == nil { return sessionKillParticipants{}, false } snapshot.sourceToken = &admission.capability } d.mu.Lock() if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { d.mu.Unlock() return sessionKillParticipants{}, false } d.notices.routingMu.Lock() source := snapshot.source if source == nil { source = target } unlockSessions := lockAttachmentSessions(source, target) if condition.empty && (len(target.attachments) != 0 || d.sessionHasPendingResumeLocked(target)) || condition.ephemeral && !target.ephemeral { unlockSessions() d.notices.routingMu.Unlock() d.mu.Unlock() return sessionKillParticipants{}, false } snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments)) snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments)) for ac := range target.attachments { snapshot.attachments[ac] = struct{}{} snapshot.transports[ac] = ac.transportSnapshot() } unlockSessions() d.notices.routingMu.Unlock() d.mu.Unlock() seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments)) add := func(ac *attachedClient) { if ac == nil { return } if _, duplicate := seen[ac]; duplicate { return } seen[ac] = struct{}{} snapshot.effectGates = append(snapshot.effectGates, ac) } if admission != nil { add(admission.capability.ac) } for ac := range snapshot.attachments { add(ac) } for ac, transport := range snapshot.transports { if transport.transport != nil { snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) } } return snapshot, true } // sessionKillTargetRemoved reports whether the exact target session has already // left the live registry, so a post-freeze mismatch is an already-removed no-op // rather than a participant change that left work undone. func (d *Daemon) sessionKillTargetRemoved(target *session) bool { if target == nil { return true } d.mu.Lock() defer d.mu.Unlock() return d.sessions[target.id] != target } // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact // target-membership validation while every discovered attachment gate is frozen. func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool { d.mu.Lock() defer d.mu.Unlock() if d.sessions[snapshot.target.id] != snapshot.target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source { return false } d.notices.routingMu.Lock() defer d.notices.routingMu.Unlock() source := snapshot.source if source == nil { source = snapshot.target } sourceCoordinator := source.renderCoordinator() targetCoordinator := snapshot.target.renderCoordinator() unlockSessions := lockAttachmentSessions(source, snapshot.target) defer unlockSessions() unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator) defer unlockCoordinators() return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator) } // sessionKillParticipantsCurrentLocked requires the ordered source/target // session and coordinator locks. func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { target := snapshot.target if snapshot.requireEphemeral && !target.ephemeral { return false } if len(target.attachments) != len(snapshot.attachments) { return false } for ac := range snapshot.attachments { if _, current := target.attachments[ac]; !current { return false } } for ac, transport := range snapshot.transports { if !ac.transportSnapshotCurrent(transport) { return false } } if snapshot.sourceToken == nil { return true } token := *snapshot.sourceToken return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator) } // stopInMemoryLifecycle cancels every session producer and closes every PTY // independently of teardown ownership. Repository work may remain blocked, but // repeated shutdown passes cannot leave pane readers or launches running. func (s *session) stopInMemoryLifecycle() { if s == nil { return } s.lifecycleStopOnce.Do(func() { s.stopFloatingLaunches() s.closeFloatingSpare() if s.cancel != nil { s.cancel() } s.mu.Lock() tabs := append([]*tab(nil), s.tabs...) s.mu.Unlock() for _, tb := range tabs { tb.closeAllPanes() } }) } func (s *session) teardownChangeLocked() chan struct{} { if s.teardownChanged == nil { s.teardownChanged = make(chan struct{}) } return s.teardownChanged } func (s *session) signalTeardownChangedLocked() { close(s.teardownChangeLocked()) s.teardownChanged = make(chan struct{}) } // reserveShutdownTeardown makes preservation the authoritative terminal // disposition before shutdown cancels PTYs. PTY EOF may still win teardown // ownership, but it can no longer reinterpret daemon cancellation as a natural // final-shell exit and purge the named session. func (s *session) reserveShutdownTeardown() { if s == nil { return } s.teardownMu.Lock() s.shutdownTeardownRequested = true s.signalTeardownChangedLocked() s.teardownMu.Unlock() } // beginTeardown waits for ownership without holding teardownMu across teardown. // A false result means Serve's shared shutdown budget expired while another // lifecycle path owned the session. Ordinary callers wait and retry ownership, // preserving failed-teardown and stopped-purge retry semantics. func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool { _, _, acquired := s.beginTeardownRequest(deadline, 0, false) return acquired } // beginTeardownRequest acquires teardown ownership and resolves its terminal // disposition atomically with shutdown reservation. This is the sole boundary // allowed to turn a teardown trigger into preserve-versus-purge policy. func (s *session) beginTeardownRequest(deadline *snapshotShutdownDeadline, reason uint8, purge bool) (uint8, bool, bool) { for { if deadline != nil { select { case <-deadline.Done(): return reason, purge, false default: } } s.teardownMu.Lock() if !s.teardownActive && s.moveReservations == 0 { if deadline != nil { select { case <-deadline.Done(): s.teardownMu.Unlock() return reason, purge, false default: } } s.teardownActive = true s.teardownDone = make(chan struct{}) s.signalTeardownChangedLocked() if s.shutdownTeardownRequested { reason = protocol.ReasonServerShutdown purge = false } s.teardownMu.Unlock() return reason, purge, true } s.teardownWaiters++ s.signalTeardownChangedLocked() changed := s.teardownChangeLocked() s.teardownMu.Unlock() timedOut := false if deadline == nil { <-changed } else { select { case <-changed: case <-deadline.Done(): timedOut = true } } s.teardownMu.Lock() s.teardownWaiters-- s.signalTeardownChangedLocked() s.teardownMu.Unlock() if timedOut { return reason, purge, false } } } func (s *session) finishTeardown() { s.teardownMu.Lock() s.teardownActive = false close(s.teardownDone) s.teardownDone = nil s.signalTeardownChangedLocked() s.teardownMu.Unlock() } // errSessionKillDeadline reports that a caller-supplied teardown deadline // expired before killSessionWithSnapshotDeadlineAndCondition froze every // attachment gate, acquired teardown ownership, or finished joining the // session's snapshot publication. The session is left live and registered // exactly as it was: a purge's snapshot quarantine is rolled back, restoring // eligibility and dirty scheduling for the surviving session, so the abort is // retry state rather than a lifecycle mutation. It is deliberately distinct // from a completed teardown: a bounded purge or shutdown must report the skip as // a typed failure or an incomplete verdict instead of misreporting success. // Callers that pass no deadline never observe it. var errSessionKillDeadline = errors.New("session teardown deadline exceeded before ownership") // errSessionKillParticipantsChanged reports that a concurrent detach or token // resume changed the participant set between the pre-freeze snapshot and a // post-freeze revalidation, so the kill aborted before freezing every gate or // before publishing terminal ownership. The session was left untouched. It is // deliberately distinct from a completed teardown so a bounded purge can retry // against a fresh snapshot instead of counting the abort as a purged unit, and // so a caller that does not retry still sees that nothing was removed. var errSessionKillParticipantsChanged = errors.New("session kill participants changed after snapshot") // sessionKillRetryMessage is the user-facing, retryable outcome for a teardown // a concurrent detach or token resume invalidated before it removed anything. // The sentinel above is internal bookkeeping, so every user-facing path (a // direct kill or a close-tab command) maps it to this message instead of // leaking the sentinel text or a permanent-failure verdict. const sessionKillRetryMessage = "session changed during the operation; try again" // teardownDeadlineElapsed reports whether the supplied deadline has already // expired. A nil deadline never counts as elapsed. func teardownDeadlineElapsed(deadline *snapshotShutdownDeadline) bool { if deadline == nil { return false } select { case <-deadline.Done(): return true default: return false } } // killSessionWithSnapshotDeadline shares Serve's shutdown budget with its // terminal checkpoint and coordinator join. A timed-out repository call keeps // only immutable capture state; it never observes closed worker channels or // session-owned cache that teardown has released. // // A deadline that wins before this call freezes every attachment gate or // acquires teardown ownership returns errSessionKillDeadline, so a bounded // caller can distinguish a skip from a completed teardown. An already-removed // target is a no-op; a target that is still registered while its participant // set, transport incarnation, or source capability changed after the snapshot // returns errSessionKillParticipantsChanged, so an aborted teardown is never // mistaken for a completed one. Ordinary callers that pass nil preserve their // previous behavior exactly. func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error { return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, deadline, admission, sessionKillCondition{}) } // sessionKillCondition narrows a kill to a target state that is checked under // the architecture locks at snapshot and again at publication. type sessionKillCondition struct { // empty requires no attachment and no parked or parking resume credential. empty bool // ephemeral requires the target to still be an ephemeral session. ephemeral bool } // killSessionIfEmpty tears down sess only while its exact registry entry has no // attachments and no parked or parking resume credential. Membership is // captured under the normal architecture locks and fenced again at publication, so an attachment winning either side survives. func (d *Daemon) killSessionIfEmpty(sess *session, reason uint8, purge bool) error { return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, nil, nil, sessionKillCondition{empty: true}) } // killEphemeralSessionIfEmpty is killSessionIfEmpty that also refuses a target // renamed into a named session after the caller's check. func (d *Daemon) killEphemeralSessionIfEmpty(sess *session, reason uint8) error { return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, false, nil, nil, sessionKillCondition{empty: true, ephemeral: true}) } func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission, condition sessionKillCondition) error { if sess == nil { return nil } // Discover every attachment whose membership can be invalidated before freezing // any one of them. The common gate helper deduplicates and freezes the whole // immutable identity set in the same global order used by transitions. participants, ok := d.snapshotSessionKillParticipants(sess, admission, condition) if !ok { return nil } if admission != nil { d.endActionAttachmentEffect(admission.effect, admission.action) } action := "" if admission != nil { action = admission.action } if d.afterAttachmentEffectParticipantsSnapshotted != nil { d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates) } var drainDone func() <-chan struct{} var drainDeadline *attachmentEffectDrainDeadline if deadline != nil { drainDone = deadline.Done } else { drainDeadline = newAttachmentEffectDrainDeadline(d.clock) drainDone = drainDeadline.Done defer drainDeadline.stop() } frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { if d.afterAttachmentEffectGateFrozen != nil { d.afterAttachmentEffectGateFrozen(action, ac) } }}, participants.effectGates...) defer func() { frozen.unfreeze() }() if !frozen.acquired { // The acquisition helper has already rolled back exactly the gates it // owned. Do not enter architecture locks or publish terminal ownership // changes unless the complete canonical participant set is frozen. if teardownDeadlineElapsed(deadline) { return errSessionKillDeadline } return nil } if !d.sessionKillParticipantsCurrent(participants) { if d.sessionKillTargetRemoved(sess) { // The exact target already left the registry, so there is nothing left // to tear down: an already-removed session is a no-op, not a // participant change. return nil } return errSessionKillParticipantsChanged } // Pane EOF, explicit close, and daemon shutdown can all converge on the same // session. Only one owner may create the terminal mutation and checkpoint; // later callers observe the completed registry transition instead of // manufacturing a newer generation that the owner will quarantine. A // shutdown duplicate abandons this wait when the shared budget expires. var acquired bool reason, purge, acquired = sess.beginTeardownRequest(deadline, reason, purge) if !acquired { // beginTeardownRequest only fails to acquire when the deadline expired // (without one it waits until ownership is available), so the session is // still untouched and the skip must stay observable. if deadline != nil { return errSessionKillDeadline } return nil } defer sess.finishTeardown() // Capture eligibility before any coordinator quarantine flips it. Every // post-quarantine abort that leaves this session registered restores exactly // this value, so a surviving session resumes checkpoint scheduling instead of // staying permanently frozen. The rollback is gated on the same teardown // ownership as finishTeardown and on the exact registry entry, so a completed // removal never resurrects scheduling. wasEligible := sess.snapEligible.Load() quarantined := false defer func() { if quarantined && d.sessionRegistered(sess) { rollbackSnapshotQuarantine(sess, wasEligible) } }() d.mu.Lock() current := d.sessions[sess.id] d.mu.Unlock() if current != sess { if purge { sess.mu.Lock() name := sess.name sess.mu.Unlock() return d.retryStoppedPurge(name) } return nil } ringing := sess.anyAttention() sess.mu.Lock() isEphemeral := sess.ephemeral name := sess.name incarnation := sess.incarnation sess.mu.Unlock() // Join snapshot publication before changing live identity or deleting the // incarnation directory, so an older publication cannot recreate it. A shared // caller deadline bounds the join: a repository call that ignores cancellation // must never hold purge admission open, so an expired budget leaves the unit // live and reports the typed skip instead. if purge && !isEphemeral { if err := d.beginSnapshotPurge(name, incarnation); err != nil { return err } if d.snapshotRepository != nil { quarantineDone := quarantineSnapshotCoordinator(sess) quarantined = true if deadline != nil { select { case <-quarantineDone: case <-deadline.Done(): return errSessionKillDeadline } } else { <-quarantineDone } } } if !purge && !isEphemeral && d.persistEnabled { d.refreshSessionCwd(sess) } var terminalSnapshotErr error retainSnapshotRetry := false if d.snapsEnabled && !isEphemeral && !purge { markSnapshotDirty(sess) sess.snapshotMu.Lock() terminalGeneration := sess.snapshotGeneration sess.snapshotMu.Unlock() if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) { sess.mu.Lock() name := sess.name sess.mu.Unlock() terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name) if reason != protocol.ReasonServerShutdown { return terminalSnapshotErr } // Preserve any already-queued routine capture until the blocked // worker can safely discard it. This retains the dirty generation // and forced retry intent without adding another queue entry. retainSnapshotRetry = true d.persistShutdownSnapshotFailure(name, terminalSnapshotErr) } } if !isEphemeral { // Joining first preserves byte ownership for a publication that is still // encoding while this session is being torn down. Once Serve's shared // budget expires, the worker is cancelled by its one final join instead; // keep the cache reachable because an uncooperative writer may still be // encoding immutable state. var quarantineDone <-chan struct{} if retainSnapshotRetry { quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess) } else { quarantineDone = quarantineSnapshotCoordinator(sess) } quarantined = true joined := true if deadline != nil { select { case <-quarantineDone: case <-deadline.Done(): joined = false } } else { <-quarantineDone } if joined { sess.snapshotMu.Lock() sess.snapshotChunkCache = nil sess.snapshotMu.Unlock() } if d.afterSessionKillQuarantine != nil { d.afterSessionKillQuarantine(sess) } } // Revalidate the exact source capability, target lifecycle, and attachment // memberships under canonical architecture locks immediately before // publication. A changed preflight aborts without mutating either lifecycle. d.mu.Lock() if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source { d.mu.Unlock() return nil } d.notices.routingMu.Lock() source := participants.source if source == nil { source = sess } sourceCoordinator := source.renderCoordinator() targetCoordinator := sess.renderCoordinator() unlockSessions := lockAttachmentSessions(source, sess) unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator) if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) { unlockCoordinators() unlockSessions() d.notices.routingMu.Unlock() d.mu.Unlock() return errSessionKillParticipantsChanged } // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held // here. Registry removal and every attachment invalidation are one publication // while the complete immutable gate set remains frozen. if !d.unregisterSessionLocked(sess) { unlockCoordinators() unlockSessions() d.notices.routingMu.Unlock() d.mu.Unlock() return errAttachmentTransition } d.clearBarScriptsForSession(sess.id) d.purgeParkingForSessionLocked(sess) parkedRetirements := d.purgeParkedForSessionLocked(sess) attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates)) capture := func(attached *attachedClient) { if attached == nil { return } attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]}) attached.setSession(nil) attached.invalidateFrozenAttachmentCapability() } for attached := range sess.attachments { capture(attached) } clear(sess.attachments) d.purgeSuspendedForSessionLocked(sess) stoppedName := sess.name stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))) ephemeral := sess.ephemeral unlockCoordinators() unlockSessions() d.notices.routingMu.Unlock() if !ephemeral { stopped := inactiveSessionFromRecord(stoppedRecord, protocol.SessionDown, nil) stopped.purging = purge d.inactive[stoppedName] = stopped } d.mu.Unlock() d.finishParkedAttachmentRetirements(parkedRetirements) frozen.unfreeze() frozen = attachmentTransitionGuard{} d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) var purgeErr error if rc := sess.renderCoordinator(); rc != nil { // Terminal teardown has two phases: this session owner first prevents any // new worker registration and detaches all tokens, then stops and waits // outside coordinator callbacks and session locks. rc.beginSessionTeardown().finish() rc.waitForTimerWorkers() } for _, attachment := range attachments { attachment.ac.clearCaptureFrames() } // Prevent queued launches from entering Open, cancel the parent context, and // close PTYs. Serve may already have performed this phase while repository // work owned by this teardown was blocked. sess.stopInMemoryLifecycle() sess.mu.Lock() tabs := append([]*tab(nil), sess.tabs...) clipFiles := sess.clipFiles sess.clipFiles = nil sess.mu.Unlock() for _, tb := range tabs { for _, attachment := range attachments { d.teardownFloating(tb, attachment.ac) } } for _, path := range clipFiles { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { d.log.Warn("removing clipboard temp file failed", "err", err, "path", path) } } // The coordinator and all panes are now stopped, so no producer can publish // another generation after this destructive source deletion. Keep the hidden // stopped record when deletion fails or is still pending so a repeated live // kill can retry. The purge-owned exact delete is bounded: a repository // delete that ignores cancellation is detached behind a name/incarnation/ // createdAt fence rather than holding purgeAllMu, admission, or control // responses. if !ephemeral && purge { if err := d.finishLiveDurablePurge(deadline, stoppedRecord); err != nil { purgeErr = errors.Join(purgeErr, err) d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName) } } if purge && purgeErr == nil && isEphemeral { // An ephemeral purge owns no durable record, but the remaining sessions' // route histories may still reference it. d.reconcileAllRouteHistories() } if ringing { d.repaintAllAttachedClients() } for _, attachment := range attachments { // The transport is still available after membership publication. Retire // attachment-owned terminal output before sending Detached. d.cleanupAttachmentOutput(attachment.ac) d.notifyDetachedSnapshotAsync(attachment, reason) } return errors.Join(purgeErr, terminalSnapshotErr) } // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds // d.mu. func (d *Daemon) allocEphemeralNameLocked() string { used := make(map[string]struct{}, len(d.sessions)+len(d.inactive)) for _, s := range d.sessions { if s != nil { used[s.name] = struct{}{} } } for name := range d.inactive { used[name] = struct{}{} } for i := 0; ; i++ { n := strconv.Itoa(i) if _, taken := used[n]; !taken { return n } } } func (d *Daemon) findByNameLocked(name string) *session { for _, s := range d.sessions { if s != nil && s.name == name { return s } } return nil } func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { if d.findByNameLocked(name) != nil { return true } if _, ok := d.inactive[name]; ok { return true } _, reserved := d.creating[name] return reserved } func (d *Daemon) cwdSampler(ctx context.Context) { t := d.clock.NewTimer(5 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C(): } if d.procCwd != nil { d.refreshNamedSessionCwds() } if err := d.flushCatalogue(); err != nil { d.log.Warn("flushing session catalogue failed", "err", err) } t.Reset(5 * time.Second) } } func (d *Daemon) refreshNamedSessionCwds() { d.mu.Lock() sessions := d.sessionsSnapshotLocked() d.mu.Unlock() for _, sess := range sessions { sess.mu.Lock() ephemeral := sess.ephemeral sess.mu.Unlock() if !ephemeral { d.refreshSessionCwd(sess) } } } func (d *Daemon) refreshSessionCwd(sess *session) { if d.procCwd == nil { return } tb := sess.firstTab() if tb == nil { return } tb.mu.Lock() p := tb.focusedPane() tb.mu.Unlock() if p == nil { return } cwd, err := d.procCwd(p.pty.Pid()) if err != nil || cwd == "" { return } d.mu.Lock() if d.sessions[sess.id] != sess { d.mu.Unlock() return } sess.mu.Lock() if sess.cwd == cwd { sess.mu.Unlock() d.mu.Unlock() return } sess.cwd = cwd if !sess.ephemeral { d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) } sess.mu.Unlock() d.mu.Unlock() markSnapshotDirty(sess) } // childEnv retains the daemon-environment helper for daemon-local legacy callers. // Interactive PTY launch paths use childEnvFrom with their session snapshot. func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string { return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID) } func copyEnvironment(env []string) []string { return append([]string(nil), env...) } func environmentEntry(entry string) (name, value string, ok bool) { name, value, ok = strings.Cut(entry, "=") return name, value, ok } const defaultShellCommand = "/bin/sh" const defaultShellTitle = "sh" type shellLaunchSpec struct { command string args []string title string } // shellLaunch resolves the command and display fallback for one normal PTY. // Both values come from the same client environment snapshot (or explicit // daemon override), so headless catalogues identify the shell they actually run. func (d *Daemon) shellLaunch(env []string) shellLaunchSpec { command := shellFromEnvironment(env) var args []string if d.shellOverride { command = d.shell args = append([]string(nil), d.shellArgs...) } return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)} } func shellDisplayName(command string) string { title := filepath.Base(command) if title == "." || title == string(filepath.Separator) || title == "" { return command } return title } func shellFromEnvironment(env []string) string { shell := "" for _, entry := range env { if name, value, ok := environmentEntry(entry); ok && name == "SHELL" { shell = value } } if shell == "" { return defaultShellCommand } return shell } // childEnvFrom preserves the supplied environment byte-for-byte and in order, // except for exact reserved variable names which vev owns. TERM starts with the // compatibility default; the PTY adapter selects the host-available profile. func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string { out := make([]string, 0, len(env)+4) for _, entry := range env { key, _, ok := environmentEntry(entry) if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") { continue } out = append(out, entry) } out = append(out, "TERM=xterm-256color", "COLORTERM=truecolor") return append(out, "TERM_PROGRAM=vev", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID, ) }