v0.8.1 1f54f61

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 = 66
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
404func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
405	return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
406}
407
408func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
409	if !validEnvironmentPolicy(policy) {
410		return ErrInvalidEnvironmentPolicy
411	}
412	return nil
413}
414
415func validClientNoticeAction(action uint8) bool {
416	switch action {
417	case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
418		return true
419	default:
420		return false
421	}
422}
423
424func validDetachedReason(reason uint8) bool {
425	switch reason {
426	case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
427		return true
428	default:
429		return false
430	}
431}
432
433func ValidateClientNotice(m ClientNotice) error {
434	if !validClientNoticeAction(m.Action) {
435		return ErrInvalidClientNotice
436	}
437	return nil
438}
439
440func ValidateDetached(m Detached) error {
441	if !validDetachedReason(m.Reason) {
442		return ErrInvalidDetached
443	}
444	return nil
445}
446
447func ValidateHello(h Hello) error {
448	if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
449		return ErrInvalidHello
450	}
451	if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
452		return fmt.Errorf("%w: geometry", ErrInvalidHello)
453	}
454	if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
455		return ErrInvalidHello
456	}
457	if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
458		return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
459	}
460	if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
461		return ErrInvalidHello
462	}
463	for _, entry := range h.Env {
464		if uint64(len(entry)) > math.MaxUint32 {
465			return ErrInvalidHello
466		}
467	}
468	if h.PreferredTabID != "" {
469		if h.Intent != IntentAttach && h.Intent != IntentResume {
470			return ErrInvalidHello
471		}
472		if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
473			return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
474		}
475	}
476	if h.ExactTarget != nil {
477		if h.Intent != IntentAttach && h.Intent != IntentResume {
478			return ErrInvalidHello
479		}
480		if err := h.ExactTarget.Validate(); err != nil {
481			return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
482		}
483		if h.Name != h.ExactTarget.SessionName {
484			return ErrInvalidHello
485		}
486		if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
487			return ErrInvalidHello
488		}
489	}
490	if h.SessionTarget == nil {
491		return nil
492	}
493	if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
494		return ErrInvalidHello
495	}
496	if err := h.SessionTarget.Validate(); err != nil {
497		return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
498	}
499	if h.Name != h.SessionTarget.SessionName {
500		return ErrInvalidHello
501	}
502	return nil
503}
504
505func ValidateOutput(m Output) error {
506	if m.Epoch == 0 {
507		return ErrInvalidOutput
508	}
509	if m.New == 0 {
510		if m.Base != 0 || m.Full || m.Context != nil {
511			return ErrInvalidOutput
512		}
513	} else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
514		return ErrInvalidOutput
515	}
516	if err := ValidateSize(m.Size); err != nil {
517		return fmt.Errorf("%w: size", ErrInvalidOutput)
518	}
519	if m.Context != nil {
520		if err := m.Context.Validate(); err != nil {
521			return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
522		}
523	}
524	if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
525		return ErrInvalidOutput
526	}
527	return nil
528}
529
530func ValidateAck(m Ack) error {
531	if m.Epoch == 0 {
532		return ErrInvalidAck
533	}
534	return nil
535}
536
537func ValidateAttachTarget(m AttachTarget) error {
538	if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
539		return ErrInvalidAttachTarget
540	}
541	if err := domain.ValidateSessionName(m.Session); err != nil {
542		return ErrInvalidAttachTarget
543	}
544	if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
545		return ErrInvalidAttachTarget
546	}
547	if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
548		return ErrInvalidAttachTarget
549	}
550	if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
551		return ErrInvalidAttachTarget
552	}
553	if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
554		return ErrInvalidAttachTarget
555	}
556	if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
557		return ErrInvalidAttachTarget
558	}
559	if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
560		return ErrInvalidAttachTarget
561	}
562	if m.SessionTarget == nil {
563		return nil
564	}
565	if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
566		return ErrInvalidAttachTarget
567	}
568	if err := m.SessionTarget.Validate(); err != nil {
569		return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
570	}
571	if m.Session != m.SessionTarget.SessionName {
572		return ErrInvalidAttachTarget
573	}
574	return nil
575}

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