main ece7682

session.go RawHistoryFolder

1// Package protocol defines vev's typed, transport-neutral client/daemon
2// contract. Binary framing and codecs live outside this package.
3package protocol
4
5import (
6	"errors"
7	"fmt"
8	"math"
9	"time"
10
11	renderer "github.com/bnema/vev-vt"
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/domain/terminalcap"
14)
15
16// Version is the negotiated vev session protocol version. It carries the
17// daemonmux physical preamble conversation (multiplex.proto), whose
18// negotiated ceilings and accepted daemon binding ride the same wire
19// version as the session and broker conversations.
20const Version uint16 = 67
21
22// HandshakeTimeout bounds every transport handshake from connect through the
23// first committed publication. It excludes the preceding client-local
24// warm-reuse probe, which has its own short budget: a cold dial that follows
25// a warm miss still receives the full handshake budget.
26const HandshakeTimeout = 15 * time.Second
27
28// MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
29// valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab
30// and pane IDs, including string lengths) within the 16 MiB frame limit.
31const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128)
32
33// MaxOutputWindow caps output states sent before client acknowledgement.
34const MaxOutputWindow = 8
35
36// MaxOutputWindowBytes caps the Output data a daemon keeps unacknowledged per
37// attachment, state frames and side effects together. A full window of large
38// frames would otherwise overrun every bounded buffer between daemon and
39// terminal. The daemon may always send one frame when none is unacknowledged,
40// so a single frame up to MaxOutputDataLen still makes progress.
41const MaxOutputWindowBytes = 2 << 20
42
43// ConnectionCapabilities describes transport behavior relevant to session
44// flow without naming a concrete carriage.
45type ConnectionCapabilities struct {
46	OutputDataLimit       int
47	PreferredOutputWindow uint8
48	AsyncSend             bool
49	OwnedSynchronousSend  bool
50	LinkState             bool
51}
52
53var (
54	ErrInvalidSize              = errors.New("ports: invalid terminal size")
55	ErrInvalidHello             = errors.New("ports: invalid hello")
56	ErrInvalidOutput            = errors.New("ports: invalid output")
57	ErrInvalidAck               = errors.New("ports: invalid ack")
58	ErrInvalidAttachTarget      = errors.New("ports: invalid attach target")
59	ErrInvalidNavigation        = errors.New("ports: invalid navigation")
60	ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")
61	ErrInvalidClientNotice      = errors.New("ports: invalid client notice")
62	ErrInvalidTerminalFocus     = errors.New("ports: invalid terminal focus")
63	ErrInvalidDetached          = errors.New("ports: invalid detached reason")
64)
65
66const (
67	IntentEphemeral uint8 = 0
68	IntentNew       uint8 = 1
69	IntentAttach    uint8 = 2
70	IntentResume    uint8 = 3
71)
72
73type EnvironmentPolicy uint8
74
75const (
76	EnvironmentPolicyClientOwned EnvironmentPolicy = iota
77	EnvironmentPolicyDaemonOwned
78)
79
80const (
81	CapabilityResume  uint32 = 1 << 0
82	CapabilityUDP     uint32 = 1 << 1
83	CapabilityPredict uint32 = 1 << 2
84)
85
86const (
87	ErrVersionMismatch    uint16 = 1
88	ErrNoSuchSession      uint16 = 2
89	ErrNameTaken          uint16 = 3
90	ErrServerShutdown     uint16 = 4
91	ErrInvalidSessionName uint16 = 5
92	ErrUnknownCommand     uint16 = 6
93	ErrNotScriptable      uint16 = 7
94	ErrInvalidCommandArgs uint16 = 8
95	ErrNoSuchTarget       uint16 = 9
96	ErrAmbiguousTarget    uint16 = 10
97	ErrInternal           uint16 = 255
98)
99
100const (
101	ReasonDetach         uint8 = 0
102	ReasonSessionKilled  uint8 = 1
103	ReasonServerShutdown uint8 = 2
104	ReasonReplaced       uint8 = 3
105	// ReasonDetachToPicker transfers terminal ownership to the client picker.
106	ReasonDetachToPicker uint8 = 4
107)
108
109// Hello is sent by the client immediately after connecting.
110type Hello struct {
111	Version     uint16
112	Intent      uint8
113	ClientID    [16]byte
114	ResumeToken uint64
115	Name        string
116	Size        domain.Size
117	PixelWidth  int
118	PixelHeight int
119	TermEnv     string
120	Cwd         string
121	// Color is the color output capability the client claims for this
122	// attachment. It travels only in Hello; Theme carries no capability.
123	Color terminalcap.ColorCapabilities
124	// KittyDirectGraphics is an explicit declaration that the client has
125	// probed its direct outer terminal and it accepts Kitty graphics output.
126	KittyDirectGraphics bool
127	// KittyKeyboard declares that the client enabled the kitty keyboard
128	// protocol on its outer terminal, so input carries kitty key sequences.
129	KittyKeyboard          bool
130	MaxOutputInFlight      uint8
131	Env                    []string
132	SessionTarget          *SessionAttachTarget
133	ExactTarget            *ExactSessionTarget
134	PreferredTabID         domain.TabStableID
135	EnvironmentPolicy      EnvironmentPolicy
136	NavigationCapabilities NavigationCapabilities
137	Remote                 bool
138	// TerminalFocus is the client's terminal window focus when it attaches,
139	// so the attachment's first paint already knows whether it may be seen.
140	TerminalFocus domain.TerminalFocus
141	// ClientPID is the local client process, exposed to bar scripts. It is 0
142	// for remote attaches, which run on another machine.
143	ClientPID uint32
144}
145
146func (h Hello) Geometry() domain.Geometry {
147	return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels()
148}
149
150type Input struct {
151	InputSeq uint64
152	ActionID uint64
153	Data     []byte
154}
155
156type Resize struct {
157	Size        domain.Size
158	PixelWidth  int
159	PixelHeight int
160}
161
162func (m Resize) Geometry() domain.Geometry {
163	return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
164}
165
166type Theme struct {
167	HasForeground bool
168	Foreground    renderer.RGB
169	HasBackground bool
170	Background    renderer.RGB
171	SchemeKnown   bool
172	Light         bool
173	PaletteKnown  uint16
174	Palette       [16]renderer.RGB
175}
176
177type ImagePush struct {
178	InputSeq uint64
179	Mime     string
180	Data     []byte
181}
182
183type ClientNotice struct{ Action uint8 }
184
185const (
186	ClientNoticeClipboardFallback uint8 = 1
187	ClientNoticeClipboardTooLarge uint8 = 2
188	ClientNoticeLinkDegraded      uint8 = 3
189	ClientNoticeLinkConnected     uint8 = 4
190)
191
192// Detach releases the attachment. Closed reports that the client process is
193// ending, so the daemon applies the ephemeral close-on-exit policy.
194type Detach struct {
195	Closed bool
196}
197type Ping struct{}
198type Pong struct{}
199
200type Ack struct {
201	Epoch uint64
202	State uint64
203}
204
205type Welcome struct {
206	SessionID         string
207	SessionName       string
208	Ephemeral         bool
209	ResumeToken       uint64
210	Capabilities      uint32
211	CommittedIdentity *CommittedRouteIdentity
212}
213
214type ErrorMsg struct {
215	Code uint16
216	Text string
217}
218
219type ViewContext struct {
220	Publication   uint64
221	Route         CommittedRouteIdentity
222	TabID         domain.TabStableID
223	FocusedPaneID domain.PaneStableID
224}
225
226func (c ViewContext) Validate() error {
227	if c.Publication == 0 {
228		return errors.New("invalid view publication")
229	}
230	if err := c.Route.Validate(); err != nil {
231		return err
232	}
233	if err := domain.ValidateTabStableID(c.TabID); err != nil {
234		return err
235	}
236	return domain.ValidatePaneStableID(c.FocusedPaneID)
237}
238
239// EchoAckDelay is how long sequenced input must have been applied before the
240// daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the
241// PTY time to echo the input, so the screen published with the acknowledgement
242// already contains that echo. Clients remove it from round-trip samples.
243const EchoAckDelay = 50 * time.Millisecond
244
245type Output struct {
246	Epoch        uint64
247	Base         uint64
248	New          uint64
249	Echo         uint64
250	ViewRevision uint64
251	Size         domain.Size
252	Full         bool
253	Context      *ViewContext
254	Data         []byte
255}
256
257type UIFence struct{ ActionID uint64 }
258
259type UIReceiptOutcome uint8
260
261const (
262	UIReceiptProcessed UIReceiptOutcome = iota + 1
263	UIReceiptUnavailable
264)
265
266type UIReceipt struct {
267	ActionID        uint64
268	Epoch           uint64
269	State           uint64
270	ViewPublication uint64
271	Outcome         UIReceiptOutcome
272}
273
274// Validate requires a committed publication for success. Unavailable receipts
275// carry no boundary: expiry cannot claim that any captured view was processed.
276func (r UIReceipt) Validate() error {
277	if r.ActionID == 0 {
278		return errors.New("UI receipt requires an action ID")
279	}
280	switch r.Outcome {
281	case UIReceiptProcessed:
282		if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
283			return nil
284		}
285	case UIReceiptUnavailable:
286		if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
287			return nil
288		}
289	}
290	return errors.New("invalid UI receipt boundary or outcome")
291}
292
293type UIViewUpdate struct {
294	Epoch   uint64
295	State   uint64
296	Context ViewContext
297}
298
299type AttachTarget struct {
300	CauseActionID     uint64
301	RequestID         uint64
302	Endpoint          string
303	Session           string
304	Intent            uint8
305	SessionTarget     *SessionAttachTarget
306	ExactTarget       *ExactSessionTarget
307	EnvironmentPolicy EnvironmentPolicy
308	SamePeer          bool
309	// PreferredTabID carries an explicit local tab selection across a handoff.
310	// Empty retains the client's route memory; a disappeared tab uses Hello's
311	// existing default-tab fallback.
312	PreferredTabID domain.TabStableID
313}
314
315type Detached struct{ Reason uint8 }
316type List struct{}
317
318type KillScope uint8
319
320const (
321	KillSession KillScope = 0
322	// KillDaemon retains the legacy All=true wire value so an older client can
323	// never turn daemon shutdown into destructive session deletion.
324	KillDaemon KillScope = 1
325	KillAll    KillScope = 2
326)
327
328type Kill struct {
329	RequestID uint64
330	Name      string
331	Scope     KillScope
332}
333
334type KillOutcome uint8
335
336const (
337	KillSucceeded KillOutcome = iota + 1
338	KillFailed
339	KillOutcomeUnknown
340)
341
342type KillFailure struct {
343	Class string
344	Name  string
345	Text  string
346}
347
348type KillResult struct {
349	RequestID uint64
350	Outcome   KillOutcome
351	Code      uint16
352	Text      string
353	Failures  []KillFailure
354}
355
356type SessionState uint8
357
358const (
359	SessionUp SessionState = iota
360	SessionDown
361	SessionBroken
362)
363
364type SessionInfo struct {
365	SessionID string
366	Name      string
367	State     SessionState
368	Ephemeral bool
369	Tabs      uint16
370	Attached  bool
371}
372
373type Sessions struct{ Sessions []SessionInfo }
374type OutputResetRequest struct{}
375
376// SelectTab asks the daemon to show one exact tab of the attached session on
377// this attachment's own view. It is the client picker's in-place tab switch:
378// the attachment and its stream stay live, and a tab that no longer exists is
379// refused without changing the view.
380type SelectTab struct {
381	TabID domain.TabStableID
382}
383
384// Validate requires a well-formed stable tab identity.
385func (m SelectTab) Validate() error {
386	return domain.ValidateTabStableID(m.TabID)
387}
388
389// TerminalFocus reports that the client's terminal window gained or lost
390// focus. A client sends it only after its terminal reports focus, so an
391// attachment that never sends it keeps unknown focus.
392type TerminalFocus struct {
393	Focus domain.TerminalFocus
394}
395
396// Validate accepts only a reported state: unknown is the absence of a report.
397func (m TerminalFocus) Validate() error {
398	if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
399		return ErrInvalidTerminalFocus
400	}
401	return nil
402}
403
404// ModifierState reports the modifier keys held on their own in the client's
405// terminal. A client sends it only when its terminal reports modifier key
406// events, so an attachment that never sends it shows no modifier held.
407type ModifierState struct {
408	Ctrl bool
409}
410
411func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
412	return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
413}
414
415func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
416	if !validEnvironmentPolicy(policy) {
417		return ErrInvalidEnvironmentPolicy
418	}
419	return nil
420}
421
422func validClientNoticeAction(action uint8) bool {
423	switch action {
424	case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
425		return true
426	default:
427		return false
428	}
429}
430
431func validDetachedReason(reason uint8) bool {
432	switch reason {
433	case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
434		return true
435	default:
436		return false
437	}
438}
439
440func ValidateClientNotice(m ClientNotice) error {
441	if !validClientNoticeAction(m.Action) {
442		return ErrInvalidClientNotice
443	}
444	return nil
445}
446
447func ValidateDetached(m Detached) error {
448	if !validDetachedReason(m.Reason) {
449		return ErrInvalidDetached
450	}
451	return nil
452}
453
454func ValidateHello(h Hello) error {
455	if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
456		return ErrInvalidHello
457	}
458	if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
459		return fmt.Errorf("%w: geometry", ErrInvalidHello)
460	}
461	if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
462		return ErrInvalidHello
463	}
464	if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
465		return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
466	}
467	if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
468		return ErrInvalidHello
469	}
470	for _, entry := range h.Env {
471		if uint64(len(entry)) > math.MaxUint32 {
472			return ErrInvalidHello
473		}
474	}
475	if h.PreferredTabID != "" {
476		if h.Intent != IntentAttach && h.Intent != IntentResume {
477			return ErrInvalidHello
478		}
479		if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
480			return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
481		}
482	}
483	if h.ExactTarget != nil {
484		if h.Intent != IntentAttach && h.Intent != IntentResume {
485			return ErrInvalidHello
486		}
487		if err := h.ExactTarget.Validate(); err != nil {
488			return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
489		}
490		if h.Name != h.ExactTarget.SessionName {
491			return ErrInvalidHello
492		}
493		if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
494			return ErrInvalidHello
495		}
496	}
497	if h.SessionTarget == nil {
498		return nil
499	}
500	if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
501		return ErrInvalidHello
502	}
503	if err := h.SessionTarget.Validate(); err != nil {
504		return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
505	}
506	if h.Name != h.SessionTarget.SessionName {
507		return ErrInvalidHello
508	}
509	return nil
510}
511
512func ValidateOutput(m Output) error {
513	if m.Epoch == 0 {
514		return ErrInvalidOutput
515	}
516	if m.New == 0 {
517		if m.Base != 0 || m.Full || m.Context != nil {
518			return ErrInvalidOutput
519		}
520	} else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
521		return ErrInvalidOutput
522	}
523	if err := ValidateSize(m.Size); err != nil {
524		return fmt.Errorf("%w: size", ErrInvalidOutput)
525	}
526	if m.Context != nil {
527		if err := m.Context.Validate(); err != nil {
528			return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
529		}
530	}
531	if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
532		return ErrInvalidOutput
533	}
534	return nil
535}
536
537func ValidateAck(m Ack) error {
538	if m.Epoch == 0 {
539		return ErrInvalidAck
540	}
541	return nil
542}
543
544func ValidateAttachTarget(m AttachTarget) error {
545	if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
546		return ErrInvalidAttachTarget
547	}
548	if err := domain.ValidateSessionName(m.Session); err != nil {
549		return ErrInvalidAttachTarget
550	}
551	if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
552		return ErrInvalidAttachTarget
553	}
554	if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
555		return ErrInvalidAttachTarget
556	}
557	if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
558		return ErrInvalidAttachTarget
559	}
560	if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
561		return ErrInvalidAttachTarget
562	}
563	if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
564		return ErrInvalidAttachTarget
565	}
566	if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
567		return ErrInvalidAttachTarget
568	}
569	if m.SessionTarget == nil {
570		return nil
571	}
572	if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
573		return ErrInvalidAttachTarget
574	}
575	if err := m.SessionTarget.Validate(); err != nil {
576		return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
577	}
578	if m.Session != m.SessionTarget.SessionName {
579		return ErrInvalidAttachTarget
580	}
581	return nil
582}

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