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