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, cancels the daemon-wide pane process context, preserves every
913// live session as stopped durable authority, and closes done so Serve returns.
914// KillAll never reaches this path. Setting closing under the same lock as the
915// snapshot guarantees no session can be inserted after the snapshot: route
916// rejects once closing is set, and both run under d.mu. killSession (which
917// relocks) runs after the lock is released.
918func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
919 return d.terminateAllForShutdown(reason, nil)
920}
921
922// terminateAllForShutdown performs the irreversible daemon teardown shared by
923// shutdownAll and Serve's exit path. It is separate from purgeAllSessions,
924// which leaves every daemon-wide shutdown fact untouched.
925func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
926 // Stop background GC first so it releases the recovery fence before final
927 // checkpoints are published under the shutdown deadline.
928 d.cancelDurableMaintenance()
929 d.closeMoveLifecycles()
930 d.mu.Lock()
931 d.closing = true
932 // Wake any KillAll waiting on purge admission so it defers to this stop
933 // instead of holding the gate until its bounded drain deadline expires.
934 d.signalPurgeAdmissionChangedLocked()
935 d.purgeAllParkingLocked()
936 parkedRetirements := d.purgeAllParkedLocked()
937 d.purgeAllSuspendedLocked()
938 snapshot := d.sessionsSnapshotLocked()
939 d.mu.Unlock()
940 d.finishParkedAttachmentRetirements(parkedRetirements)
941 // Publish preservation policy for the complete registry snapshot before
942 // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
943 // but its disposition must remain daemon shutdown rather than session purge.
944 for _, s := range snapshot {
945 s.reserveShutdownTeardown()
946 }
947 d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
948 for _, s := range snapshot {
949 // Cancellation and PTY closure must not wait behind a teardown owner that
950 // is blocked in snapshot publication or purge work.
951 s.mu.Lock()
952 name := s.name
953 ephemeral := s.ephemeral
954 s.mu.Unlock()
955 s.stopInMemoryLifecycle()
956 if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
957 checkpointIncomplete = true
958 d.log.Error("closing session with unpersisted terminal state", "err", err)
959 }
960 if !ephemeral && deadline != nil {
961 select {
962 case <-deadline.Done():
963 checkpointIncomplete = true
964 d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
965 default:
966 }
967 }
968 }
969 // Signal Serve even when a concurrent role transition made this initial
970 // pass abort; Serve owns the bounded defensive teardown passes.
971 d.doneOnce.Do(func() { close(d.done) })
972 return checkpointIncomplete
973}
974
975// waitSessionWorkersWithSnapshotDeadline joins in-memory workers when possible,
976// but never extends Serve beyond the shared repository shutdown budget. By this
977// point connection handlers are joined and every session has been cancelled, so
978// no new session worker can be registered while the waiter is active.
979func (d *Daemon) waitSessionWorkersWithSnapshotDeadline(deadline *snapshotShutdownDeadline) {
980 done := make(chan struct{})
981 go func() {
982 d.sessWg.Wait()
983 close(done)
984 }()
985 if deadline == nil {
986 <-done
987 return
988 }
989 select {
990 case <-done:
991 case <-deadline.Done():
992 }
993}
994
995// snapshotShutdownDeadline is a single-use shutdown budget shared by forced
996// checkpoints and the final snapshot worker join. Its done channel is never
997// closed by callers, so a detached worker can safely retain session state until
998// an uncooperative repository call returns.
999type snapshotShutdownDeadline struct {
1000 done chan struct{}
1001 stopCh chan struct{}
1002 finished chan struct{}
1003 stopOnce sync.Once
1004}
1005
1006func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline {
1007 deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})}
1008 timer := clock.NewTimer(snapshotFinalFlushTimeout)
1009 go func() {
1010 defer close(deadline.finished)
1011 select {
1012 case <-timer.C():
1013 close(deadline.done)
1014 case <-deadline.stopCh:
1015 timer.Stop()
1016 }
1017 }()
1018 return deadline
1019}
1020
1021func (d *snapshotShutdownDeadline) stop() {
1022 if d != nil {
1023 d.stopOnce.Do(func() {
1024 close(d.stopCh)
1025 <-d.finished
1026 })
1027 }
1028}
1029
1030func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
1031 if d == nil {
1032 return nil
1033 }
1034 return d.done
1035}
1036
1037func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, context.CancelFunc) {
1038 ctx, cancel := context.WithCancel(context.Background())
1039 if deadline == nil {
1040 return ctx, cancel
1041 }
1042 select {
1043 case <-deadline.Done():
1044 cancel()
1045 return ctx, cancel
1046 default:
1047 }
1048 stop := make(chan struct{})
1049 go func() {
1050 select {
1051 case <-deadline.Done():
1052 cancel()
1053 case <-stop:
1054 }
1055 }()
1056 var stopOnce sync.Once
1057 return ctx, func() {
1058 stopOnce.Do(func() { close(stop) })
1059 cancel()
1060 }
1061}
1062
1063// registerSessionLocked publishes an exact attachment-session identity. Caller
1064// holds d.mu.
1065func (d *Daemon) registerSessionLocked(entry *session) bool {
1066 if entry == nil {
1067 return false
1068 }
1069 core := entry.core()
1070 if core == nil || core.id == "" || d.sessions[core.id] != nil {
1071 return false
1072 }
1073 d.sessions[core.id] = entry
1074 return true
1075}
1076
1077// unregisterSessionLocked removes only the exact registered identity. Caller
1078// holds d.mu.
1079func (d *Daemon) unregisterSessionLocked(entry *session) bool {
1080 if entry == nil {
1081 return false
1082 }
1083 core := entry.core()
1084 if core == nil || d.sessions[core.id] != entry {
1085 return false
1086 }
1087 delete(d.sessions, core.id)
1088 return true
1089}
1090
1091func (d *Daemon) sessionsSnapshotLocked() []*session {
1092 return sessionsSnapshot(d.sessions)
1093}
1094
1095// handleConn reads the first typed message off a fresh connection and routes it.
1096// A context watcher closes the connection on cancellation or handshake timeout.
1097func (d *Daemon) handleConn(tr ports.ServerConnection) {
1098 handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx, acceptedHandshakeDeadline(tr))
1099 stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1100 defer finishHandshake()
1101 defer stopTransport()
1102
1103 var first protocol.ClientMessage
1104 err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1105 var receiveErr error
1106 first, receiveErr = tr.ReceiveClient()
1107 return receiveErr
1108 })
1109 if err != nil {
1110 var failure *protocol.DecodeFailure
1111 if errors.As(err, &failure) {
1112 d.handleInitialDecodeFailure(handshakeCtx, tr, failure)
1113 } else {
1114 err = handshakeContextError(d.hardCtx, timedOut, err)
1115 d.log.Warn("connection closed before hello", "err", err)
1116 }
1117 _ = tr.Close()
1118 return
1119 }
1120 switch message := first.(type) {
1121 case protocol.List:
1122 stopTransport()
1123 finishHandshake()
1124 d.handleList(tr)
1125 case protocol.CommandRequest:
1126 stopTransport()
1127 finishHandshake()
1128 if err := d.handleCommand(tr, message); err != nil {
1129 d.log.Warn("command handler failed", "err", err)
1130 }
1131 case protocol.RemotePreviewWatch:
1132 stopTransport()
1133 finishHandshake()
1134 d.serveRemotePreviewWatch(tr, message)
1135 case protocol.NavigationInventoryRequest:
1136 stopTransport()
1137 finishHandshake()
1138 d.handleNavigationInventory(tr, message)
1139 case protocol.Kill:
1140 stopTransport()
1141 finishHandshake()
1142 d.handleKill(tr, message)
1143 case protocol.Hello:
1144 d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, message)
1145 default:
1146 d.log.Warn("hello rejected", "err", "expected hello")
1147 _ = boundedHandshakeOperation(handshakeCtx, tr, func() error {
1148 return tr.SendServer(serverError(protocol.ErrInternal, "expected hello"))
1149 })
1150 stopTransport()
1151 finishHandshake()
1152 _ = tr.Close()
1153 }
1154}
1155
1156func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.ServerConnection, failure *protocol.DecodeFailure) {
1157 send := func(message protocol.ServerMessage) {
1158 _ = boundedHandshakeOperation(ctx, tr, func() error { return tr.SendServer(message) })
1159 _ = tr.Close()
1160 }
1161 switch failure.Kind {
1162 case protocol.DecodeMessageHello:
1163 if failure.Version != 0 && failure.Version != protocol.Version {
1164 send(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1165 return
1166 }
1167 send(serverError(protocol.ErrInternal, "malformed hello"))
1168 case protocol.DecodeMessageCommand:
1169 if !failure.HasRequestID {
1170 return
1171 }
1172 code, text := protocol.ErrInternal, "malformed command request"
1173 if failure.Version != 0 && failure.Version != protocol.Version {
1174 code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
1175 }
1176 send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
1177 case protocol.DecodeMessageKill:
1178 send(serverError(protocol.ErrInternal, "malformed kill request"))
1179 case protocol.DecodeMessageRemotePreview:
1180 send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
1181 case protocol.DecodeMessageNavigationInventory:
1182 if !failure.HasRequestID {
1183 return
1184 }
1185 status := protocol.NavigationInventoryInvalid
1186 if failure.Version != 0 && failure.Version != protocol.Version {
1187 status = protocol.NavigationInventoryVersionMismatch
1188 }
1189 send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status})
1190 default:
1191 send(serverError(protocol.ErrInternal, "expected hello"))
1192 }
1193}
1194
1195// handleList replies with the current session listing and closes the (one-shot
1196// control) connection.
1197func (d *Daemon) handleList(tr ports.ServerConnection) {
1198 defer func() { _ = tr.Close() }()
1199
1200 d.mu.Lock()
1201 infos := make([]protocol.SessionInfo, 0, len(d.sessions)+len(d.inactive))
1202 liveNames := make(map[string]struct{}, len(d.sessions))
1203 for _, s := range d.sessions {
1204 if s == nil {
1205 continue
1206 }
1207 s.mu.Lock()
1208 info := protocol.SessionInfo{
1209 SessionID: string(s.id),
1210 Name: s.name,
1211 State: protocol.SessionUp,
1212 Ephemeral: s.ephemeral,
1213 Tabs: uint16(len(s.tabs)),
1214 // Attached means registered interactive presence, not mere transport
1215 // membership: a suspended-only session is headless to observers.
1216 Attached: sessionInteractivelyAttachedLocked(s),
1217 }
1218 liveNames[s.name] = struct{}{}
1219 s.mu.Unlock()
1220 infos = append(infos, info)
1221 }
1222 for name, inactive := range d.inactive {
1223 if _, live := liveNames[name]; live || !inactive.visible() {
1224 continue
1225 }
1226 state := protocol.SessionDown
1227 if inactive.broken() {
1228 state = protocol.SessionBroken
1229 }
1230 infos = append(infos, protocol.SessionInfo{Name: name, State: state})
1231 }
1232 d.mu.Unlock()
1233
1234 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
1235 _ = d.boundedControlSend(tr, serverSessions(infos))
1236}
1237
1238// handleKill terminates the requested live session or stopped named session,
1239// purges every session (KillAll), or accepts daemon shutdown (KillDaemon).
1240// Every normally decoded request receives exactly one correlated result before
1241// the control connection closes. KillAll leaves the daemon active.
1242func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
1243 defer func() { _ = tr.Close() }()
1244 send := func(result protocol.KillResult) {
1245 result.RequestID = request.RequestID
1246 d.logKillResultSendFailure(result, d.boundedControlSend(tr, result))
1247 }
1248
1249 switch request.Scope {
1250 case protocol.KillAll:
1251 result := d.purgeAllSessions(protocol.ReasonSessionKilled)
1252 if result.Superseded() {
1253 send(protocol.KillResult{Outcome: protocol.KillOutcomeUnknown, Code: protocol.ErrServerShutdown, Text: "daemon is shutting down"})
1254 return
1255 }
1256 if result.Failed() {
1257 failures := make([]protocol.KillFailure, 0, min(len(result.Failures), purgeSummaryFailureLimit))
1258 for _, failure := range result.Failures[:min(len(result.Failures), purgeSummaryFailureLimit)] {
1259 failures = append(failures, protocol.KillFailure{Class: failure.Class.String(), Name: failure.Name, Text: failure.Err.Error()})
1260 }
1261 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: result.summary(), Failures: failures})
1262 return
1263 }
1264 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1265 return
1266 case protocol.KillDaemon:
1267 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1268 d.shutdownAll(protocol.ReasonServerShutdown)
1269 return
1270 case protocol.KillSession:
1271 default:
1272 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "invalid kill scope"})
1273 return
1274 }
1275
1276 d.mu.Lock()
1277 target := d.findByNameLocked(request.Name)
1278 if target == nil {
1279 if _, ok := d.inactive[request.Name]; ok {
1280 d.mu.Unlock()
1281 // Stopped sessions use the same catalogue-first, incarnation-second
1282 // deletion order as live and offline purges.
1283 if err := d.retryStoppedPurge(request.Name); err != nil {
1284 d.log.Warn("deleting stopped session failed", "err", err, "session", request.Name)
1285 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting stopped session failed"})
1286 return
1287 }
1288 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1289 return
1290 }
1291 }
1292 d.mu.Unlock()
1293
1294 if target == nil {
1295 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: " + request.Name})
1296 return
1297 }
1298 if err := d.killSession(target, protocol.ReasonSessionKilled, true); err != nil {
1299 if errors.Is(err, errSessionKillParticipantsChanged) {
1300 // The exact session was not removed; a concurrent detach or token
1301 // resume invalidated the teardown snapshot. Report the retryable
1302 // outcome instead of a permanent delete failure.
1303 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: sessionKillRetryMessage})
1304 return
1305 }
1306 send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting persisted session failed"})
1307 return
1308 }
1309 send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1310}
1311
1312// handleHello runs the attach handshake for direct package callers. Accepted
1313// connections use handleConn, which resolves the optional accepted deadline so
1314// one budget also covers the first frame read; handleHello adopts the same seam
1315// when the connection exposes it. A connection without the seam keeps the
1316// ordinary fresh protocol.HandshakeTimeout budget.
1317func (d *Daemon) handleHello(tr ports.ServerConnection, hello protocol.Hello) {
1318 handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(context.Background(), acceptedHandshakeDeadline(tr))
1319 stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1320 defer finishHandshake()
1321 defer stopTransport()
1322 d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, hello)
1323}
1324
1325// handleHelloWithContext runs the attach handshake: version check, intent
1326// routing, Welcome, guaranteed first paint, then the per-connection input loop.
1327func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.ServerConnection, h protocol.Hello) {
1328 if err := handshakeContextError(d.hardCtx, timedOut, handshakeCtx.Err()); err != nil {
1329 _ = tr.Close()
1330 return
1331 }
1332 sendHandshake := func(message protocol.ServerMessage) error {
1333 err := boundedHandshakeOperation(handshakeCtx, tr, func() error { return tr.SendServer(message) })
1334 if err != nil {
1335 return handshakeContextError(d.hardCtx, timedOut, err)
1336 }
1337 return nil
1338 }
1339 if h.Version != protocol.Version {
1340 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", protocol.Version, "intent", h.Intent, "session", h.Name)
1341 _ = sendHandshake(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1342 _ = tr.Close()
1343 return
1344 }
1345
1346 sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1347 if rerr != nil {
1348 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1349 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1350 _ = sendHandshake(serverError(pe.code, pe.text))
1351 } else {
1352 _ = sendHandshake(serverError(protocol.ErrInternal, rerr.Error()))
1353 }
1354 _ = tr.Close()
1355 return
1356 }
1357
1358 welcomeSent := false
1359 //nolint:contextcheck // handshakeCtx is intentionally not propagated: cancellation teardown must complete its geometry and ownership cleanup.
1360 failAttachment := func() {
1361 d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1362 }
1363 if err := handshakeCtx.Err(); err != nil {
1364 failAttachment()
1365 return
1366 }
1367 expected := ac.transportSnapshot()
1368 welcomeToken := sess.captureAttachmentCapability(ac, tr)
1369 welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1370 if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1371 if admitted {
1372 welcomeTicket.End()
1373 }
1374 failAttachment()
1375 return
1376 }
1377 if err := handshakeCtx.Err(); err != nil {
1378 welcomeTicket.End()
1379 failAttachment()
1380 return
1381 }
1382 welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1383 frame, err := serverWelcome(sess, d.resumeTokenSnapshot(ac))
1384 if err != nil {
1385 return err
1386 }
1387 return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1388 })
1389 if welcomeErr != nil {
1390 <-welcomeDone
1391 welcomeTicket.End()
1392 failAttachment()
1393 return
1394 }
1395 welcomeSent = true
1396 // Release Welcome's effect before discovering post-handshake authority so a
1397 // replacement blocked behind the send can publish its generation and lease.
1398 welcomeTicket.End()
1399 postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1400 if !admitted {
1401 failAttachment()
1402 return
1403 }
1404 postWelcomeLease := postWelcomeToken.lease
1405 if postWelcomeToken.ac == nil {
1406 postWelcomeTicket.End()
1407 failAttachment()
1408 return
1409 }
1410 postWelcomeRC := sess.renderCoordinator()
1411 if postWelcomeLease != nil && (postWelcomeRC == nil || !postWelcomeRC.markAttachmentReady(postWelcomeLease)) {
1412 postWelcomeTicket.End()
1413 // The attachment was detached while Welcome was in flight; never let
1414 // this stale handshake emit an Output frame.
1415 failAttachment()
1416 return
1417 }
1418 paintToken := sess.captureAttachmentCapability(ac, tr)
1419 painted := make(chan bool, 1)
1420 paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1421 //nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
1422 painted <- d.firstPaintForTransition(paintToken)
1423 return nil
1424 })
1425 if paintErr != nil {
1426 <-paintDone
1427 postWelcomeTicket.End()
1428 failAttachment()
1429 return
1430 }
1431 if !<-painted || handshakeCtx.Err() != nil {
1432 postWelcomeTicket.End()
1433 failAttachment()
1434 return
1435 }
1436 if !d.commitResumeClaim(ac) {
1437 postWelcomeTicket.End()
1438 failAttachment()
1439 return
1440 }
1441 postWelcomeTicket.End()
1442 stopHandshakeTransport()
1443 finishHandshake()
1444 d.runConnLoop(ac)
1445 _ = tr.Close()
1446}
1447
1448// protoErr is a session-level rejection carrying a wire ErrorMsg code.
1449type protoErr struct {
1450 code uint16
1451 text string
1452}
1453
1454func (e *protoErr) Error() string { return e.text }
1455
1456// finishAttach completes an attachment prepared while d.mu is held. Every
1457// caller must hold d.mu on entry; finishAttach transfers ownership by
1458// unlocking d.mu before returning, including every error path. It publishes
1459// terminal and role state before releasing d.mu, then defers coordinator
1460// cleanup so obsolete workers never delay the new handshake.
1461func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello) (*attachedClient, error) {
1462 initialTabIndex := -1
1463 if h.SessionTarget != nil {
1464 var ok bool
1465 initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.SessionTarget)
1466 if !ok {
1467 if tr != nil {
1468 _ = tr.Close()
1469 }
1470 d.mu.Unlock()
1471 return nil, &protoErr{protocol.ErrNoSuchTarget, "remote tab no longer exists"}
1472 }
1473 }
1474 if h.PreferredTabID != "" {
1475 // Route memory is a best-effort attachment cursor, not attach authority.
1476 // It overrides point-in-time picker tab metadata when still present and
1477 // otherwise falls back to the session's normal first-tab repair.
1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1479 }
1480 terminalCapabilities := terminalcap.Resolve(h.Env, h.Color)
1481 // Kitty graphics are enabled only by the explicit direct-terminal
1482 // declaration in Hello. Environment values remain useful for color and
1483 // diagnostics, but cannot authorize terminal-global graphics side effects.
1484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1485 opts := attachClientOptions{
1486 clientID: h.ClientID,
1487 kittyKeyboard: h.KittyKeyboard,
1488 resumeCapable: true,
1489 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1490 navigationCapabilities: h.NavigationCapabilities,
1491 terminalFocus: h.TerminalFocus,
1492 clientPID: h.ClientPID,
1493 clientEnv: helloClientEnvironment(h),
1494 terminalCapabilities: terminalCapabilities,
1495 capabilitiesSet: true,
1496 }
1497 geometry := h.Geometry()
1498 if geometry.Size != sz {
1499 geometry = domain.Geometry{Size: sz}
1500 }
1501 ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
1502 d.mu.Unlock()
1503 result, err := d.transitionAttachment(attachmentTransitionRequest{
1504 target: sess,
1505 next: ac,
1506 expectedTransport: ac.transportSnapshot(),
1507 activateTargetTab: initialTabIndex >= 0,
1508 targetTabIndex: initialTabIndex,
1509 ready: false,
1510 })
1511 if err != nil {
1512 if ac.output != nil && ac.output.graphicsOutput != nil {
1513 d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1514 }
1515 if tr != nil {
1516 _ = tr.Close()
1517 }
1518 return nil, err
1519 }
1520 d.finishAttachedClient(sess, ac, opts)
1521 d.deferAttachmentTransitionCleanups(result)
1522 return ac, nil
1523}
1524
1525func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1526 if sess == nil || preferred == "" {
1527 return -1
1528 }
1529 sess.mu.Lock()
1530 defer sess.mu.Unlock()
1531 for i, candidate := range sess.tabs {
1532 if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1533 return i
1534 }
1535 }
1536 return -1
1537}
1538
1539func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1540 d.mu.Lock()
1541 var (
1542 done chan struct{}
1543 stopped inactiveSession
1544 ok bool
1545 )
1546 if sess := d.findByNameLocked(name); sess != nil {
1547 sess.mu.Lock()
1548 done = sess.restoreDone
1549 sess.mu.Unlock()
1550 } else {
1551 stopped, ok = d.inactive[name]
1552 if !ok || !stopped.visible() {
1553 d.mu.Unlock()
1554 return nil
1555 }
1556 if stopped.restorePending() {
1557 done = stopped.restoreDone
1558 }
1559 }
1560 d.mu.Unlock()
1561
1562 if done != nil {
1563 select {
1564 case <-done:
1565 case <-ctx.Done():
1566 return ctx.Err()
1567 }
1568 }
1569
1570 d.mu.Lock()
1571 defer d.mu.Unlock()
1572 if d.findByNameLocked(name) != nil {
1573 return nil
1574 }
1575 stopped, ok = d.inactive[name]
1576 if !ok || stopped.record.Name == "" {
1577 return nil
1578 }
1579 if stopped.broken() {
1580 return &protoErr{protocol.ErrInternal, "session durable state is broken: " + name}
1581 }
1582 if stopped.record.Committed == nil {
1583 return nil
1584 }
1585 return &protoErr{protocol.ErrInternal, "session was not restored into this daemon: " + name}
1586}
1587
1588// route resolves a Hello to a session and a freshly attached client, creating
1589// the session for ephemeral/new intents. Direct package callers retain the
1590// daemon lifetime context; inbound handshakes use routeWithContext.
1591func (d *Daemon) validateExactSessionTargetLocked(target protocol.ExactSessionTarget) error {
1592 if sess := d.findByNameLocked(target.SessionName); sess != nil {
1593 if sess.incarnation != target.LifecycleID {
1594 return &protoErr{protocol.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1595 }
1596 return nil
1597 }
1598 stopped, ok := d.inactive[target.SessionName]
1599 if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
1600 return &protoErr{protocol.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1601 }
1602 return nil
1603}
1604
1605func (d *Daemon) validateExactSessionTarget(ctx context.Context, target protocol.ExactSessionTarget) error {
1606 if err := target.Validate(); err != nil {
1607 return &protoErr{protocol.ErrNoSuchSession, "invalid exact session target"}
1608 }
1609 if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1610 return err
1611 }
1612
1613 d.mu.Lock()
1614 defer d.mu.Unlock()
1615 return d.validateExactSessionTargetLocked(target)
1616}
1617
1618func (d *Daemon) route(h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1619 ctx := d.serveCtx
1620 if ctx == nil {
1621 ctx = context.Background()
1622 }
1623 return d.routeWithContext(ctx, h, tr)
1624}
1625
1626func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1627 if ctx == nil {
1628 ctx = context.Background()
1629 }
1630 if err := ctx.Err(); err != nil {
1631 return nil, nil, err
1632 }
1633 sz := h.Size
1634 if !sz.Valid() {
1635 return nil, nil, &protoErr{protocol.ErrInternal, "invalid terminal size"}
1636 }
1637 // The accepting side's provisioned admission, when present, is the authority
1638 // this Hello is validated against before any restore, creation, resume
1639 // claim, ownership change, or environment mutation. A connection without
1640 // that seam receives the standard Hello validation and gains no admission
1641 // exception, so a forged Hello can never widen what it is allowed to do.
1642 admission, admitted := sessionHelloAdmission(tr)
1643 if admitted {
1644 if err := validateSessionHelloAdmission(h, admission); err != nil {
1645 return nil, nil, err
1646 }
1647 if admission.Admission == ports.BrokerAdmissionExact && h.ResumeToken != 0 {
1648 if err := d.validateResumeAdmittedTarget(h.ResumeToken, admission.Target); err != nil {
1649 return nil, nil, err
1650 }
1651 }
1652 }
1653 if h.Intent == protocol.IntentResume && h.ResumeToken == 0 {
1654 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is required"}
1655 }
1656 if h.ExactTarget != nil && h.ResumeToken == 0 && h.Name != h.ExactTarget.SessionName {
1657 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "exact session target name mismatch"}
1658 }
1659 // A remote exact-target handoff is an admitted transition like a local
1660 // create/restore: it reserves purge admission before dispatch, so a Hello
1661 // arriving during a KillAll waits on the gate exactly as a local route does
1662 // instead of being rejected outright.
1663 if h.SessionTarget != nil && h.ResumeToken == 0 {
1664 if err := d.acquirePurgeAdmission(ctx); err != nil {
1665 return nil, nil, err
1666 }
1667 defer d.releasePurgeAdmission()
1668 return d.routeRemoteTargetWithContext(ctx, h, tr)
1669 }
1670 if h.ExactTarget != nil && h.ResumeToken == 0 {
1671 if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1672 return nil, nil, err
1673 }
1674 }
1675
1676 // A non-zero token is an authoritative resume credential. If it is unknown,
1677 // expired, or raced with lifecycle teardown, fail closed instead of routing
1678 // the Hello as an ordinary attach that could create or replace ownership.
1679 // The one legitimate pre-park race is a still-active same-client credential
1680 // for the requested session; hand that into the park/resume lifecycle.
1681 if h.ResumeToken != 0 {
1682 d.mu.Lock()
1683 parkedAtStart := d.resume.parkedFor(h.ResumeToken)
1684 d.mu.Unlock()
1685 if parkedAtStart == nil {
1686 if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1687 if errors.Is(err, errResumeTokenLifecycleRace) {
1688 // Live recovery parked then lost a competing resumeParked
1689 // race; keep the fail-closed wire response instead of
1690 // leaking the internal sentinel to handleHello as ErrInternal.
1691 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1692 }
1693 return nil, nil, err
1694 } else if ok {
1695 return sess, ac, nil
1696 }
1697 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1698 }
1699 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1700 if ok {
1701 return sess, ac, nil
1702 }
1703 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1704 } else if errors.Is(err, errResumeTokenLifecycleRace) {
1705 // The parked entry was replaced while this handshake waited for
1706 // its send lock. Fail closed on the original credential rather
1707 // than falling through to ordinary attach/create routing.
1708 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1709 } else {
1710 return nil, nil, err
1711 }
1712 }
1713 if h.Intent == protocol.IntentResume || h.Intent == protocol.IntentAttach {
1714 if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1715 return nil, nil, err
1716 }
1717 }
1718 // A daemon-owned ephemeral Hello without an exact remote target is refused by the daemon. An authenticated remote admission that names the
1719 // creation is the one exception, and it is granted only under a validated
1720 // remote admission; a connection without one never gains it.
1721 if !admitted && h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned && h.SessionTarget == nil &&
1722 h.Intent == protocol.IntentEphemeral {
1723 return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1724 }
1725 // KillAll admission gate: a purge owns the exact lifecycle set it captured,
1726 // so a Hello arriving during it waits here and is then admitted as a create
1727 // or restore after the purge, never inserted into the set being removed.
1728 // This is transient and re-opened by the purge; it is not daemon closing.
1729 if err := d.acquirePurgeAdmission(ctx); err != nil {
1730 return nil, nil, err
1731 }
1732 defer d.releasePurgeAdmission()
1733 d.mu.Lock()
1734 // Shutdown/create interlock: once an explicit shutdown has begun
1735 // (shutdownAll started) no new session may be created and no attach may
1736 // proceed — the teardown snapshot has already been (or is being) taken, so
1737 // anything inserted now would leak its PTY and hang Serve. An empty registry
1738 // alone is not shutdown, and a KillAll purge is gated by admission above.
1739 if d.closing {
1740 d.mu.Unlock()
1741 return nil, nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
1742 }
1743 // The first exact-target check happens before restore I/O. Recheck while
1744 // holding d.mu so a same-name lifecycle replacement cannot slip between
1745 // validation and attachment creation.
1746 if h.ExactTarget != nil && h.ResumeToken == 0 {
1747 if err := d.validateExactSessionTargetLocked(*h.ExactTarget); err != nil {
1748 d.mu.Unlock()
1749 return nil, nil, err
1750 }
1751 }
1752 switch h.Intent {
1753 case protocol.IntentEphemeral:
1754 name := d.allocEphemeralNameLocked()
1755 // A daemon-owned creation (an authenticated remote admission) uses the
1756 // daemon's own environment and home rather than any client value; a
1757 // client-owned creation (local) uses the admitted client environment and
1758 // working directory. The Hello fields are already validated to match the
1759 // admission, so this mirrors the named-creation ownership rule below.
1760 cwd, env := h.Cwd, h.Env
1761 if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1762 cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1763 }
1764 sess, err := d.createSessionLockedWithMode(name, true, cwd, h.Geometry(), env)
1765 if err != nil {
1766 d.mu.Unlock()
1767 return nil, nil, err
1768 }
1769 ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1770 return sess, ac, err
1771
1772 case protocol.IntentNew:
1773 if h.Name == "" {
1774 d.mu.Unlock()
1775 return nil, nil, &protoErr{protocol.ErrInvalidSessionName, "empty session name"}
1776 }
1777 if err := domain.ValidateSessionName(h.Name); err != nil {
1778 d.mu.Unlock()
1779 return nil, nil, &protoErr{protocol.ErrInvalidSessionName, err.Error()}
1780 }
1781 if d.nameLiveOrStoppedLocked(h.Name) {
1782 d.mu.Unlock()
1783 return nil, nil, &protoErr{protocol.ErrNameTaken, "session name already in use: " + h.Name}
1784 }
1785 cwd, env := h.Cwd, h.Env
1786 if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1787 // A remote CNS destination owns shell startup just like a picker
1788 // attach. Client paths may exist here but belong to another user.
1789 cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1790 }
1791 sess, err := d.createSessionLockedWithMode(h.Name, false, cwd, h.Geometry(), env)
1792 if err != nil {
1793 d.mu.Unlock()
1794 return nil, nil, err
1795 }
1796 ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1797 return sess, ac, err
1798
1799 case protocol.IntentAttach:
1800 sess := d.findByNameLocked(h.Name)
1801 created := false
1802 if sess == nil {
1803 stopped, ok := d.inactive[h.Name]
1804 if !ok || !stopped.canResume() {
1805 d.mu.Unlock()
1806 return nil, nil, &protoErr{protocol.ErrNoSuchSession, "no such resumable session: " + h.Name}
1807 }
1808 cwd := d.dirOrHome(stopped.cwd)
1809 env := h.Env
1810 daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1811 if daemonOwned {
1812 env = copyEnvironment(d.baseEnv)
1813 }
1814 var err error
1815 sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
1816 if err != nil {
1817 d.mu.Unlock()
1818 return nil, nil, err
1819 }
1820 if daemonOwned {
1821 // Like a snapshot restore, the resumed session starts from the
1822 // daemon's environment until a local client attaches.
1823 markEnvironmentProvisional(sess)
1824 }
1825 created = true
1826 }
1827 ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
1828 return sess, ac, err
1829
1830 default:
1831 d.mu.Unlock()
1832 return nil, nil, &protoErr{protocol.ErrInternal, "unknown intent"}
1833 }
1834}