1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "context"
6 cryptorand "crypto/rand"
7 "encoding/binary"
8 "errors"
9 "log/slog"
10 "math"
11 "os"
12 "reflect"
13 "sort"
14 "sync"
15 "sync/atomic"
16 "time"
17
18 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/domain/terminalcap"
20 "github.com/bnema/vev/internal/ports"
21 "github.com/bnema/vev/internal/protocol"
22 "github.com/bnema/vev/internal/usecase/keys"
23 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24)
25
26// Scheduler debounce bounds. Idle updates use the minimum for low latency;
27// sustained floods step toward the maximum to reduce frame/syscall pressure.
28const (
29 minDebounceInterval = 2 * time.Millisecond
30 maxSyncUpdateDuration = 500 * time.Millisecond
31)
32
33const debounceInterval = minDebounceInterval
34
35// ptyReadBufSize is the PTY reader's read buffer.
36const ptyReadBufSize = 32 * 1024
37
38// New and restored panes have independent line and uncompressed logical-byte
39// ceilings. Visible primary and alternate screens are outside this budget.
40const (
41 defaultScrollbackRows = domain.DefaultScrollbackLines
42 defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000
43)
44
45// detachNotifyTimeout bounds the best-effort Detached notification on the
46// detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
47// blocks the write for this long, its transport is force-closed, which fails
48// the in-flight send. Teardown is never gated on a client draining its socket.
49const detachNotifyTimeout = time.Second
50
51// maxUnackedOutputStates caps how many output states may be in flight (sent but
52// not yet acked by the client) before paint defers rather than composing
53// another diff. It bounds the daemon's paint rate to the client's ack rate, so
54// heavy output degrades to lower fps on a slow link instead of overflowing the
55// transport. It must stay well under the datagram carriage's 32-frame client
56// window so its reliable queue never fills from painting alone.
57const maxUnackedOutputStates = protocol.MaxOutputWindow
58
59// normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
60// means the legacy/default window, so malformed or absent values remain safe.
61func normalizeOutputWindow(window uint8) uint8 {
62 if window == 0 || window > maxUnackedOutputStates {
63 return maxUnackedOutputStates
64 }
65 return window
66}
67
68const defaultResumeParkGrace = 15 * time.Minute
69
70// defaultSuspendedSafetyExpiry bounds how long the daemon retains a suspended
71// attachment. Suspension keeps transport ownership and session membership but
72// removes interactive authority, so a client that never returns must not keep
73// the underlying process alive indefinitely. Expiry is a terminal, no-notice
74// eviction that never mints a resume credential.
75const defaultSuspendedSafetyExpiry = 24 * time.Hour
76
77// defaultSize is retained for headless layout helpers that have no client
78// window; Hello routing rejects invalid dimensions instead of using it.
79var defaultSize = domain.Size{Cols: 80, Rows: 24}
80
81type Daemon struct {
82 mu sync.Mutex
83 sessions map[domain.SessionID]*session
84 inactive map[string]inactiveSession
85 // creating reserves names while durable creation I/O runs without mu.
86 creating map[string]struct{}
87 nextID uint64
88 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
89 // mark. It is guarded by mu and prevents a wall-clock regression from
90 // reusing a lifecycle identity.
91 lastAllocatedCreatedAt int64
92 mruSeq atomic.Uint64
93 creationRequestSeq atomic.Uint64
94 // closing marks that explicit daemon shutdown (KillDaemon/process
95 // cancellation) has irreversibly begun. It is set under mu when
96 // shutdownAll starts, and checked by route under the same mutex — so a Hello
97 // racing shutdown can never insert a new session that nobody would tear
98 // down. Neither an empty registry (final session removal) nor a KillAll
99 // purge sets it: the daemon survives empty→occupied→empty and survives a
100 // purge, and only an explicit stop/process cancellation ends it.
101 closing bool
102
103 // moveLifecycleMu is the daemon-level admission gate for transferable
104 // ownership changes. It is acquired before any session teardownMu, and is
105 // never held while a move, teardown, or external operation runs.
106 moveLifecycleMu sync.Mutex
107 moveLifecycleClosing bool
108 moveLifecycleActive uint
109 moveLifecycleChanged chan struct{}
110 // paneProcessCtx roots transferable pane processes outside any one session.
111 // Shutdown cancels it only after the global move gate drains.
112 paneProcessCtx context.Context
113 paneProcessCancel context.CancelFunc
114
115 // purgeAllMu grants one daemon-wide purge (KillAll) owner at a time so two
116 // concurrent kill-all requests never interleave their exact lifecycle sets.
117 // It is not an admission gate; see purgeAdmissionClosing below.
118 purgeAllMu sync.Mutex
119 // purgeAdmissionClosing is the transient KillAll admission gate. Unlike
120 // closing and moveLifecycleClosing it is re-openable: purgeAllSessions sets
121 // it under mu, removes exactly the live and stopped/broken/durable records
122 // captured at that instant, then clears it so the daemon keeps serving.
123 // Creation and restoration wait on the changed channel (a blocked writer
124 // keeps new admissions out), while moves and internal creations reject, so
125 // no transition can be inserted into the purge's exact lifecycle set.
126 // purgeAdmissionActive counts admitted transitions the purge must drain.
127 // purgeEpoch increments once at each purge that acquires the gate (after the
128 // drain) so a restoration worker blocked on admission can detect a purge
129 // that completed while it waited and must not resurrect the records captured
130 // before that purge.
131 // All three fields are guarded by mu.
132 purgeAdmissionClosing bool
133 purgeAdmissionActive uint
134 purgeAdmissionChanged chan struct{}
135 purgeEpoch uint64
136
137 // notifies holds one completion channel per in-flight async Detached
138 // notification (guarded by mu, pruned on insert). Channels rather than a
139 // WaitGroup: notifications are spawned from arbitrary goroutines while
140 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
141 // with Wait.
142 notifies []chan struct{}
143 // resume owns parked and in-flight parking resume credentials. Guarded by mu.
144 resume resumeCredentials
145 // suspended retains one daemon-owned safety expiry per suspended
146 // attachment. Expiry is exact: the record carries the suspension's
147 // lifecycle generation and transport incarnation, so a later activation,
148 // re-suspension, detach, or session teardown can never be retired by a
149 // stale timer. Guarded by mu.
150 suspended map[*attachedClient]*suspendedAttachmentRetention
151 // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty
152 // ID blocks. Once a block may have reached an outer terminal it remains in
153 // this bounded table for the daemon lifetime: side-effect Output frames have
154 // no terminal ACK. Pool exhaustion disables graphics for new attachments and
155 // leaves their ordinary text output intact.
156 graphicsNamespaces map[uint64]struct{}
157 graphicsNamespaceFences map[uint64]uint64
158 graphicsNamespaceQuarantines map[uint64]*graphicsNamespaceQuarantine
159 // graphicsNamespaceSalt separates daemon lifetimes before attachment keys
160 // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
161 // daemon must not deterministically reopen the previous daemon's block.
162 graphicsNamespaceSalt uint64
163
164 attnMu sync.Mutex
165 animFrame int
166 animWake chan struct{}
167
168 paletteRecentMu sync.Mutex
169 paletteRecent []string
170 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
171 // interval between JRS validation and its committed hand-off.
172 beforeRecentSessionHandoff func()
173 // beforeCopyModeRevalidate is a deterministic test seam between staging a
174 // copy-mode candidate and revalidating its pane membership.
175 beforeCopyModeRevalidate func()
176 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
177 // snapshot is captured and before its mapped position is applied.
178 beforeCopyMouseMap func()
179 // beforeSessionResizePublication runs after final external PTY validation
180 // and before coordinator epoch admission. It is a deterministic regression
181 // seam for stale resize publication.
182 beforeSessionResizePublication func()
183 // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
184 // and immediately before a post-commit effect is published. Tests use it to
185 // move a pane through the ordered resize fences at the former TOCTOU window.
186 beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
187 // afterConnectionSessionSnapshot is a deterministic test seam after the
188 // connection loop reads its current session and before it captures that role.
189 afterConnectionSessionSnapshot func(*session)
190 // afterAttachmentFrameDispatch is a deterministic test seam after the
191 // connection loop snapshots a token and before a decoded frame takes effect.
192 afterAttachmentFrameDispatch func(attachmentCapability)
193 // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
194 // participants after architecture preflight locks are released and before
195 // the globally ordered freeze begins.
196 afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
197 // afterAttachmentEffectGateFrozen observes each participant after it is frozen in
198 // immutable identity order and before drain/publication continues.
199 afterAttachmentEffectGateFrozen func(string, *attachedClient)
200 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
201 // attachment gates are frozen and drained, before architecture publication.
202 afterAttachmentEffectsFrozen func()
203 // afterPurgeAdmission is a deterministic test seam that runs after a KillAll
204 // purge has closed purge admission and taken the closing fence, while it
205 // still owns purgeAllMu. It lets tests observe purge serialization.
206 afterPurgeAdmission func()
207 // beforePurgeLiveSessionKill is a deterministic test seam that runs after a
208 // KillAll purge's per-unit shutdown precheck and before that live unit
209 // attempts teardown ownership. Tests use it to let an explicit stop win that
210 // exact window, proving a stop-owned unit is never reported as a success.
211 beforePurgeLiveSessionKill func(*session)
212 // afterSessionKillQuarantine is a deterministic test seam that runs while a
213 // teardown owns the session and its snapshot coordinator is quarantined, but
214 // before the pre-publication revalidation. Tests use it to change the
215 // participant set in that window and prove a post-quarantine abort rolls the
216 // quarantine back so a surviving session keeps checkpointing.
217 afterSessionKillQuarantine func(*session)
218 // beforeAttachmentTransitionIdentityAdmission is a deterministic test seam
219 // after a ready transition published its capability and before the committed
220 // route identity is admitted, so tests can supersede that exact capability in
221 // the window before the transition's first paint and completion.
222 beforeAttachmentTransitionIdentityAdmission func(attachmentCapability)
223 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
224 // seam used by transition validation tests.
225 afterAttachmentTransitionCoordinatorsLocked func()
226 // beforeMoveDispatch, afterMoveLifecycleReserved, and
227 // afterMovePaneSourceSnapshot are test-only seams. They run before dispatch
228 // admission, after lifecycle reservation, and after the pre-fence source
229 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
230 // non-failing publication section. None is set in production, and none may
231 // perform external work while locks are held.
232 beforeMoveDispatch func()
233 afterMoveLifecycleReserved func()
234 afterMoveLifecycleGateBeforeTeardownLocks func()
235 afterMovePaneSourceSnapshot func()
236 afterMoveTabSourceSnapshot func()
237 beforeMovePaneCommit func()
238 beforeMoveTabCommit func()
239 // beforeMoveFollowCompletion is a deterministic test seam after the composite
240 // follow's first paint and before the postcommit admits the capability that
241 // reports completion, so tests can supersede the published capability.
242 beforeMoveFollowCompletion func(attachmentCapability)
243 // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
244 // attachment gate but before it checks session ownership.
245 afterDetachAttachmentEffectsFrozen func()
246 // beforeClientGoneDetach pauses clientGone after the stale-transport
247 // precheck and before exact transport/incarnation detach validation.
248 beforeClientGoneDetach func()
249 // beforeMarkParkingInFlight pauses resumeLiveAttachment after the live
250 // credential matches and before markParkingInFlight, so tests can win
251 // explicit detach or session removal before late marker publication.
252 beforeMarkParkingInFlight func()
253 // afterClientGoneDetach pauses finishClientGone after detach won and before
254 // parkAttachment. The parking-in-flight marker must already be published.
255 afterClientGoneDetach func()
256 // afterResumeLiveDetach pauses resumeLiveAttachment after detach won and
257 // before parkAttachment. The parking-in-flight marker must already be
258 // published so a concurrent same-token resume can wait the gap.
259 afterResumeLiveDetach func()
260 // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
261 // matching in-flight entry and before it blocks on done.
262 afterParkingWaitArmed func()
263 // beforeResumeParkedSendMu pauses resumeParked after the initial parked
264 // lookup and before the attachment send lock, so tests can consume or
265 // replace the credential while the handshake waits.
266 beforeResumeParkedSendMu func()
267 // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
268 // reserves its exact capability and before its first observable mutation.
269 afterAttachmentEffectAdmitted func(attachmentCapability)
270 // beforeFirstPaintSendWait is a test-only seam immediately before a
271 // transition first paint waits for the attachment send lock.
272 beforeFirstPaintSendWait func(attachmentCapability)
273 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
274 // fresh exact capability before producing PTY, action, or overlay effects.
275 afterDelayedKeyEffectAttempt func(bool)
276 // afterActionAttachmentEffectEnded observes action-specific admission release. It
277 // is a deterministic seam for proving no role-bound mutation follows release.
278 afterActionAttachmentEffectEnded func(string)
279 // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
280 // after the render ticket ends and before exact lifecycle validation.
281 beforeAttachmentSendErrorCleanup func(attachmentCapability)
282 afterAttachmentSendErrorCleanup func()
283 // beforeSuspendedExpiryFreeze pauses the safety expiry after its timer fired
284 // and before it freezes the attachment effect gate, so tests can win
285 // activation, disconnect, or replacement first. It is a test-only seam.
286 beforeSuspendedExpiryFreeze func(*attachedClient)
287 ptys ports.PTYFactory
288 clock ports.Clock
289 log *slog.Logger
290 runtimeObserver ports.RuntimeObserver
291 baseEnv []string
292 shell string
293 shellArgs []string
294 shellOverride bool
295 persistEnabled bool
296 catalogue ports.Catalogue
297 catalogueRecords []domain.CatalogueRecord
298 catalogueRecordsProvided bool
299 // snapshotRepository is the sole checkpoint storage contract.
300 snapshotRepository ports.SnapshotRepository
301 recovery *recoveryusecase.Coordinator
302 snapshotGarbageCollection bool
303 maintenanceWorkerCancel context.CancelFunc
304 maintenanceWorkerDone chan struct{}
305 // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
306 // restoration reconciles durable checkpoints, so it is a durable writer and
307 // is guarded by snapshotWorkerMu with the other two.
308 restoreWorkerDone chan struct{}
309 snapsEnabled bool
310 noticeStore ports.NoticeStore
311 snapshotJobs chan *snapshotCapture
312 // snapshotAdmitted contains every capture accepted by either worker queue,
313 // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
314 snapshotAdmitted map[*snapshotCapture]struct{}
315 // snapshotWake wakes the repository scheduler when a session becomes dirty
316 // or an attempt completes. It is never closed and producers only send
317 // non-blockingly.
318 snapshotWake chan struct{}
319 snapshotWorkerMu sync.Mutex
320 snapshotWorkerID uint64
321 snapshotWorkerCtx context.Context
322 snapshotWorkerCancel context.CancelFunc
323 snapshotWorkerDone chan struct{}
324 snapshotWorkerFlush chan struct{}
325 snapshotWorkerFinalWake chan struct{}
326 // snapshotFinalJobs coalesces terminal captures by session when the bounded
327 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
328 // sessions, each with only its newest terminal state while the worker blocks.
329 snapshotFinalJobs map[*session]*snapshotCapture
330 snapshotFinalOrder []*session
331 snapshotWorkerClosing bool
332 snapshotWorkerInFlight *snapshotCapture
333 // snapshotNoticeMu guards the active global persistence failure signature.
334 // It is separate from snapshotWorkerMu so notice routing cannot block a
335 // producer or a repository worker.
336 snapshotNoticeMu sync.Mutex
337 snapshotActiveFailureSignature string
338 shutdownNoticeMu sync.Mutex
339 shutdownNoticedSessions map[string]struct{}
340 restoreDone chan struct{}
341 restoreOnce sync.Once
342 procCwd func(int) (string, error)
343 procComm func(int) (string, error)
344 procArgv func(int) ([]string, error)
345 procGroupArgv func(int, int) ([]string, error)
346 dirOrHome func(string) string
347 bindings atomic.Pointer[keys.Bindings]
348 codeOverrides atomic.Pointer[map[string]string]
349 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
350 floatingConfig atomic.Pointer[domain.FloatingConfig]
351 copyConfig atomic.Pointer[domain.CopyConfig]
352 paletteConfig atomic.Pointer[domain.PaletteConfig]
353 navConfig atomic.Pointer[domain.NavConfig]
354 tabsConfig atomic.Pointer[domain.TabsConfig]
355 ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
356 scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
357 themeConfig atomic.Pointer[themeConfigSnapshot]
358 barScripts *barScriptState
359 notices *noticeCenter
360 resumeParkGrace time.Duration
361 suspendedSafetyExpiry time.Duration
362 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
363 // (see clipboard.go); empty means use os.TempDir().
364 tempDir string
365
366 serveCtx context.Context
367 serveCancel context.CancelFunc
368
369 // hardCtx force-closes connection transports on shutdown, but only after
370 // shutdownAll has delivered graceful Detached notices — keeping it separate
371 // from serveCtx so a parent-context cancel never races the notice.
372 hardCtx context.Context
373 hardCancel context.CancelFunc
374
375 // done is closed exactly once by an explicit daemon stop (shutdownAll) to
376 // break Serve's accept loop. It is deliberately not closed by final session
377 // removal or by a KillAll purge: an empty registry and a purged registry
378 // both keep the daemon serving.
379 done chan struct{}
380 doneOnce sync.Once
381
382 // sessWg owns attention animation, bar-script polling, CWD sampling,
383 // snapshot save/restore, and floating-session launch goroutines.
384 sessWg sync.WaitGroup
385 connWg sync.WaitGroup // per-connection handler goroutines
386 // attachmentCleanupWg owns replacement transport closes and retired render
387 // worker joins. Only connection handlers add work; Serve waits for those
388 // handlers before joining this group, so no Add races its terminal Wait.
389 attachmentCleanupWg sync.WaitGroup
390}
391
392type parkedAttachment struct {
393 sess *session
394 ac *attachedClient
395 pickerGeneration uint64
396 paletteGeneration uint64
397 timer ports.Timer
398 claimed bool
399 done chan struct{}
400 doneOnce sync.Once
401}
402
403// parkingAttachment is the observable detach→park lifecycle for one resume
404// credential. done closes when the token is published into parked or parking
405// is abandoned.
406type parkingAttachment struct {
407 sess *session
408 ac *attachedClient
409 done chan struct{}
410 doneOnce sync.Once
411}
412
413func (p *parkingAttachment) closeDone() {
414 if p == nil || p.done == nil {
415 return
416 }
417 p.doneOnce.Do(func() { close(p.done) })
418}
419
420// inactiveSession is the durable authority for a named session without a live
421// runtime. It remains present while healthy-down, restoring, broken, degraded,
422// or purge-fenced; callers must use its predicates instead of inferring those
423// states from map membership.
424type inactiveSession struct {
425 name string
426 cwd string
427 createdAt int64
428 incarnation domain.IncarnationID
429 lastUsedSeq uint64
430 tabNames []string
431 tabRecords []domain.CatalogueTabRecord
432 purging bool
433 record domain.CatalogueRecord
434 state protocol.SessionState
435 restoreDone chan struct{}
436}
437
438func (s inactiveSession) restorePending() bool {
439 if s.restoreDone == nil {
440 return false
441 }
442 select {
443 case <-s.restoreDone:
444 return false
445 default:
446 return true
447 }
448}
449
450func (s inactiveSession) broken() bool {
451 return s.state == protocol.SessionBroken || s.record.DegradedReason != ""
452}
453
454func (s inactiveSession) visible() bool {
455 return !s.purging
456}
457
458func (s inactiveSession) canResume() bool {
459 return s.visible() && s.state == protocol.SessionDown && !s.broken()
460}
461
462func (s inactiveSession) sameLifecycle(other inactiveSession) bool {
463 return s.name == other.name && s.createdAt == other.createdAt && s.incarnation == other.incarnation
464}
465
466type Option func(*Daemon)
467
468// WithRuntimeObserver accepts only a composition-root serialized observer.
469// The application owns its lifecycle; the daemon never creates or closes a
470// second reporting worker around it.
471func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
472 return func(d *Daemon) { d.runtimeObserver = observer }
473}
474
475// WithShell overrides the command (and its args) each session spawns. The
476// default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
477// deterministic program.
478func WithShell(cmd string, args []string) Option {
479 return func(d *Daemon) {
480 d.shell = cmd
481 d.shellArgs = args
482 d.shellOverride = true
483 }
484}
485
486// WithCatalogue installs the singular catalogue and the strictly opened
487// records that define the daemon's expected-session registry at startup.
488func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
489 return func(d *Daemon) {
490 d.catalogue = catalogue
491 d.persistEnabled = catalogue != nil
492 d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
493 d.catalogueRecordsProvided = true
494 }
495}
496
497func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool, error) {
498 if d == nil || d.catalogue == nil {
499 return domain.CatalogueRecord{}, false, nil
500 }
501 return d.catalogue.Record(name)
502}
503
504// markCatalogueDirty buffers metadata. The periodic catalogue timer, the next
505// identity write, or shutdown provides the durability barrier.
506func (d *Daemon) markCatalogueDirty(update domain.CatalogueMetadataUpdate) {
507 if d == nil || d.catalogue == nil {
508 return
509 }
510 if err := d.catalogue.UpdateMetadata(update); err != nil {
511 d.log.Warn("buffering session metadata failed", "err", err, "session", update.Name)
512 }
513}
514
515func (d *Daemon) flushCatalogue() error {
516 if d == nil || d.catalogue == nil {
517 return nil
518 }
519 return d.catalogue.Sync()
520}
521
522// WithRecoveryCoordinator installs the durable recovery coordinator.
523func WithRecoveryCoordinator(coordinator *recoveryusecase.Coordinator) Option {
524 return func(d *Daemon) {
525 d.recovery = coordinator
526 }
527}
528
529// WithSnapshotRepository enables content-addressed incremental snapshots.
530func WithSnapshotRepository(repository ports.SnapshotRepository) Option {
531 return func(d *Daemon) {
532 if isNilSnapshotRepository(repository) {
533 return
534 }
535 d.snapshotRepository = repository
536 d.snapsEnabled = true
537 }
538}
539
540func isNilSnapshotRepository(repository ports.SnapshotRepository) bool {
541 if repository == nil {
542 return true
543 }
544 value := reflect.ValueOf(repository)
545 switch value.Kind() {
546 case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
547 return value.IsNil()
548 default:
549 return false
550 }
551}
552
553// WithNoticeStore enables persisting undeliverable notices across daemon
554// restarts. A nil store keeps the daemon in no-op notice-persistence mode.
555func WithNoticeStore(store ports.NoticeStore) Option {
556 return func(d *Daemon) { d.noticeStore = store }
557}
558
559// WithSnapshotGarbageCollection runs background snapshot GC through the
560// recovery coordinator installed by WithRecoveryCoordinator.
561func WithSnapshotGarbageCollection() Option {
562 return func(d *Daemon) { d.snapshotGarbageCollection = true }
563}
564
565// WithCwdReader overrides the process cwd reader used for persistence tests.
566func WithCwdReader(fn func(int) (string, error)) Option {
567 return func(d *Daemon) {
568 if fn != nil {
569 d.procCwd = fn
570 }
571 }
572}
573
574// WithProcessInspector installs the platform process-inspection implementation.
575func WithProcessInspector(ins ports.ProcessInspector) Option {
576 return func(d *Daemon) {
577 if ins == nil {
578 return
579 }
580 d.procCwd = ins.Cwd
581 d.procComm = ins.Comm
582 d.procArgv = ins.Argv
583 d.procGroupArgv = ins.GroupArgv
584 }
585}
586
587// WithDirOrHome installs path fallback behavior from the application layer.
588func WithDirOrHome(fn func(string) string) Option {
589 return func(d *Daemon) {
590 if fn != nil {
591 d.dirOrHome = fn
592 }
593 }
594}
595
596// WithTempDir overrides the directory clipboard-image-transfer writes temp
597// files into (production default: os.TempDir()); tests use this with
598// t.TempDir() so writes are isolated and auto-cleaned.
599func WithTempDir(dir string) Option {
600 return func(d *Daemon) {
601 d.tempDir = dir
602 }
603}
604
605// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
606func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
607 return func(d *Daemon) {
608 if runner != nil {
609 d.barScripts.runner = barScriptRunner{runner: runner}
610 }
611 }
612}
613
614// WithResumeParkGrace overrides how long detached resume-capable clients stay
615// parked for reconnection. Non-positive durations keep the default.
616func WithResumeParkGrace(grace time.Duration) Option {
617 return func(d *Daemon) {
618 if grace > 0 {
619 d.resumeParkGrace = grace
620 }
621 }
622}
623
624// WithSuspendedSafetyExpiry overrides how long the daemon retains a suspended
625// attachment before terminal eviction. Non-positive durations keep the default.
626// Fake-clock tests set a short duration so the real timer path stays under test.
627func WithSuspendedSafetyExpiry(expiry time.Duration) Option {
628 return func(d *Daemon) {
629 if expiry > 0 {
630 d.suspendedSafetyExpiry = expiry
631 }
632 }
633}
634
635// WithConfig applies the initial user configuration.
636func WithConfig(cfg domain.Config) Option {
637 return func(d *Daemon) {
638 d.ApplyConfig(cfg)
639 }
640}
641
642// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
643// holds d.mu. The persisted high-water mark is loaded by New before any named
644// session can be resumed or created.
645func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
646 now := d.nowUnixNano()
647 if now <= d.lastAllocatedCreatedAt {
648 if d.lastAllocatedCreatedAt == math.MaxInt64 {
649 return 0, errors.New("daemon: lifecycle identities exhausted")
650 }
651 now = d.lastAllocatedCreatedAt + 1
652 }
653 d.lastAllocatedCreatedAt = now
654 return now, nil
655}
656
657func (d *Daemon) nowUnixNano() int64 {
658 return d.daemonNow().UnixNano()
659}
660
661// daemonNow reports the daemon clock. New defaults a nil clock to the
662// system clock, so the fallback below only serves hand-built fixtures
663// and stays explicit about its provenance instead of hiding a bare
664// wall-clock read.
665func (d *Daemon) daemonNow() time.Time {
666 if d == nil || d.clock == nil {
667 return systemClock{}.Now()
668 }
669 return d.clock.Now()
670}
671
672type systemClock struct{}
673type systemTimer struct{ *time.Timer }
674
675var graphicsNamespaceFallbackSalt atomic.Uint64
676
677func newGraphicsNamespaceSalt() uint64 {
678 var bytes [8]byte
679 if _, err := cryptorand.Read(bytes[:]); err == nil {
680 if salt := binary.BigEndian.Uint64(bytes[:]); salt != 0 {
681 return salt
682 }
683 }
684 for {
685 salt := graphicsNamespaceFallbackSalt.Add(1)
686 if salt != 0 {
687 return salt
688 }
689 }
690}
691
692func (systemClock) Now() time.Time { return time.Now() }
693func (systemClock) NewTimer(delay time.Duration) ports.Timer {
694 return systemTimer{Timer: time.NewTimer(delay)}
695}
696func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
697
698// New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
699// render debounce, and log receives diagnostics (defaults to slog.Default).
700func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
701 if log == nil {
702 log = slog.Default()
703 }
704 if clock == nil {
705 clock = systemClock{}
706 }
707 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
708 d := &Daemon{
709 sessions: make(map[domain.SessionID]*session),
710 inactive: make(map[string]inactiveSession),
711 creating: make(map[string]struct{}),
712 resume: newResumeCredentials(),
713 suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
714 graphicsNamespaces: make(map[uint64]struct{}),
715 graphicsNamespaceFences: make(map[uint64]uint64),
716 graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
717 graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
718 paneProcessCtx: paneProcessCtx,
719 paneProcessCancel: paneProcessCancel,
720 ptys: ptys,
721 clock: clock,
722 log: log,
723 baseEnv: os.Environ(),
724 shell: defaultShellCommand,
725 dirOrHome: dirOrHome,
726 done: make(chan struct{}),
727 restoreDone: make(chan struct{}),
728 animWake: make(chan struct{}, 1),
729 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
730 snapshotAdmitted: make(map[*snapshotCapture]struct{}),
731 snapshotWake: make(chan struct{}, 1),
732 notices: newNoticeCenter(),
733 resumeParkGrace: defaultResumeParkGrace,
734 suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
735 barScripts: &barScriptState{
736 cfg: barConfigFromDomain(domain.Defaults().Bar),
737 outputs: make(map[domain.SessionID]barScriptOutputs),
738 lastRefresh: make(map[domain.SessionID]time.Time),
739 lastContext: make(map[domain.SessionID]barScriptContext),
740 running: make(map[domain.SessionID]bool),
741 reload: make(chan struct{}, 1),
742 },
743 }
744 defaults := domain.Defaults()
745 defaultFloating := defaults.Floating
746 d.floatingConfig.Store(&defaultFloating)
747 defaultCopy := defaults.Copy
748 d.copyConfig.Store(&defaultCopy)
749 defaultPalette := defaults.Palette
750 d.paletteConfig.Store(&defaultPalette)
751 defaultTabs := defaults.Tabs
752 d.tabsConfig.Store(&defaultTabs)
753 for _, o := range opts {
754 o(d)
755 }
756 if d.dirOrHome == nil {
757 d.dirOrHome = dirOrHome
758 }
759 if d.bindings.Load() == nil {
760 d.bindings.Store(keys.DefaultBindings())
761 }
762 if d.codeOverrides.Load() == nil {
763 empty := map[string]string{}
764 d.codeOverrides.Store(&empty)
765 }
766 if d.restoreProcessAllowlist.Load() == nil {
767 allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
768 d.restoreProcessAllowlist.Store(&allow)
769 }
770 records := d.catalogueRecords
771 var maxSeq uint64
772 var maxCreatedAt int64
773 hasCreatedAt := false
774 for _, r := range records {
775 if d.catalogueRecordsProvided {
776 state, done := initialSessionState(r)
777 d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done)
778 } else {
779 d.inactive[r.Name] = inactiveSessionFromRecord(r, protocol.SessionDown, nil)
780 }
781 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
782 maxCreatedAt = r.CreatedAt
783 hasCreatedAt = true
784 }
785 if r.LastUsedSeq > maxSeq {
786 maxSeq = r.LastUsedSeq
787 }
788 }
789 if hasCreatedAt {
790 d.lastAllocatedCreatedAt = maxCreatedAt
791 }
792 d.mruSeq.Store(maxSeq)
793 return d
794}
795
796// Serve runs the accept loop over l, owning it for the loop's lifetime. It
797// returns only on ctx cancellation or an explicit daemon shutdown request
798// (KillDaemon), never when the session registry drains to empty: final
799// session removal is independent of daemon shutdown so the daemon survives
800// empty→occupied→empty and can accept fresh sessions again. A KillAll purge
801// leaves Serve running too. On the ctx path
802// attached clients are detached with ReasonServerShutdown.
803func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
804 d.serveCtx, d.serveCancel = context.WithCancel(ctx)
805 defer d.serveCancel()
806 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
807 defer d.hardCancel()
808
809 d.sessWg.Go(func() {
810 d.attentionAnimator(d.serveCtx)
811 })
812 d.sessWg.Go(func() {
813 d.barScriptPoller(d.serveCtx)
814 })
815 d.sessWg.Go(func() {
816 d.historyMaintenance(d.serveCtx)
817 })
818 if d.persistEnabled {
819 d.sessWg.Go(func() {
820 d.cwdSampler(d.serveCtx)
821 })
822 }
823 if d.snapsEnabled {
824 d.startSnapshotEncodeWorker()
825 d.sessWg.Go(func() {
826 d.snapshotRepositorySaver(d.serveCtx)
827 })
828 d.startSnapshotRestoration()
829 d.startDurableMaintenance()
830 } else {
831 d.closeRestoreDone()
832 }
833 // Break the accept loop only on an explicit shutdown: the parent context
834 // being cancelled or an explicit daemon stop request (d.done). An empty
835 // registry never closes the listener, so the daemon keeps accepting.
836 go func() {
837 select {
838 case <-d.serveCtx.Done():
839 case <-d.done:
840 }
841 _ = l.Close()
842 }()
843
844 for {
845 tr, err := l.Accept()
846 if err != nil {
847 if d.serveCtx.Err() != nil {
848 d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
849 } else {
850 d.log.Warn("accept loop exiting", "err", err)
851 }
852 break
853 }
854 d.connWg.Go(func() {
855 d.handleConn(tr)
856 })
857 }
858
859 // Tear down. shutdownAll marks the daemon closing (route now rejects any
860 // racing Hello) and kills every session: PTYs are closed and contexts
861 // cancelled unconditionally, with the Detached notices sent asynchronously
862 // under a deadline. Wait for those notices (bounded — a wedged client's
863 // transport is force-closed by the deadline) before hard-closing the
864 // remaining parked connections, so graceful notices are never raced by the
865 // force-close. Finally drain the conn handlers, run one defensive sweep in
866 // case any ordering ever leaves a session behind, and join the session
867 // goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
868 // One deadline owns every terminal checkpoint wait and the final worker
869 // join. A repository is allowed to ignore cancellation, so spending a fresh
870 // interval at either stage would make shutdown exceed its documented bound.
871 var snapshotDeadline *snapshotShutdownDeadline
872 if d.snapsEnabled {
873 snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
874 defer snapshotDeadline.stop()
875 }
876 d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
877 d.waitNotifies()
878 d.hardCancel()
879 d.serveCancel()
880 // Wait for handlers before flushing snapshots: a handler that already
881 // entered killSession may still submit its terminal capture after the
882 // registry snapshot has been removed.
883 d.connWg.Wait()
884 d.attachmentCleanupWg.Wait()
885 // Forced terminal checkpoints run before shutdown stops producers or the
886 // worker. StopDurableWriters reports every session whose final capture still
887 // owns work when the shared checkpoint budget expires. Persist one notice per
888 // affected session, then retain ownership until every writer actually exits.
889 stopCtx, stopCancel := snapshotStopContext(snapshotDeadline)
890 timedOutSessions := d.StopDurableWriters(stopCtx)
891 stopCancel()
892 for _, name := range timedOutSessions {
893 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
894 }
895 d.WaitDurableWriters()
896 d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
897 d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
898 d.waitNotifies()
899 if err := d.flushCatalogue(); err != nil {
900 d.log.Warn("flushing session catalogue at shutdown failed", "err", err)
901 }
902 if d.catalogue != nil {
903 if err := d.catalogue.Close(); err != nil {
904 d.log.Warn("closing session catalogue failed", "err", err)
905 }
906 }
907 return nil
908}
909
910// shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is
911// the only caller that irreversibly ends the daemon: it closes move admission,
912// marks closing, reserves shutdown preservation for every live session, then
913// cancels the daemon-wide pane process context, preserves every live session as
914// stopped durable authority, and closes done so Serve returns.
915// KillAll never reaches this path. Setting closing under the same lock as the
916// snapshot guarantees no session can be inserted after the snapshot: route
917// rejects once closing is set, and both run under d.mu. killSession (which
918// relocks) runs after the lock is released.
919func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
920 return d.terminateAllForShutdown(reason, nil)
921}
922
923// terminateAllForShutdown performs the irreversible daemon teardown shared by
924// shutdownAll and Serve's exit path. It is separate from purgeAllSessions,
925// which leaves every daemon-wide shutdown fact untouched.
926func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
927 // Stop background GC first so it releases the recovery fence before final
928 // checkpoints are published under the shutdown deadline.
929 d.cancelDurableMaintenance()
930 d.closeMoveLifecycles()
931 d.mu.Lock()
932 d.closing = true
933 // Wake any KillAll waiting on purge admission so it defers to this stop
934 // instead of holding the gate until its bounded drain deadline expires.
935 d.signalPurgeAdmissionChangedLocked()
936 d.purgeAllParkingLocked()
937 parkedRetirements := d.purgeAllParkedLocked()
938 d.purgeAllSuspendedLocked()
939 snapshot := d.sessionsSnapshotLocked()
940 d.mu.Unlock()
941 d.finishParkedAttachmentRetirements(parkedRetirements)
942 // Publish preservation policy for the complete registry snapshot before
943 // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
944 // but its disposition must remain daemon shutdown rather than session purge.
945 // closeMoveLifecycles therefore leaves pane processes running; only this
946 // ordering, reserve then cancel, keeps a named session durable.
947 for _, s := range snapshot {
948 s.reserveShutdownTeardown()
949 }
950 d.cancelPaneProcesses()
951 d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
952 for _, s := range snapshot {
953 // Cancellation and PTY closure must not wait behind a teardown owner that
954 // is blocked in snapshot publication or purge work.
955 s.mu.Lock()
956 name := s.name
957 ephemeral := s.ephemeral
958 s.mu.Unlock()
959 s.stopInMemoryLifecycle()
960 if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
961 checkpointIncomplete = true
962 d.log.Error("closing session with unpersisted terminal state", "err", err)
963 }
964 if !ephemeral && deadline != nil {
965 select {
966 case <-deadline.Done():
967 checkpointIncomplete = true
968 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
969 default:
970 }
971 }
972 }
973 // Signal Serve even when a concurrent role transition made this initial
974 // pass abort; Serve owns the bounded defensive teardown passes.
975 d.doneOnce.Do(func() { close(d.done) })
976 return checkpointIncomplete
977}
978
979// waitSessionWorkersWithSnapshotDeadline joins in-memory workers when possible,
980// but never extends Serve beyond the shared repository shutdown budget. By this
981// point connection handlers are joined and every session has been cancelled, so
982// no new session worker can be registered while the waiter is active.
983func (d *Daemon) waitSessionWorkersWithSnapshotDeadline(deadline *snapshotShutdownDeadline) {
984 done := make(chan struct{})
985 go func() {
986 d.sessWg.Wait()
987 close(done)
988 }()
989 if deadline == nil {
990 <-done
991 return
992 }
993 select {
994 case <-done:
995 case <-deadline.Done():
996 }
997}
998
999// snapshotShutdownDeadline is a single-use shutdown budget shared by forced
1000// checkpoints and the final snapshot worker join. Its done channel is never
1001// closed by callers, so a detached worker can safely retain session state until
1002// an uncooperative repository call returns.
1003type snapshotShutdownDeadline struct {
1004 done chan struct{}
1005 stopCh chan struct{}
1006 finished chan struct{}
1007 stopOnce sync.Once
1008}
1009
1010func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline {
1011 deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})}
1012 timer := clock.NewTimer(snapshotFinalFlushTimeout)
1013 go func() {
1014 defer close(deadline.finished)
1015 select {
1016 case <-timer.C():
1017 close(deadline.done)
1018 case <-deadline.stopCh:
1019 timer.Stop()
1020 }
1021 }()
1022 return deadline
1023}
1024
1025func (d *snapshotShutdownDeadline) stop() {
1026 if d != nil {
1027 d.stopOnce.Do(func() {
1028 close(d.stopCh)
1029 <-d.finished
1030 })
1031 }
1032}
1033
1034func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
1035 if d == nil {
1036 return nil
1037 }
1038 return d.done
1039}
1040
1041func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, context.CancelFunc) {
1042 ctx, cancel := context.WithCancel(context.Background())
1043 if deadline == nil {
1044 return ctx, cancel
1045 }
1046 select {
1047 case <-deadline.Done():
1048 cancel()
1049 return ctx, cancel
1050 default:
1051 }
1052 stop := make(chan struct{})
1053 go func() {
1054 select {
1055 case <-deadline.Done():
1056 cancel()
1057 case <-stop:
1058 }
1059 }()
1060 var stopOnce sync.Once
1061 return ctx, func() {
1062 stopOnce.Do(func() { close(stop) })
1063 cancel()
1064 }
1065}
1066
1067// registerSessionLocked publishes an exact attachment-session identity. Caller
1068// holds d.mu.
1069func (d *Daemon) registerSessionLocked(entry *session) bool {
1070 if entry == nil {
1071 return false
1072 }
1073 core := entry.core()
1074 if core == nil || core.id == "" || d.sessions[core.id] != nil {
1075 return false
1076 }
1077 d.sessions[core.id] = entry
1078 return true
1079}
1080
1081// unregisterSessionLocked removes only the exact registered identity. Caller
1082// holds d.mu.
1083func (d *Daemon) unregisterSessionLocked(entry *session) bool {
1084 if entry == nil {
1085 return false
1086 }
1087 core := entry.core()
1088 if core == nil || d.sessions[core.id] != entry {
1089 return false
1090 }
1091 delete(d.sessions, core.id)
1092 return true
1093}
1094
1095func (d *Daemon) sessionsSnapshotLocked() []*session {
1096 return sessionsSnapshot(d.sessions)
1097}
1098
1099// handleConn reads the first typed message off a fresh connection and routes it.
1100// A context watcher closes the connection on cancellation or handshake timeout.
1101func (d *Daemon) handleConn(tr ports.ServerConnection) {
1102 handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx, acceptedHandshakeDeadline(tr))
1103 stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1104 defer finishHandshake()
1105 defer stopTransport()
1106
1107 var first protocol.ClientMessage
1108 err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1109 var receiveErr error
1110 first, receiveErr = tr.ReceiveClient()
1111 return receiveErr
1112 })
1113 if err != nil {
1114 var failure *protocol.DecodeFailure
1115 if errors.As(err, &failure) {
1116 d.handleInitialDecodeFailure(handshakeCtx, tr, failure)
1117 } else {
1118 err = handshakeContextError(d.hardCtx, timedOut, err)
1119 d.log.Warn("connection closed before hello", "err", err)
1120 }
1121 _ = tr.Close()
1122 return
1123 }
1124 switch message := first.(type) {
1125 case protocol.List:
1126 stopTransport()
1127 finishHandshake()
1128 d.handleList(tr)
1129 case protocol.CommandRequest:
1130 stopTransport()
1131 finishHandshake()
1132 if err := d.handleCommand(tr, message); err != nil {
1133 d.log.Warn("command handler failed", "err", err)
1134 }
1135 case protocol.RemotePreviewWatch:
1136 stopTransport()
1137 finishHandshake()
1138 d.serveRemotePreviewWatch(tr, message)
1139 case protocol.NavigationInventoryRequest:
1140 stopTransport()
1141 finishHandshake()
1142 d.handleNavigationInventory(tr, message)
1143 case protocol.Kill:
1144 stopTransport()
1145 finishHandshake()
1146 d.handleKill(tr, message)
1147 case protocol.Hello:
1148 d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, message)
1149 default:
1150 d.log.Warn("hello rejected", "err", "expected hello")
1151 _ = boundedHandshakeOperation(handshakeCtx, tr, func() error {
1152 return tr.SendServer(serverError(protocol.ErrInternal, "expected hello"))
1153 })
1154 stopTransport()
1155 finishHandshake()
1156 _ = tr.Close()
1157 }
1158}
1159
1160func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.ServerConnection, failure *protocol.DecodeFailure) {
1161 send := func(message protocol.ServerMessage) {
1162 _ = boundedHandshakeOperation(ctx, tr, func() error { return tr.SendServer(message) })
1163 _ = tr.Close()
1164 }
1165 switch failure.Kind {
1166 case protocol.DecodeMessageHello:
1167 if failure.Version != 0 && failure.Version != protocol.Version {
1168 send(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1169 return
1170 }
1171 send(serverError(protocol.ErrInternal, "malformed hello"))
1172 case protocol.DecodeMessageCommand:
1173 if !failure.HasRequestID {
1174 return
1175 }
1176 code, text := protocol.ErrInternal, "malformed command request"
1177 if failure.Version != 0 && failure.Version != protocol.Version {
1178 code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
1179 }
1180 send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
1181 case protocol.DecodeMessageKill:
1182 send(serverError(protocol.ErrInternal, "malformed kill request"))
1183 case protocol.DecodeMessageRemotePreview:
1184 send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
1185 case protocol.DecodeMessageNavigationInventory:
1186 if !failure.HasRequestID {
1187 return
1188 }
1189 status := protocol.NavigationInventoryInvalid
1190 if failure.Version != 0 && failure.Version != protocol.Version {
1191 status = protocol.NavigationInventoryVersionMismatch
1192 }
1193 send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status})
1194 default:
1195 send(serverError(protocol.ErrInternal, "expected hello"))
1196 }
1197}
1198
1199// handleList replies with the current session listing and closes the (one-shot
1200// control) connection.
1201func (d *Daemon) handleList(tr ports.ServerConnection) {
1202 defer func() { _ = tr.Close() }()
1203
1204 d.mu.Lock()
1205 infos := make([]protocol.SessionInfo, 0, len(d.sessions)+len(d.inactive))
1206 liveNames := make(map[string]struct{}, len(d.sessions))
1207 for _, s := range d.sessions {
1208 if s == nil {
1209 continue
1210 }
1211 s.mu.Lock()
1212 info := protocol.SessionInfo{
1213 SessionID: string(s.id),
1214 Name: s.name,
1215 State: protocol.SessionUp,
1216 Ephemeral: s.ephemeral,
1217 Tabs: uint16(len(s.tabs)),
1218 // Attached means registered interactive presence, not mere transport
1219 // membership: a suspended-only session is headless to observers.
1220 Attached: sessionInteractivelyAttachedLocked(s),
1221 }
1222 liveNames[s.name] = struct{}{}
1223 s.mu.Unlock()
1224 infos = append(infos, info)
1225 }
1226 for name, inactive := range d.inactive {
1227 if _, live := liveNames[name]; live || !inactive.visible() {
1228 continue
1229 }
1230 state := protocol.SessionDown
1231 if inactive.broken() {
1232 state = protocol.SessionBroken
1233 }
1234 infos = append(infos, protocol.SessionInfo{Name: name, State: state})
1235 }
1236 d.mu.Unlock()
1237
1238 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
1239 _ = d.boundedControlSend(tr, serverSessions(infos))
1240}
1241
1242// handleKill terminates the requested live session or stopped named session,
1243// purges every session (KillAll), or accepts daemon shutdown (KillDaemon).
1244// Every normally decoded request receives exactly one correlated result before
1245// the control connection closes. KillAll leaves the daemon active.
1246func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
1247 defer func() { _ = tr.Close() }()
1248 send := func(result protocol.KillResult) {
1249 result.RequestID = request.RequestID
1250 d.logKillResultSendFailure(result, d.boundedControlSend(tr, result))
1251 }
1252
1253 switch request.Scope {
1254 case protocol.KillAll:
1255 result := d.purgeAllSessions(protocol.ReasonSessionKilled)
1256 if result.Superseded() {
1257 send(protocol.KillResult{Outcome: protocol.KillOutcomeUnknown, Code: protocol.ErrServerShutdown, Text: "daemon is shutting down"})
1258 return
1259 }
1260 if result.Failed() {
1261 failures := make([]protocol.KillFailure, 0, min(len(result.Failures), purgeSummaryFailureLimit))
1262 for _, failure := range result.Failures[:min(len(result.Failures), purgeSummaryFailureLimit)] {
1263 failures = append(failures, protocol.KillFailure{Class: failure.Class.String(), Name: failure.Name, Text: failure.Err.Error()})
1264 }
1265 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: result.summary(), Failures: failures})
1266 return
1267 }
1268 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1269 return
1270 case protocol.KillDaemon:
1271 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1272 d.shutdownAll(protocol.ReasonServerShutdown)
1273 return
1274 case protocol.KillSession:
1275 default:
1276 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "invalid kill scope"})
1277 return
1278 }
1279
1280 d.mu.Lock()
1281 target := d.findByNameLocked(request.Name)
1282 if target == nil {
1283 if _, ok := d.inactive[request.Name]; ok {
1284 d.mu.Unlock()
1285 // Stopped sessions use the same catalogue-first, incarnation-second
1286 // deletion order as live and offline purges.
1287 if err := d.retryStoppedPurge(request.Name); err != nil {
1288 d.log.Warn("deleting stopped session failed", "err", err, "session", request.Name)
1289 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting stopped session failed"})
1290 return
1291 }
1292 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1293 return
1294 }
1295 }
1296 d.mu.Unlock()
1297
1298 if target == nil {
1299 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: " + request.Name})
1300 return
1301 }
1302 if err := d.killSession(target, protocol.ReasonSessionKilled, true); err != nil {
1303 if errors.Is(err, errSessionKillParticipantsChanged) {
1304 // The exact session was not removed; a concurrent detach or token
1305 // resume invalidated the teardown snapshot. Report the retryable
1306 // outcome instead of a permanent delete failure.
1307 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: sessionKillRetryMessage})
1308 return
1309 }
1310 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting persisted session failed"})
1311 return
1312 }
1313 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1314}
1315
1316// handleHello runs the attach handshake for direct package callers. Accepted
1317// connections use handleConn, which resolves the optional accepted deadline so
1318// one budget also covers the first frame read; handleHello adopts the same seam
1319// when the connection exposes it. A connection without the seam keeps the
1320// ordinary fresh protocol.HandshakeTimeout budget.
1321func (d *Daemon) handleHello(tr ports.ServerConnection, hello protocol.Hello) {
1322 handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(context.Background(), acceptedHandshakeDeadline(tr))
1323 stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1324 defer finishHandshake()
1325 defer stopTransport()
1326 d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, hello)
1327}
1328
1329// handleHelloWithContext runs the attach handshake: version check, intent
1330// routing, Welcome, guaranteed first paint, then the per-connection input loop.
1331func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.ServerConnection, h protocol.Hello) {
1332 if err := handshakeContextError(d.hardCtx, timedOut, handshakeCtx.Err()); err != nil {
1333 _ = tr.Close()
1334 return
1335 }
1336 sendHandshake := func(message protocol.ServerMessage) error {
1337 err := boundedHandshakeOperation(handshakeCtx, tr, func() error { return tr.SendServer(message) })
1338 if err != nil {
1339 return handshakeContextError(d.hardCtx, timedOut, err)
1340 }
1341 return nil
1342 }
1343 if h.Version != protocol.Version {
1344 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", protocol.Version, "intent", h.Intent, "session", h.Name)
1345 _ = sendHandshake(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1346 _ = tr.Close()
1347 return
1348 }
1349
1350 sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1351 if rerr != nil {
1352 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1353 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1354 _ = sendHandshake(serverError(pe.code, pe.text))
1355 } else {
1356 _ = sendHandshake(serverError(protocol.ErrInternal, rerr.Error()))
1357 }
1358 _ = tr.Close()
1359 return
1360 }
1361
1362 welcomeSent := false
1363 //nolint:contextcheck // handshakeCtx is intentionally not propagated: cancellation teardown must complete its geometry and ownership cleanup.
1364 failAttachment := func() {
1365 d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1366 }
1367 if err := handshakeCtx.Err(); err != nil {
1368 failAttachment()
1369 return
1370 }
1371 expected := ac.transportSnapshot()
1372 welcomeToken := sess.captureAttachmentCapability(ac, tr)
1373 welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1374 if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1375 if admitted {
1376 welcomeTicket.End()
1377 }
1378 failAttachment()
1379 return
1380 }
1381 if err := handshakeCtx.Err(); err != nil {
1382 welcomeTicket.End()
1383 failAttachment()
1384 return
1385 }
1386 welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1387 frame, err := serverWelcome(sess, d.resumeTokenSnapshot(ac))
1388 if err != nil {
1389 return err
1390 }
1391 return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1392 })
1393 if welcomeErr != nil {
1394 <-welcomeDone
1395 welcomeTicket.End()
1396 failAttachment()
1397 return
1398 }
1399 welcomeSent = true
1400 // Release Welcome's effect before discovering post-handshake authority so a
1401 // replacement blocked behind the send can publish its generation and lease.
1402 welcomeTicket.End()
1403 postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1404 if !admitted {
1405 failAttachment()
1406 return
1407 }
1408 postWelcomeLease := postWelcomeToken.lease
1409 if postWelcomeToken.ac == nil {
1410 postWelcomeTicket.End()
1411 failAttachment()
1412 return
1413 }
1414 postWelcomeRC := sess.renderCoordinator()
1415 if postWelcomeLease != nil && (postWelcomeRC == nil || !postWelcomeRC.markAttachmentReady(postWelcomeLease)) {
1416 postWelcomeTicket.End()
1417 // The attachment was detached while Welcome was in flight; never let
1418 // this stale handshake emit an Output frame.
1419 failAttachment()
1420 return
1421 }
1422 paintToken := sess.captureAttachmentCapability(ac, tr)
1423 painted := make(chan bool, 1)
1424 paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1425 //nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
1426 painted <- d.firstPaintForTransition(paintToken)
1427 return nil
1428 })
1429 if paintErr != nil {
1430 <-paintDone
1431 postWelcomeTicket.End()
1432 failAttachment()
1433 return
1434 }
1435 if !<-painted || handshakeCtx.Err() != nil {
1436 postWelcomeTicket.End()
1437 failAttachment()
1438 return
1439 }
1440 if !d.commitResumeClaim(ac) {
1441 postWelcomeTicket.End()
1442 failAttachment()
1443 return
1444 }
1445 postWelcomeTicket.End()
1446 stopHandshakeTransport()
1447 finishHandshake()
1448 d.runConnLoop(ac)
1449 _ = tr.Close()
1450}
1451
1452// protoErr is a session-level rejection carrying a wire ErrorMsg code.
1453type protoErr struct {
1454 code uint16
1455 text string
1456}
1457
1458func (e *protoErr) Error() string { return e.text }
1459
1460// finishAttach completes an attachment prepared while d.mu is held. Every
1461// caller must hold d.mu on entry; finishAttach transfers ownership by
1462// unlocking d.mu before returning, including every error path. It publishes
1463// terminal and role state before releasing d.mu, then defers coordinator
1464// cleanup so obsolete workers never delay the new handshake.
1465func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello) (*attachedClient, error) {
1466 initialTabIndex := -1
1467 if h.SessionTarget != nil {
1468 var ok bool
1469 initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.SessionTarget)
1470 if !ok {
1471 if tr != nil {
1472 _ = tr.Close()
1473 }
1474 d.mu.Unlock()
1475 return nil, &protoErr{protocol.ErrNoSuchTarget, "remote tab no longer exists"}
1476 }
1477 }
1478 if h.PreferredTabID != "" {
1479 // Route memory is a best-effort attachment cursor, not attach authority.
1480 // It overrides point-in-time picker tab metadata when still present and
1481 // otherwise falls back to the session's normal first-tab repair.
1482 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1483 }
1484 terminalCapabilities := terminalcap.Detect(h.Env)
1485 // Kitty graphics are enabled only by the explicit direct-terminal
1486 // declaration in Hello. Environment values remain useful for color and
1487 // diagnostics, but cannot authorize terminal-global graphics side effects.
1488 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1489 if h.TrueColor && !terminalCapabilities.TrueColor() {
1490 terminalCapabilities.ColorMode = terminalcap.TrueColor
1491 terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1492 }
1493 opts := attachClientOptions{
1494 clientID: h.ClientID,
1495 kittyKeyboard: h.KittyKeyboard,
1496 resumeCapable: true,
1497 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1498 navigationCapabilities: h.NavigationCapabilities,
1499 terminalFocus: h.TerminalFocus,
1500 clientPID: h.ClientPID,
1501 clientEnv: helloClientEnvironment(h),
1502 terminalCapabilities: terminalCapabilities,
1503 capabilitiesSet: true,
1504 }
1505 geometry := h.Geometry()
1506 if geometry.Size != sz {
1507 geometry = domain.Geometry{Size: sz}
1508 }
1509 ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
1510 d.mu.Unlock()
1511 result, err := d.transitionAttachment(attachmentTransitionRequest{
1512 target: sess,
1513 next: ac,
1514 expectedTransport: ac.transportSnapshot(),
1515 activateTargetTab: initialTabIndex >= 0,
1516 targetTabIndex: initialTabIndex,
1517 ready: false,
1518 })
1519 if err != nil {
1520 if ac.output != nil && ac.output.graphicsOutput != nil {
1521 d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1522 }
1523 if tr != nil {
1524 _ = tr.Close()
1525 }
1526 return nil, err
1527 }
1528 d.finishAttachedClient(sess, ac, opts)
1529 d.deferAttachmentTransitionCleanups(result)
1530 return ac, nil
1531}
1532
1533func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1534 if sess == nil || preferred == "" {
1535 return -1
1536 }
1537 sess.mu.Lock()
1538 defer sess.mu.Unlock()
1539 for i, candidate := range sess.tabs {
1540 if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1541 return i
1542 }
1543 }
1544 return -1
1545}
1546
1547func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1548 d.mu.Lock()
1549 var (
1550 done chan struct{}
1551 stopped inactiveSession
1552 ok bool
1553 )
1554 if sess := d.findByNameLocked(name); sess != nil {
1555 sess.mu.Lock()
1556 done = sess.restoreDone
1557 sess.mu.Unlock()
1558 } else {
1559 stopped, ok = d.inactive[name]
1560 if !ok || !stopped.visible() {
1561 d.mu.Unlock()
1562 return nil
1563 }
1564 if stopped.restorePending() {
1565 done = stopped.restoreDone
1566 }
1567 }
1568 d.mu.Unlock()
1569
1570 if done != nil {
1571 select {
1572 case <-done:
1573 case <-ctx.Done():
1574 return ctx.Err()
1575 }
1576 }
1577
1578 d.mu.Lock()
1579 defer d.mu.Unlock()
1580 if d.findByNameLocked(name) != nil {
1581 return nil
1582 }
1583 stopped, ok = d.inactive[name]
1584 if !ok || stopped.record.Name == "" {
1585 return nil
1586 }
1587 if stopped.broken() {
1588 return &protoErr{protocol.ErrInternal, "session durable state is broken: " + name}
1589 }
1590 if stopped.record.Committed == nil {
1591 return nil
1592 }
1593 return &protoErr{protocol.ErrInternal, "session was not restored into this daemon: " + name}
1594}
1595
1596// route resolves a Hello to a session and a freshly attached client, creating
1597// the session for ephemeral/new intents. Direct package callers retain the
1598// daemon lifetime context; inbound handshakes use routeWithContext.
1599func (d *Daemon) validateExactSessionTargetLocked(target protocol.ExactSessionTarget) error {
1600 if sess := d.findByNameLocked(target.SessionName); sess != nil {
1601 if sess.incarnation != target.LifecycleID {
1602 return &protoErr{protocol.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1603 }
1604 return nil
1605 }
1606 stopped, ok := d.inactive[target.SessionName]
1607 if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
1608 return &protoErr{protocol.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1609 }
1610 return nil
1611}
1612
1613func (d *Daemon) validateExactSessionTarget(ctx context.Context, target protocol.ExactSessionTarget) error {
1614 if err := target.Validate(); err != nil {
1615 return &protoErr{protocol.ErrNoSuchSession, "invalid exact session target"}
1616 }
1617 if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1618 return err
1619 }
1620
1621 d.mu.Lock()
1622 defer d.mu.Unlock()
1623 return d.validateExactSessionTargetLocked(target)
1624}
1625
1626func (d *Daemon) route(h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1627 ctx := d.serveCtx
1628 if ctx == nil {
1629 ctx = context.Background()
1630 }
1631 return d.routeWithContext(ctx, h, tr)
1632}
1633
1634func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1635 if ctx == nil {
1636 ctx = context.Background()
1637 }
1638 if err := ctx.Err(); err != nil {
1639 return nil, nil, err
1640 }
1641 sz := h.Size
1642 if !sz.Valid() {
1643 return nil, nil, &protoErr{protocol.ErrInternal, "invalid terminal size"}
1644 }
1645 // The accepting side's provisioned admission, when present, is the authority
1646 // this Hello is validated against before any restore, creation, resume
1647 // claim, ownership change, or environment mutation. A connection without
1648 // that seam receives the standard Hello validation and gains no admission
1649 // exception, so a forged Hello can never widen what it is allowed to do.
1650 admission, admitted := sessionHelloAdmission(tr)
1651 if admitted {
1652 if err := validateSessionHelloAdmission(h, admission); err != nil {
1653 return nil, nil, err
1654 }
1655 if admission.Admission == ports.BrokerAdmissionExact && h.ResumeToken != 0 {
1656 if err := d.validateResumeAdmittedTarget(h.ResumeToken, admission.Target); err != nil {
1657 return nil, nil, err
1658 }
1659 }
1660 }
1661 if h.Intent == protocol.IntentResume && h.ResumeToken == 0 {
1662 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is required"}
1663 }
1664 if h.ExactTarget != nil && h.ResumeToken == 0 && h.Name != h.ExactTarget.SessionName {
1665 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "exact session target name mismatch"}
1666 }
1667 // A remote exact-target handoff is an admitted transition like a local
1668 // create/restore: it reserves purge admission before dispatch, so a Hello
1669 // arriving during a KillAll waits on the gate exactly as a local route does
1670 // instead of being rejected outright.
1671 if h.SessionTarget != nil && h.ResumeToken == 0 {
1672 if err := d.acquirePurgeAdmission(ctx); err != nil {
1673 return nil, nil, err
1674 }
1675 defer d.releasePurgeAdmission()
1676 return d.routeRemoteTargetWithContext(ctx, h, tr)
1677 }
1678 if h.ExactTarget != nil && h.ResumeToken == 0 {
1679 if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1680 return nil, nil, err
1681 }
1682 }
1683
1684 // A non-zero token is an authoritative resume credential. If it is unknown,
1685 // expired, or raced with lifecycle teardown, fail closed instead of routing
1686 // the Hello as an ordinary attach that could create or replace ownership.
1687 // The one legitimate pre-park race is a still-active same-client credential
1688 // for the requested session; hand that into the park/resume lifecycle.
1689 if h.ResumeToken != 0 {
1690 d.mu.Lock()
1691 parkedAtStart := d.resume.parkedFor(h.ResumeToken)
1692 d.mu.Unlock()
1693 if parkedAtStart == nil {
1694 if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1695 if errors.Is(err, errResumeTokenLifecycleRace) {
1696 // Live recovery parked then lost a competing resumeParked
1697 // race; keep the fail-closed wire response instead of
1698 // leaking the internal sentinel to handleHello as ErrInternal.
1699 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1700 }
1701 return nil, nil, err
1702 } else if ok {
1703 return sess, ac, nil
1704 }
1705 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1706 }
1707 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1708 if ok {
1709 return sess, ac, nil
1710 }
1711 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1712 } else if errors.Is(err, errResumeTokenLifecycleRace) {
1713 // The parked entry was replaced while this handshake waited for
1714 // its send lock. Fail closed on the original credential rather
1715 // than falling through to ordinary attach/create routing.
1716 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1717 } else {
1718 return nil, nil, err
1719 }
1720 }
1721 if h.Intent == protocol.IntentResume || h.Intent == protocol.IntentAttach {
1722 if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1723 return nil, nil, err
1724 }
1725 }
1726 // A daemon-owned ephemeral Hello without an exact remote target is refused by the daemon. An authenticated remote admission that names the
1727 // creation is the one exception, and it is granted only under a validated
1728 // remote admission; a connection without one never gains it.
1729 if !admitted && h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned && h.SessionTarget == nil &&
1730 h.Intent == protocol.IntentEphemeral {
1731 return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1732 }
1733 // KillAll admission gate: a purge owns the exact lifecycle set it captured,
1734 // so a Hello arriving during it waits here and is then admitted as a create
1735 // or restore after the purge, never inserted into the set being removed.
1736 // This is transient and re-opened by the purge; it is not daemon closing.
1737 if err := d.acquirePurgeAdmission(ctx); err != nil {
1738 return nil, nil, err
1739 }
1740 defer d.releasePurgeAdmission()
1741 d.mu.Lock()
1742 // Shutdown/create interlock: once an explicit shutdown has begun
1743 // (shutdownAll started) no new session may be created and no attach may
1744 // proceed — the teardown snapshot has already been (or is being) taken, so
1745 // anything inserted now would leak its PTY and hang Serve. An empty registry
1746 // alone is not shutdown, and a KillAll purge is gated by admission above.
1747 if d.closing {
1748 d.mu.Unlock()
1749 return nil, nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
1750 }
1751 // The first exact-target check happens before restore I/O. Recheck while
1752 // holding d.mu so a same-name lifecycle replacement cannot slip between
1753 // validation and attachment creation.
1754 if h.ExactTarget != nil && h.ResumeToken == 0 {
1755 if err := d.validateExactSessionTargetLocked(*h.ExactTarget); err != nil {
1756 d.mu.Unlock()
1757 return nil, nil, err
1758 }
1759 }
1760 switch h.Intent {
1761 case protocol.IntentEphemeral:
1762 name := d.allocEphemeralNameLocked()
1763 // A daemon-owned creation (an authenticated remote admission) uses the
1764 // daemon's own environment and home rather than any client value; a
1765 // client-owned creation (local) uses the admitted client environment and
1766 // working directory. The Hello fields are already validated to match the
1767 // admission, so this mirrors the named-creation ownership rule below.
1768 cwd, env := h.Cwd, h.Env
1769 if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1770 cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1771 }
1772 sess, err := d.createSessionLockedWithMode(name, true, cwd, h.Geometry(), env)
1773 if err != nil {
1774 d.mu.Unlock()
1775 return nil, nil, err
1776 }
1777 ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1778 return sess, ac, err
1779
1780 case protocol.IntentNew:
1781 if h.Name == "" {
1782 d.mu.Unlock()
1783 return nil, nil, &protoErr{protocol.ErrInvalidSessionName, "empty session name"}
1784 }
1785 if err := domain.ValidateSessionName(h.Name); err != nil {
1786 d.mu.Unlock()
1787 return nil, nil, &protoErr{protocol.ErrInvalidSessionName, err.Error()}
1788 }
1789 if d.nameLiveOrStoppedLocked(h.Name) {
1790 d.mu.Unlock()
1791 return nil, nil, &protoErr{protocol.ErrNameTaken, "session name already in use: " + h.Name}
1792 }
1793 cwd, env := h.Cwd, h.Env
1794 if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1795 // A remote CNS destination owns shell startup just like a picker
1796 // attach. Client paths may exist here but belong to another user.
1797 cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1798 }
1799 sess, err := d.createSessionLockedWithMode(h.Name, false, cwd, h.Geometry(), env)
1800 if err != nil {
1801 d.mu.Unlock()
1802 return nil, nil, err
1803 }
1804 ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1805 return sess, ac, err
1806
1807 case protocol.IntentAttach:
1808 sess := d.findByNameLocked(h.Name)
1809 created := false
1810 if sess == nil {
1811 stopped, ok := d.inactive[h.Name]
1812 if !ok || !stopped.canResume() {
1813 d.mu.Unlock()
1814 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "no such resumable session: " + h.Name}
1815 }
1816 cwd := d.dirOrHome(stopped.cwd)
1817 env := h.Env
1818 daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1819 if daemonOwned {
1820 env = copyEnvironment(d.baseEnv)
1821 }
1822 var err error
1823 sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
1824 if err != nil {
1825 d.mu.Unlock()
1826 return nil, nil, err
1827 }
1828 if daemonOwned {
1829 // Like a snapshot restore, the resumed session starts from the
1830 // daemon's environment until a local client attaches.
1831 markEnvironmentProvisional(sess)
1832 }
1833 created = true
1834 }
1835 ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
1836 return sess, ac, err
1837
1838 default:
1839 d.mu.Unlock()
1840 return nil, nil, &protoErr{protocol.ErrInternal, "unknown intent"}
1841 }
1842}