package daemon import ( "crypto/rand" "encoding/binary" "errors" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" ) var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race") // resumeTokenSnapshot reads the credential under the same daemon lock used by // its lifecycle writers. Callers must not hold d.mu. func (d *Daemon) resumeTokenSnapshot(ac *attachedClient) uint64 { if ac == nil { return 0 } d.mu.Lock() defer d.mu.Unlock() return ac.resumeToken } func newResumeToken() uint64 { var b [8]byte if _, err := rand.Read(b[:]); err != nil { panic("crypto/rand failed generating resume token: " + err.Error()) } return binary.BigEndian.Uint64(b[:]) } func (d *Daemon) nextResumeTokenLocked() uint64 { return d.resume.issue() } // markParkingInFlight publishes an observable parking lifecycle so IntentResume // can wait out the detach→park gap. Callers must publish this before clearing // the live seat so resume never observes both registries empty for a still-valid // credential. Under d.mu then sess.mu it advertises only when the daemon still // registers the exact session and ac remains a registered attachment; otherwise // it returns 0 and never creates a marker after terminal // cleanup. Returns the resume token, or 0 when parking cannot be advertised. func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 { if sess == nil || ac == nil || !ac.resumeCapable { return 0 } d.mu.Lock() defer d.mu.Unlock() if d.closing || d.sessions[sess.id] != sess { return 0 } sess.mu.Lock() registered := attachmentRegisteredLocked(sess, ac) sess.mu.Unlock() if !registered { return 0 } if ac.resumeToken == 0 { ac.resumeToken = d.nextResumeTokenLocked() } d.ensureParkingInFlightLocked(sess, ac) d.log.Info("parking in flight", "session", sess.nameSnapshot()) return ac.resumeToken } // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac. // Caller holds d.mu and has verified the daemon is not closing. func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) { d.resume.markParking(sess, ac) } func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) { if token == 0 { return } d.mu.Lock() d.clearParkingInFlightLocked(token, ac) d.mu.Unlock() } // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this // teardown lost the seat while still the live owner (stale transport fence). // The live owner clears its own marker; if another path already detached or // unrouted, that owning teardown is responsible for park publication/cleanup. func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) { if token == 0 || ac == nil { return } d.mu.Lock() defer d.mu.Unlock() if d.resume.parkedFor(token) != nil { return } stillOwner := false if sess != nil { sess.mu.Lock() stillOwner = attachmentRegisteredLocked(sess, ac) sess.mu.Unlock() } if !stillOwner { return } d.clearParkingInFlightLocked(token, ac) } func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) { d.resume.clearParking(token, ac) } func (d *Daemon) purgeAllParkingLocked() { d.resume.purgeParking(nil) } // purgeParkingForSessionLocked removes and closes every in-flight parking // marker for sess so same-token waiters fail closed instead of hanging when // the session is removed before park publication. Caller holds d.mu. func (d *Daemon) purgeParkingForSessionLocked(sess *session) { if sess != nil { d.resume.purgeParking(sess) } } // waitParkingInFlight waits for an identified same-client parking publication // to finish. Returns true when a matching in-flight entry was observed. // Unknown and mismatched credentials return false without waiting. func (d *Daemon) waitParkingInFlight(h protocol.Hello) bool { if h.ResumeToken == 0 { return false } d.mu.Lock() pending := d.resume.parkingFor(h.ResumeToken) if pending == nil { d.mu.Unlock() return false } if pending.ac.clientID != h.ClientID { d.mu.Unlock() return false } if h.Name != "" && pending.sess != nil && pending.sess.nameSnapshot() != h.Name { d.mu.Unlock() return false } done := pending.done d.mu.Unlock() d.log.Info("resume waiting for in-flight parking", "session", h.Name) if d.afterParkingWaitArmed != nil { d.afterParkingWaitArmed() } <-done return true } func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { if sess == nil || ac == nil || !ac.resumeCapable || ac.attachmentActivity() != attachmentActive { return false } d.mu.Lock() defer d.mu.Unlock() if d.closing { return false } if ac.resumeToken == 0 { ac.resumeToken = d.nextResumeTokenLocked() } // Do not recreate parking markers here: post-detach publication races // terminal session removal and can strand IntentResume waiters. Callers // that need the detach→park gap advertised must markParkingInFlight while // still the exact live owner. Direct parkAttachment still parks below. d.log.Info("parking client prepared", "session", sess.nameSnapshot()) return true } func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { if !d.prepareParkAttachment(sess, ac) { return false } // The client recreates picker state after reconnect. Retire the daemon-side // interaction before publishing the parked attachment so resumed input is // never swallowed by a stale overlay. d.retirePicker(ac) // A parked attachment has no session owner. Release pane snapshots before // publishing it in the daemon registry so headless pane/session teardown // cannot leave closed panes retained through its capture cache. This must // remain outside d.mu: sendMu is ordered before the daemon lock. ac.clearCaptureFrames() var paletteGeneration uint64 if ac.overlays != nil { ac.overlays.paletteMu.Lock() paletteGeneration = ac.overlays.paletteGeneration ac.overlays.paletteMu.Unlock() } d.mu.Lock() if d.closing || d.sessions[sess.id] != sess { d.clearParkingInFlightLocked(ac.resumeToken, ac) d.mu.Unlock() return false } token := ac.resumeToken var ( oldRetirement *parkedAttachmentRetirement oldSame *parkedAttachment ) if old := d.resume.parkedFor(token); old != nil { if old.ac == ac { oldSame = old } else { retirement := d.retireParkedAttachmentLocked(token, old) oldRetirement = &retirement } } grace := d.resumeParkGrace timer := d.clock.NewTimer(grace) parked := &parkedAttachment{ sess: sess, ac: ac, paletteGeneration: paletteGeneration, timer: timer, done: make(chan struct{}), } ac.parked = true d.resume.publishParked(token, parked) d.mu.Unlock() if oldRetirement != nil { d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement}) } if oldSame != nil { if oldSame.timer != nil { oldSame.timer.Stop() } oldSame.closeDone() } d.log.Info("client parked for resume", "session", sess.nameSnapshot(), "grace", grace) d.watchParkedTimer(token, parked) return true } func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) { if parked == nil || parked.timer == nil { return } timer := parked.timer go func() { select { case <-timer.C(): d.expireParked(token, parked) case <-parked.done: } }() } func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { d.mu.Lock() if d.resume.parkedFor(token) == parked { if parked.claimed { d.mu.Unlock() return } d.removeParkedLocked(token, parked) d.mu.Unlock() d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration) d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot()) d.reapAbandonedEphemeral(parked.sess) return } d.mu.Unlock() } // removeParkedLocked invalidates one non-nil parked attachment. Caller holds // d.mu and has verified token still holds parked when that matters. func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { d.resume.takeParked(token, parked) d.discardAttachmentOutputLocked(parked.ac) parked.ac.resumeToken = 0 parked.ac.parked = false if parked.timer != nil { parked.timer.Stop() } parked.closeDone() } func (p *parkedAttachment) closeDone() { if p.done == nil { return } p.doneOnce.Do(func() { close(p.done) }) } // commitResumeClaim consumes the old credential only after Welcome has been // written successfully. The claim itself is serialized by d.mu, so a racing // resume cannot acquire the same parked attachment. func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { if d == nil || ac == nil { return false } d.mu.Lock() defer d.mu.Unlock() token := ac.resumeClaimToken parked := d.resume.parkedFor(token) if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { return token == 0 } d.resume.takeParked(token, parked) parked.claimed = false if parked.timer != nil { parked.timer.Stop() } parked.closeDone() ac.resumeClaimToken = 0 return true } // abortResumeClaim restores a parked credential when the transport could not // complete the pre-claim Welcome handshake. It invalidates the claimed link // generation before making the credential available to another winner. func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { if d == nil || ac == nil { return false } d.mu.Lock() token := ac.resumeClaimToken parked := d.resume.parkedFor(token) if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { d.mu.Unlock() return false } d.mu.Unlock() // A successful claim may already have published active membership. Freeze // and drain without architecture locks before restoring parked identity. frozen := freezeAttachmentEffectGates(ac) defer frozen.unfreeze() d.mu.Lock() if d.resume.parkedFor(token) != parked || !parked.claimed || ac.resumeClaimToken != token { d.mu.Unlock() return false } parked.claimed = false ac.resumeClaimToken = 0 ac.resumeToken = token ac.parked = true sess := parked.sess if sess != nil { sess.mu.Lock() sess.unregisterAttachmentLocked(ac) ac.setSession(nil) ac.invalidateFrozenAttachmentCapability() sess.mu.Unlock() } else { ac.setSession(nil) ac.invalidateFrozenAttachmentCapability() } // Recreate the parked entry and its watcher instead of restoring a timer // that may already have fired while the claim held the old entry. This also // makes a failed handshake's credential expiry deterministic under fake clocks. if parked.timer != nil { parked.timer.Stop() } parked.closeDone() rearmed := &parkedAttachment{ sess: parked.sess, ac: ac, paletteGeneration: parked.paletteGeneration, timer: d.clock.NewTimer(d.resumeParkGrace), done: make(chan struct{}), } d.resume.publishParked(token, rearmed) captured := ac.transportSnapshot().transport d.mu.Unlock() if sess != nil { sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") } d.watchParkedTimer(token, rearmed) if captured != nil { _ = ac.closeCapturedTransport(ac.revokeTransport(captured)) } return true } type parkedAttachmentRetirement struct { parked *parkedAttachment paletteGeneration uint64 transport transportSnapshot } func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { d.resume.takeParked(token, parked) d.discardAttachmentOutputLocked(parked.ac) parked.ac.resumeToken = 0 parked.ac.parked = false parked.ac.setSession(nil) parked.ac.invalidateRetiredAttachmentCapability() return parkedAttachmentRetirement{ parked: parked, paletteGeneration: parked.paletteGeneration, transport: parked.ac.transportSnapshot(), } } // purgeParkedForSessionLocked invalidates every parked token for sess and // returns the external resources that must be retired after releasing d.mu. // It is reserved for terminal session kill and daemon shutdown. func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement { if sess == nil { return nil } return d.retireParkedLocked(d.resume.parkedForSession(sess)) } func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement { return d.retireParkedLocked(d.resume.parkedForSession(nil)) } func (d *Daemon) retireParkedLocked(entries map[uint64]*parkedAttachment) []parkedAttachmentRetirement { retirements := make([]parkedAttachmentRetirement, 0, len(entries)) for token, parked := range entries { retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) } return retirements } // finishParkedAttachmentRetirements stops timers and retires discovery-overlay // ownership and transports without holding daemon, session, coordinator, or pane locks. func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) { for _, retirement := range retirements { if retirement.parked.timer != nil { retirement.parked.timer.Stop() } retirement.parked.closeDone() ac := retirement.parked.ac d.closePaletteIfCurrent(ac, retirement.paletteGeneration) _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) } } // resumeLiveAttachment recovers a nonzero resume credential that still belongs // to the named session's active attachment because transport teardown has not // parked it yet. Only the exact live owner (same session, token, and client ID) // is accepted; arbitrary unknown tokens stay fail-closed. func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) { d.mu.Lock() if d.closing { d.mu.Unlock() return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"} } // Teardown may have parked the credential already. if d.resume.parkedFor(h.ResumeToken) != nil { d.mu.Unlock() return d.resumeParked(h, tr, sz) } sess := d.findByNameLocked(h.Name) var ( ac *attachedClient oldSnap transportSnapshot credentialMatch bool clientMismatch bool ) if sess != nil { sess.mu.Lock() for candidate := range sess.attachments { if candidate.attachmentActivity() != attachmentActive || !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken { continue } ac = candidate if ac.clientID != h.ClientID { clientMismatch = true } else { oldSnap = ac.transportSnapshot() credentialMatch = oldSnap.transport != nil } break } sess.mu.Unlock() } // Teardown may have parked the credential while we resolved the session. if d.resume.parkedFor(h.ResumeToken) != nil { d.mu.Unlock() return d.resumeParked(h, tr, sz) } if credentialMatch && h.SessionTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.SessionTarget) { d.mu.Unlock() return nil, nil, false, &protoErr{protocol.ErrNoSuchTarget, "remote target no longer matches resumed session"} } d.mu.Unlock() if clientMismatch { reason := "client id mismatch" d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason) return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} } if !credentialMatch { // Detach may have won and not published parked yet; wait out that gap // for the exact same-client credential, then try the parked path. _ = d.waitParkingInFlight(h) return d.resumeParked(h, tr, sz) } if d.beforeMarkParkingInFlight != nil { d.beforeMarkParkingInFlight() } // Advertise parking before detach so a concurrent same-token resume never // observes both the live seat and parking/parked registries empty. Late // publication after terminal detach/session removal returns 0. parkingToken := d.markParkingInFlight(sess, ac) if !d.detachIfCurrentTransport(sess, ac, oldSnap) { d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken) // A newer owner, rebound link, or teardown won; wait if teardown is // still publishing the park, then try the parked path. _ = d.waitParkingInFlight(h) return d.resumeParked(h, tr, sz) } if d.afterResumeLiveDetach != nil { d.afterResumeLiveDetach() } if rc := sess.renderCoordinator(); rc != nil { rc.noteDetach(ac) } sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") if !d.parkAttachment(sess, ac) { // Detach already published; retire the captured link exactly once so // shutdown cannot leave an orphaned open transport with no owner. d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) d.resetScreenDefaultColors(sess) _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport)) d.mu.Lock() closing := d.closing d.mu.Unlock() if closing { return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"} } return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} } _ = ac.closeCapturedTransport(oldSnap.transport) d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot()) return d.resumeParked(h, tr, sz) } // resumeParked acquires the parked client's output lock before the daemon // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked // entry is revalidated after both locks are held because it may expire between // the initial lookup and lock acquisition. func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) { d.mu.Lock() parked := d.resume.parkedFor(h.ResumeToken) d.mu.Unlock() if parked == nil || h.ResumeToken == 0 { return nil, nil, false, nil } ac := parked.ac if d.beforeResumeParkedSendMu != nil { d.beforeResumeParkedSendMu() } ac.sendMu.Lock() d.mu.Lock() if d.closing { d.mu.Unlock() ac.sendMu.Unlock() return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"} } if d.resume.parkedFor(h.ResumeToken) != parked || parked.claimed { if d.sessions[parked.sess.id] == parked.sess { d.mu.Unlock() ac.sendMu.Unlock() return nil, nil, false, errResumeTokenLifecycleRace } d.mu.Unlock() ac.sendMu.Unlock() return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} } sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) d.mu.Unlock() ac.sendMu.Unlock() if err == nil && ok && sess != nil && resumed != nil { // Resume publication normally schedules reconciliation asynchronously; // complete the claiming terminal's geometry before the resumed // attachment is allowed to produce its first frame. sess.geometry.reconcile(d, sess, resumed) // A resumed unsupported attachment bypasses finishAttachedClient. Check // the restored scene at this boundary so suppression is explained once. d.warnUnsupportedGraphics(resumed) } if err != nil && resumed != nil { d.abortResumeClaim(resumed) } return sess, resumed, ok, err } // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu // and d.mu in that order. func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) { parked := d.resume.parkedFor(h.ResumeToken) if parked == nil || h.ResumeToken == 0 { return nil, nil, false, nil } sess := parked.sess registered := d.sessions[sess.id] == sess if !registered { d.removeParkedLocked(h.ResumeToken, parked) d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false) return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} } ac := parked.ac if parked.claimed { return nil, nil, false, errResumeTokenLifecycleRace } if ac.clientID != h.ClientID { reason := "client id mismatch" d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason) return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} } if h.SessionTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.SessionTarget) { return nil, nil, false, &protoErr{protocol.ErrNoSuchTarget, "remote target no longer matches resumed session"} } // Claim the attachment while retaining the old credential until Welcome is // accepted. A failed handshake can therefore restore this exact parked // entry; another resume sees claimed and loses atomically. parked.claimed = true ac.resumeClaimToken = h.ResumeToken // The parked attachment has no session owner and cannot paint. Retire the // abandoned transport's output chain before binding the replacement so the // mandatory first paint cannot be blocked by ACKs that died with the link. ac.rebaseOutput() ac.output.setWindow(h.MaxOutputInFlight) ac.replaceTransport(tr) // A parked attachment can resume through any route. Reapply the declared // direct-terminal capability before resumed capture or first paint observes // the replacement transport. d.reconfigureAttachmentOutput(sess, ac, h) // The replacement terminal's focus, too, is declared before first paint. ac.setTerminalFocus(h.TerminalFocus) ac.clientPID.Store(h.ClientPID) geometry := h.Geometry() if geometry.Size != sz { geometry = domain.Geometry{Size: sz} } ac.setGeometry(geometry) ac.resumeToken = d.nextResumeTokenLocked() ac.parked = false // The resumed session's snapshot is the sole source for future PTY children. // Existing PTYs retain the environment they were started with. sess.mu.Lock() if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned { sess.env = copyEnvironment(h.Env) } sess.mu.Unlock() preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID) // Resume preparation used sendMu -> d.mu, but freeze/drain must run with // neither held. Reacquire them before returning to preserve this helper's // locked-caller contract. d.mu.Unlock() ac.sendMu.Unlock() transition, err := d.transitionAttachment(attachmentTransitionRequest{ target: sess, next: ac, expectedTransport: ac.transportSnapshot(), activateTargetTab: preferredTabIndex >= 0, targetTabIndex: preferredTabIndex, ready: false, }) ac.sendMu.Lock() d.mu.Lock() if err != nil { return sess, ac, false, &protoErr{protocol.ErrInternal, "resume attachment transition failed"} } d.deferAttachmentTransitionCleanups(transition) d.touchMRU(sess) d.log.Info("client resumed", "session", sess.nameSnapshot()) return sess, ac, true, nil }