v0.3.0 99dc266

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

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