resume.go RawHistoryFolder

1package daemon
2
3import (
4	"crypto/rand"
5	"encoding/binary"
6	"errors"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11)
12
13var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
14
15// resumeTokenSnapshot reads the credential under the same daemon lock used by
16// its lifecycle writers. Callers must not hold d.mu.
17func (d *Daemon) resumeTokenSnapshot(ac *attachedClient) uint64 {
18	if ac == nil {
19		return 0
20	}
21	d.mu.Lock()
22	defer d.mu.Unlock()
23	return ac.resumeToken
24}
25
26func newResumeToken() uint64 {
27	var b [8]byte
28	if _, err := rand.Read(b[:]); err != nil {
29		panic("crypto/rand failed generating resume token: " + err.Error())
30	}
31	return binary.BigEndian.Uint64(b[:])
32}
33
34func (d *Daemon) nextResumeTokenLocked() uint64 { return d.resume.issue() }
35
36// markParkingInFlight publishes an observable parking lifecycle so IntentResume
37// can wait out the detach→park gap. Callers must publish this before clearing
38// the live seat so resume never observes both registries empty for a still-valid
39// credential. Under d.mu then sess.mu it advertises only when the daemon still
40// registers the exact session and ac remains a registered attachment; otherwise
41// it returns 0 and never creates a marker after terminal
42// cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44	if sess == nil || ac == nil || !ac.resumeCapable {
45		return 0
46	}
47	d.mu.Lock()
48	defer d.mu.Unlock()
49	if d.closing || d.sessions[sess.id] != sess {
50		return 0
51	}
52	sess.mu.Lock()
53	registered := attachmentRegisteredLocked(sess, ac)
54	sess.mu.Unlock()
55	if !registered {
56		return 0
57	}
58	if ac.resumeToken == 0 {
59		ac.resumeToken = d.nextResumeTokenLocked()
60	}
61	d.ensureParkingInFlightLocked(sess, ac)
62	d.log.Info("parking in flight", "session", sess.nameSnapshot())
63	return ac.resumeToken
64}
65
66// ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
67// Caller holds d.mu and has verified the daemon is not closing.
68func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
69	d.resume.markParking(sess, ac)
70}
71
72func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
73	if token == 0 {
74		return
75	}
76	d.mu.Lock()
77	d.clearParkingInFlightLocked(token, ac)
78	d.mu.Unlock()
79}
80
81// clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
82// teardown lost the seat while still the live owner (stale transport fence).
83// The live owner clears its own marker; if another path already detached or
84// unrouted, that owning teardown is responsible for park publication/cleanup.
85func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
86	if token == 0 || ac == nil {
87		return
88	}
89	d.mu.Lock()
90	defer d.mu.Unlock()
91	if d.resume.parkedFor(token) != nil {
92		return
93	}
94	stillOwner := false
95	if sess != nil {
96		sess.mu.Lock()
97		stillOwner = attachmentRegisteredLocked(sess, ac)
98		sess.mu.Unlock()
99	}
100	if !stillOwner {
101		return
102	}
103	d.clearParkingInFlightLocked(token, ac)
104}
105
106func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
107	d.resume.clearParking(token, ac)
108}
109
110func (d *Daemon) purgeAllParkingLocked() { d.resume.purgeParking(nil) }
111
112// purgeParkingForSessionLocked removes and closes every in-flight parking
113// marker for sess so same-token waiters fail closed instead of hanging when
114// the session is removed before park publication. Caller holds d.mu.
115func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
116	if sess != nil {
117		d.resume.purgeParking(sess)
118	}
119}
120
121// waitParkingInFlight waits for an identified same-client parking publication
122// to finish. Returns true when a matching in-flight entry was observed.
123// Unknown and mismatched credentials return false without waiting.
124func (d *Daemon) waitParkingInFlight(h protocol.Hello) bool {
125	if h.ResumeToken == 0 {
126		return false
127	}
128	d.mu.Lock()
129	pending := d.resume.parkingFor(h.ResumeToken)
130	if pending == nil {
131		d.mu.Unlock()
132		return false
133	}
134	if pending.ac.clientID != h.ClientID {
135		d.mu.Unlock()
136		return false
137	}
138	if h.Name != "" && pending.sess != nil && pending.sess.nameSnapshot() != h.Name {
139		d.mu.Unlock()
140		return false
141	}
142	done := pending.done
143	d.mu.Unlock()
144	d.log.Info("resume waiting for in-flight parking", "session", h.Name)
145	if d.afterParkingWaitArmed != nil {
146		d.afterParkingWaitArmed()
147	}
148	<-done
149	return true
150}
151
152func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
153	if sess == nil || ac == nil || !ac.resumeCapable || ac.attachmentActivity() != attachmentActive {
154		return false
155	}
156	d.mu.Lock()
157	defer d.mu.Unlock()
158	if d.closing {
159		return false
160	}
161	if ac.resumeToken == 0 {
162		ac.resumeToken = d.nextResumeTokenLocked()
163	}
164	// Do not recreate parking markers here: post-detach publication races
165	// terminal session removal and can strand IntentResume waiters. Callers
166	// that need the detach→park gap advertised must markParkingInFlight while
167	// still the exact live owner. Direct parkAttachment still parks below.
168	d.log.Info("parking client prepared", "session", sess.nameSnapshot())
169	return true
170}
171
172func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
173	if !d.prepareParkAttachment(sess, ac) {
174		return false
175	}
176	// The client recreates picker state after reconnect. Retire the daemon-side
177	// interaction before publishing the parked attachment so resumed input is
178	// never swallowed by a stale overlay.
179	d.retirePicker(ac)
180	// A parked attachment has no session owner. Release pane snapshots before
181	// publishing it in the daemon registry so headless pane/session teardown
182	// cannot leave closed panes retained through its capture cache. This must
183	// remain outside d.mu: sendMu is ordered before the daemon lock.
184	ac.clearCaptureFrames()
185	var paletteGeneration uint64
186	if ac.overlays != nil {
187		ac.overlays.paletteMu.Lock()
188		paletteGeneration = ac.overlays.paletteGeneration
189		ac.overlays.paletteMu.Unlock()
190	}
191	d.mu.Lock()
192	if d.closing || d.sessions[sess.id] != sess {
193		d.clearParkingInFlightLocked(ac.resumeToken, ac)
194		d.mu.Unlock()
195		return false
196	}
197	token := ac.resumeToken
198	var (
199		oldRetirement *parkedAttachmentRetirement
200		oldSame       *parkedAttachment
201	)
202	if old := d.resume.parkedFor(token); old != nil {
203		if old.ac == ac {
204			oldSame = old
205		} else {
206			retirement := d.retireParkedAttachmentLocked(token, old)
207			oldRetirement = &retirement
208		}
209	}
210	grace := d.resumeParkGrace
211	timer := d.clock.NewTimer(grace)
212	parked := &parkedAttachment{
213		sess: sess, ac: ac, paletteGeneration: paletteGeneration,
214		timer: timer, done: make(chan struct{}),
215	}
216	ac.parked = true
217	d.resume.publishParked(token, parked)
218	d.mu.Unlock()
219	if oldRetirement != nil {
220		d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
221	}
222	if oldSame != nil {
223		if oldSame.timer != nil {
224			oldSame.timer.Stop()
225		}
226		oldSame.closeDone()
227	}
228	d.log.Info("client parked for resume", "session", sess.nameSnapshot(), "grace", grace)
229	d.watchParkedTimer(token, parked)
230	return true
231}
232
233func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
234	if parked == nil || parked.timer == nil {
235		return
236	}
237	timer := parked.timer
238	go func() {
239		select {
240		case <-timer.C():
241			d.expireParked(token, parked)
242		case <-parked.done:
243		}
244	}()
245}
246
247func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
248	d.mu.Lock()
249	if d.resume.parkedFor(token) == parked {
250		if parked.claimed {
251			d.mu.Unlock()
252			return
253		}
254		d.removeParkedLocked(token, parked)
255		d.mu.Unlock()
256		d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)
257		d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
258		d.reapAbandonedEphemeral(parked.sess)
259		return
260	}
261	d.mu.Unlock()
262}
263
264// removeParkedLocked invalidates one non-nil parked attachment. Caller holds
265// d.mu and has verified token still holds parked when that matters.
266func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
267	d.resume.takeParked(token, parked)
268	d.discardAttachmentOutputLocked(parked.ac)
269	parked.ac.resumeToken = 0
270	parked.ac.parked = false
271	if parked.timer != nil {
272		parked.timer.Stop()
273	}
274	parked.closeDone()
275}
276
277func (p *parkedAttachment) closeDone() {
278	if p.done == nil {
279		return
280	}
281	p.doneOnce.Do(func() { close(p.done) })
282}
283
284// commitResumeClaim consumes the old credential only after Welcome has been
285// written successfully. The claim itself is serialized by d.mu, so a racing
286// resume cannot acquire the same parked attachment.
287func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
288	if d == nil || ac == nil {
289		return false
290	}
291	d.mu.Lock()
292	defer d.mu.Unlock()
293	token := ac.resumeClaimToken
294	parked := d.resume.parkedFor(token)
295	if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
296		return token == 0
297	}
298	d.resume.takeParked(token, parked)
299	parked.claimed = false
300	if parked.timer != nil {
301		parked.timer.Stop()
302	}
303	parked.closeDone()
304	ac.resumeClaimToken = 0
305	return true
306}
307
308// abortResumeClaim restores a parked credential when the transport could not
309// complete the pre-claim Welcome handshake. It invalidates the claimed link
310// generation before making the credential available to another winner.
311func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
312	if d == nil || ac == nil {
313		return false
314	}
315	d.mu.Lock()
316	token := ac.resumeClaimToken
317	parked := d.resume.parkedFor(token)
318	if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
319		d.mu.Unlock()
320		return false
321	}
322	d.mu.Unlock()
323
324	// A successful claim may already have published active membership. Freeze
325	// and drain without architecture locks before restoring parked identity.
326	frozen := freezeAttachmentEffectGates(ac)
327	defer frozen.unfreeze()
328
329	d.mu.Lock()
330	if d.resume.parkedFor(token) != parked || !parked.claimed || ac.resumeClaimToken != token {
331		d.mu.Unlock()
332		return false
333	}
334	parked.claimed = false
335	ac.resumeClaimToken = 0
336	ac.resumeToken = token
337	ac.parked = true
338	sess := parked.sess
339	if sess != nil {
340		sess.mu.Lock()
341		sess.unregisterAttachmentLocked(ac)
342		ac.setSession(nil)
343		ac.invalidateFrozenAttachmentCapability()
344		sess.mu.Unlock()
345	} else {
346		ac.setSession(nil)
347		ac.invalidateFrozenAttachmentCapability()
348	}
349	// Recreate the parked entry and its watcher instead of restoring a timer
350	// that may already have fired while the claim held the old entry. This also
351	// makes a failed handshake's credential expiry deterministic under fake clocks.
352	if parked.timer != nil {
353		parked.timer.Stop()
354	}
355	parked.closeDone()
356	rearmed := &parkedAttachment{
357		sess:              parked.sess,
358		ac:                ac,
359		paletteGeneration: parked.paletteGeneration,
360		timer:             d.clock.NewTimer(d.resumeParkGrace),
361		done:              make(chan struct{}),
362	}
363	d.resume.publishParked(token, rearmed)
364	captured := ac.transportSnapshot().transport
365	d.mu.Unlock()
366	if sess != nil {
367		sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
368	}
369	d.watchParkedTimer(token, rearmed)
370	if captured != nil {
371		_ = ac.closeCapturedTransport(ac.revokeTransport(captured))
372	}
373	return true
374}
375
376type parkedAttachmentRetirement struct {
377	parked            *parkedAttachment
378	paletteGeneration uint64
379	transport         transportSnapshot
380}
381
382func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
383	d.resume.takeParked(token, parked)
384	d.discardAttachmentOutputLocked(parked.ac)
385	parked.ac.resumeToken = 0
386	parked.ac.parked = false
387	parked.ac.setSession(nil)
388	parked.ac.invalidateRetiredAttachmentCapability()
389	return parkedAttachmentRetirement{
390		parked:            parked,
391		paletteGeneration: parked.paletteGeneration,
392		transport:         parked.ac.transportSnapshot(),
393	}
394}
395
396// purgeParkedForSessionLocked invalidates every parked token for sess and
397// returns the external resources that must be retired after releasing d.mu.
398// It is reserved for terminal session kill and daemon shutdown.
399func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
400	if sess == nil {
401		return nil
402	}
403	return d.retireParkedLocked(d.resume.parkedForSession(sess))
404}
405
406func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
407	return d.retireParkedLocked(d.resume.parkedForSession(nil))
408}
409
410func (d *Daemon) retireParkedLocked(entries map[uint64]*parkedAttachment) []parkedAttachmentRetirement {
411	retirements := make([]parkedAttachmentRetirement, 0, len(entries))
412	for token, parked := range entries {
413		retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
414	}
415	return retirements
416}
417
418// finishParkedAttachmentRetirements stops timers and retires discovery-overlay
419// ownership and transports without holding daemon, session, coordinator, or pane locks.
420func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
421	for _, retirement := range retirements {
422		if retirement.parked.timer != nil {
423			retirement.parked.timer.Stop()
424		}
425		retirement.parked.closeDone()
426		ac := retirement.parked.ac
427		d.closePaletteIfCurrent(ac, retirement.paletteGeneration)
428		_ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
429	}
430}
431
432// resumeLiveAttachment recovers a nonzero resume credential that still belongs
433// to the named session's active attachment because transport teardown has not
434// parked it yet. Only the exact live owner (same session, token, and client ID)
435// is accepted; arbitrary unknown tokens stay fail-closed.
436func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
437	d.mu.Lock()
438	if d.closing {
439		d.mu.Unlock()
440		return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
441	}
442	// Teardown may have parked the credential already.
443	if d.resume.parkedFor(h.ResumeToken) != nil {
444		d.mu.Unlock()
445		return d.resumeParked(h, tr, sz)
446	}
447	sess := d.findByNameLocked(h.Name)
448	var (
449		ac              *attachedClient
450		oldSnap         transportSnapshot
451		credentialMatch bool
452		clientMismatch  bool
453	)
454	if sess != nil {
455		sess.mu.Lock()
456		for candidate := range sess.attachments {
457			if candidate.attachmentActivity() != attachmentActive || !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
458				continue
459			}
460			ac = candidate
461			if ac.clientID != h.ClientID {
462				clientMismatch = true
463			} else {
464				oldSnap = ac.transportSnapshot()
465				credentialMatch = oldSnap.transport != nil
466			}
467			break
468		}
469		sess.mu.Unlock()
470	}
471	// Teardown may have parked the credential while we resolved the session.
472	if d.resume.parkedFor(h.ResumeToken) != nil {
473		d.mu.Unlock()
474		return d.resumeParked(h, tr, sz)
475	}
476	if credentialMatch && h.SessionTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.SessionTarget) {
477		d.mu.Unlock()
478		return nil, nil, false, &protoErr{protocol.ErrNoSuchTarget, "remote target no longer matches resumed session"}
479	}
480	d.mu.Unlock()
481
482	if clientMismatch {
483		reason := "client id mismatch"
484		d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
485		return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
486	}
487	if !credentialMatch {
488		// Detach may have won and not published parked yet; wait out that gap
489		// for the exact same-client credential, then try the parked path.
490		_ = d.waitParkingInFlight(h)
491		return d.resumeParked(h, tr, sz)
492	}
493
494	if d.beforeMarkParkingInFlight != nil {
495		d.beforeMarkParkingInFlight()
496	}
497	// Advertise parking before detach so a concurrent same-token resume never
498	// observes both the live seat and parking/parked registries empty. Late
499	// publication after terminal detach/session removal returns 0.
500	parkingToken := d.markParkingInFlight(sess, ac)
501	if !d.detachIfCurrentTransport(sess, ac, oldSnap) {
502		d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
503		// A newer owner, rebound link, or teardown won; wait if teardown is
504		// still publishing the park, then try the parked path.
505		_ = d.waitParkingInFlight(h)
506		return d.resumeParked(h, tr, sz)
507	}
508	if d.afterResumeLiveDetach != nil {
509		d.afterResumeLiveDetach()
510	}
511	if rc := sess.renderCoordinator(); rc != nil {
512		rc.noteDetach(ac)
513	}
514	sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
515	if !d.parkAttachment(sess, ac) {
516		// Detach already published; retire the captured link exactly once so
517		// shutdown cannot leave an orphaned open transport with no owner.
518		d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
519		d.resetScreenDefaultColors(sess)
520		_ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
521		d.mu.Lock()
522		closing := d.closing
523		d.mu.Unlock()
524		if closing {
525			return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
526		}
527		return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
528	}
529	_ = ac.closeCapturedTransport(oldSnap.transport)
530	d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot())
531	return d.resumeParked(h, tr, sz)
532}
533
534// resumeParked acquires the parked client's output lock before the daemon
535// registry lock, preserving the global sendMu > Daemon.mu ordering. The parked
536// entry is revalidated after both locks are held because it may expire between
537// the initial lookup and lock acquisition.
538func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
539	d.mu.Lock()
540	parked := d.resume.parkedFor(h.ResumeToken)
541	d.mu.Unlock()
542	if parked == nil || h.ResumeToken == 0 {
543		return nil, nil, false, nil
544	}
545
546	ac := parked.ac
547	if d.beforeResumeParkedSendMu != nil {
548		d.beforeResumeParkedSendMu()
549	}
550	ac.sendMu.Lock()
551	d.mu.Lock()
552	if d.closing {
553		d.mu.Unlock()
554		ac.sendMu.Unlock()
555		return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
556	}
557	if d.resume.parkedFor(h.ResumeToken) != parked || parked.claimed {
558		if d.sessions[parked.sess.id] == parked.sess {
559			d.mu.Unlock()
560			ac.sendMu.Unlock()
561			return nil, nil, false, errResumeTokenLifecycleRace
562		}
563		d.mu.Unlock()
564		ac.sendMu.Unlock()
565		return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
566	}
567	colorBefore := ac.terminalCapabilities.Color.Mode
568	sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
569	colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore
570	d.mu.Unlock()
571	ac.sendMu.Unlock()
572	if err == nil && ok && sess != nil && resumed != nil {
573		// Resume publication normally schedules reconciliation asynchronously;
574		// complete the claiming terminal's geometry before the resumed
575		// attachment is allowed to produce its first frame.
576		sess.geometry.reconcile(d, sess, resumed)
577		if colorChanged {
578			// The applied theme (chrome styles) was resolved for the previous
579			// terminal's color mode; re-resolve it for the new one before the
580			// first paint, and tell the user about a declared downgrade just as a
581			// fresh attach would.
582			d.publishColorDowngradeNotice(sess, resumed)
583			d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)
584		}
585		// A resumed unsupported attachment bypasses finishAttachedClient. Check
586		// the restored scene at this boundary so suppression is explained once.
587		d.warnUnsupportedGraphics(resumed)
588	}
589	if err != nil && resumed != nil {
590		d.abortResumeClaim(resumed)
591	}
592	return sess, resumed, ok, err
593}
594
595// resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
596// and d.mu in that order.
597func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
598	parked := d.resume.parkedFor(h.ResumeToken)
599	if parked == nil || h.ResumeToken == 0 {
600		return nil, nil, false, nil
601	}
602	sess := parked.sess
603	registered := d.sessions[sess.id] == sess
604	if !registered {
605		d.removeParkedLocked(h.ResumeToken, parked)
606		d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false)
607		return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
608	}
609	ac := parked.ac
610	if parked.claimed {
611		return nil, nil, false, errResumeTokenLifecycleRace
612	}
613	if ac.clientID != h.ClientID {
614		reason := "client id mismatch"
615		d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
616		return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
617	}
618	if h.SessionTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.SessionTarget) {
619		return nil, nil, false, &protoErr{protocol.ErrNoSuchTarget, "remote target no longer matches resumed session"}
620	}
621	// Claim the attachment while retaining the old credential until Welcome is
622	// accepted. A failed handshake can therefore restore this exact parked
623	// entry; another resume sees claimed and loses atomically.
624	parked.claimed = true
625	ac.resumeClaimToken = h.ResumeToken
626	// The parked attachment has no session owner and cannot paint. Retire the
627	// abandoned transport's output chain before binding the replacement so the
628	// mandatory first paint cannot be blocked by ACKs that died with the link.
629	ac.rebaseOutput()
630	ac.resetEchoAck()
631	ac.output.setWindow(h.MaxOutputInFlight)
632	ac.replaceTransport(tr)
633	// A parked attachment can resume through any route. Reapply the declared
634	// direct-terminal capability before resumed capture or first paint observes
635	// the replacement transport.
636	d.reconfigureAttachmentOutput(sess, ac, h)
637	// The replacement terminal's focus, too, is declared before first paint.
638	ac.setTerminalFocus(h.TerminalFocus)
639	ac.clientPID.Store(h.ClientPID)
640	ac.setClientEnvironment(helloClientEnvironment(h))
641	geometry := h.Geometry()
642	if geometry.Size != sz {
643		geometry = domain.Geometry{Size: sz}
644	}
645	ac.setGeometry(geometry)
646	ac.resumeToken = d.nextResumeTokenLocked()
647	ac.parked = false
648	preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
649	// Resume preparation used sendMu -> d.mu, but freeze/drain must run with
650	// neither held. Reacquire them before returning to preserve this helper's
651	// locked-caller contract.
652	d.mu.Unlock()
653	ac.sendMu.Unlock()
654	transition, err := d.transitionAttachment(attachmentTransitionRequest{
655		target: sess,
656		next:   ac,
657
658		expectedTransport: ac.transportSnapshot(),
659		activateTargetTab: preferredTabIndex >= 0,
660		targetTabIndex:    preferredTabIndex,
661		ready:             false,
662	})
663	ac.sendMu.Lock()
664	d.mu.Lock()
665	if err != nil {
666		return sess, ac, false, &protoErr{protocol.ErrInternal, "resume attachment transition failed"}
667	}
668	d.deferAttachmentTransitionCleanups(transition)
669	d.touchMRU(sess)
670	d.log.Info("client resumed", "session", sess.nameSnapshot())
671	return sess, ac, true, nil
672}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close