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