detached 6b2d35b

feat(ipc): framed binary protocol, unix transport/listener, stale-socket handling

Implements ports.Transport/ports.Listener over AF_UNIX SOCK_STREAM with a
length-prefixed binary framing ([uint32 BE length][uint8 type][payload]),
plus typed payload codecs for all ten message kinds (Hello, Input, Resize,
Detach, Ping, Pong, Welcome, ErrorMsg, Output, Detached). Unmarshal is
defensive: truncated or trailing-garbage payloads error rather than panic.

Listener adds stale-socket recovery: on EADDRINUSE it dial-probes the
existing socket path and either unlinks-and-retries (dead peer) or returns
ErrDaemonRunning (live peer). SocketDir() resolves the daemon's runtime
directory per XDG conventions with /run/user and /tmp fallbacks.
commit
6b2d35b797515eb7d26ede40b19228a755299da3
author
brice · · 2026-07-02 11:08 UTC
committer
brice · · 2026-07-02 11:11 UTC
parent
ffe9b7e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +1368 -0
A internal/adapters/ipc/listener.go +137 -0
@@ -0,0 +1,137 @@
1+package ipc
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// socketFileName is the fixed name of the daemon's listening socket within
15+// its socket directory.
16+const socketFileName = "daemon.sock"
17+
18+// ErrDaemonRunning is returned by Listen when the socket path is already
19+// bound by a live daemon (a dial-probe against it succeeded).
20+var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
21+
22+// unixListener implements ports.Listener over an AF_UNIX SOCK_STREAM
23+// listener.
24+type unixListener struct {
25+ ln *net.UnixListener
26+ addr string
27+}
28+
29+// Listen creates the socket directory (0700) if needed and starts
30+// listening on dir/daemon.sock (chmod 0600 after bind).
31+//
32+// If the socket path is already bound (EADDRINUSE), Listen dial-probes it:
33+// a failed dial (connection refused, or the file having vanished) means
34+// the previous owner died without cleaning up, so the stale socket file is
35+// unlinked and bind is retried once. A successful dial means a live daemon
36+// owns the socket, and Listen returns ErrDaemonRunning.
37+func Listen(dir string) (ports.Listener, error) {
38+ if err := os.MkdirAll(dir, 0o700); err != nil {
39+ return nil, fmt.Errorf("ipc: creating socket directory: %w", err)
40+ }
41+
42+ sockPath := filepath.Join(dir, socketFileName)
43+
44+ ln, err := bindUnix(sockPath)
45+ if err != nil {
46+ if !errors.Is(err, syscall.EADDRINUSE) {
47+ return nil, fmt.Errorf("ipc: listen on %s: %w", sockPath, err)
48+ }
49+
50+ if probeErr := probeLiveDaemon(sockPath); probeErr != nil {
51+ return nil, probeErr
52+ }
53+
54+ // Dial failed: the socket file is stale (its listener died
55+ // without unlinking). Remove it and retry the bind once.
56+ if rmErr := os.Remove(sockPath); rmErr != nil && !os.IsNotExist(rmErr) {
57+ return nil, fmt.Errorf("ipc: removing stale socket %s: %w", sockPath, rmErr)
58+ }
59+
60+ ln, err = bindUnix(sockPath)
61+ if err != nil {
62+ return nil, fmt.Errorf("ipc: listen on %s after removing stale socket: %w", sockPath, err)
63+ }
64+ }
65+
66+ if err := os.Chmod(sockPath, 0o600); err != nil {
67+ ln.Close()
68+ return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)
69+ }
70+
71+ return &unixListener{ln: ln, addr: sockPath}, nil
72+}
73+
74+// bindUnix binds and listens on sockPath.
75+func bindUnix(sockPath string) (*net.UnixListener, error) {
76+ addr, err := net.ResolveUnixAddr("unix", sockPath)
77+ if err != nil {
78+ return nil, err
79+ }
80+ return net.ListenUnix("unix", addr)
81+}
82+
83+// probeLiveDaemon dial-probes sockPath to distinguish a stale socket file
84+// from one actively served by a running daemon. It returns nil if the
85+// socket looks stale (safe to unlink and retry), or ErrDaemonRunning if a
86+// peer accepted the dial.
87+func probeLiveDaemon(sockPath string) error {
88+ conn, err := net.Dial("unix", sockPath)
89+ if err != nil {
90+ if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) {
91+ return nil // stale: nobody home
92+ }
93+ return fmt.Errorf("ipc: probing socket %s: %w", sockPath, err)
94+ }
95+ conn.Close()
96+ return ErrDaemonRunning
97+}
98+
99+// Accept waits for and returns the next connection, wrapped as a
100+// ports.Transport.
101+func (l *unixListener) Accept() (ports.Transport, error) {
102+ conn, err := l.ln.Accept()
103+ if err != nil {
104+ return nil, err
105+ }
106+ return NewTransport(conn), nil
107+}
108+
109+// Close stops accepting connections. The underlying net.UnixListener
110+// unlinks the socket file on close (default behavior for listeners it
111+// created).
112+func (l *unixListener) Close() error {
113+ return l.ln.Close()
114+}
115+
116+// Addr returns the filesystem path of the listening socket.
117+func (l *unixListener) Addr() string {
118+ return l.addr
119+}
120+
121+// SocketDir returns the directory vev's daemon should place its socket in:
122+// $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory
123+// exists, else /tmp/vev-<uid>.
124+func SocketDir() string {
125+ uid := os.Getuid()
126+
127+ if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
128+ return filepath.Join(xdg, "vev")
129+ }
130+
131+ runUser := fmt.Sprintf("/run/user/%d", uid)
132+ if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
133+ return filepath.Join(runUser, "vev")
134+ }
135+
136+ return fmt.Sprintf("/tmp/vev-%d", uid)
137+}
@@ -0,0 +1,137 @@
1+package ipc
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// socketFileName is the fixed name of the daemon's listening socket within
15+// its socket directory.
16+const socketFileName = "daemon.sock"
17+
18+// ErrDaemonRunning is returned by Listen when the socket path is already
19+// bound by a live daemon (a dial-probe against it succeeded).
20+var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
21+
22+// unixListener implements ports.Listener over an AF_UNIX SOCK_STREAM
23+// listener.
24+type unixListener struct {
25+ ln *net.UnixListener
26+ addr string
27+}
28+
29+// Listen creates the socket directory (0700) if needed and starts
30+// listening on dir/daemon.sock (chmod 0600 after bind).
31+//
32+// If the socket path is already bound (EADDRINUSE), Listen dial-probes it:
33+// a failed dial (connection refused, or the file having vanished) means
34+// the previous owner died without cleaning up, so the stale socket file is
35+// unlinked and bind is retried once. A successful dial means a live daemon
36+// owns the socket, and Listen returns ErrDaemonRunning.
37+func Listen(dir string) (ports.Listener, error) {
38+ if err := os.MkdirAll(dir, 0o700); err != nil {
39+ return nil, fmt.Errorf("ipc: creating socket directory: %w", err)
40+ }
41+
42+ sockPath := filepath.Join(dir, socketFileName)
43+
44+ ln, err := bindUnix(sockPath)
45+ if err != nil {
46+ if !errors.Is(err, syscall.EADDRINUSE) {
47+ return nil, fmt.Errorf("ipc: listen on %s: %w", sockPath, err)
48+ }
49+
50+ if probeErr := probeLiveDaemon(sockPath); probeErr != nil {
51+ return nil, probeErr
52+ }
53+
54+ // Dial failed: the socket file is stale (its listener died
55+ // without unlinking). Remove it and retry the bind once.
56+ if rmErr := os.Remove(sockPath); rmErr != nil && !os.IsNotExist(rmErr) {
57+ return nil, fmt.Errorf("ipc: removing stale socket %s: %w", sockPath, rmErr)
58+ }
59+
60+ ln, err = bindUnix(sockPath)
61+ if err != nil {
62+ return nil, fmt.Errorf("ipc: listen on %s after removing stale socket: %w", sockPath, err)
63+ }
64+ }
65+
66+ if err := os.Chmod(sockPath, 0o600); err != nil {
67+ ln.Close()
68+ return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)
69+ }
70+
71+ return &unixListener{ln: ln, addr: sockPath}, nil
72+}
73+
74+// bindUnix binds and listens on sockPath.
75+func bindUnix(sockPath string) (*net.UnixListener, error) {
76+ addr, err := net.ResolveUnixAddr("unix", sockPath)
77+ if err != nil {
78+ return nil, err
79+ }
80+ return net.ListenUnix("unix", addr)
81+}
82+
83+// probeLiveDaemon dial-probes sockPath to distinguish a stale socket file
84+// from one actively served by a running daemon. It returns nil if the
85+// socket looks stale (safe to unlink and retry), or ErrDaemonRunning if a
86+// peer accepted the dial.
87+func probeLiveDaemon(sockPath string) error {
88+ conn, err := net.Dial("unix", sockPath)
89+ if err != nil {
90+ if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) {
91+ return nil // stale: nobody home
92+ }
93+ return fmt.Errorf("ipc: probing socket %s: %w", sockPath, err)
94+ }
95+ conn.Close()
96+ return ErrDaemonRunning
97+}
98+
99+// Accept waits for and returns the next connection, wrapped as a
100+// ports.Transport.
101+func (l *unixListener) Accept() (ports.Transport, error) {
102+ conn, err := l.ln.Accept()
103+ if err != nil {
104+ return nil, err
105+ }
106+ return NewTransport(conn), nil
107+}
108+
109+// Close stops accepting connections. The underlying net.UnixListener
110+// unlinks the socket file on close (default behavior for listeners it
111+// created).
112+func (l *unixListener) Close() error {
113+ return l.ln.Close()
114+}
115+
116+// Addr returns the filesystem path of the listening socket.
117+func (l *unixListener) Addr() string {
118+ return l.addr
119+}
120+
121+// SocketDir returns the directory vev's daemon should place its socket in:
122+// $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory
123+// exists, else /tmp/vev-<uid>.
124+func SocketDir() string {
125+ uid := os.Getuid()
126+
127+ if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
128+ return filepath.Join(xdg, "vev")
129+ }
130+
131+ runUser := fmt.Sprintf("/run/user/%d", uid)
132+ if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
133+ return filepath.Join(runUser, "vev")
134+ }
135+
136+ return fmt.Sprintf("/tmp/vev-%d", uid)
137+}
A internal/adapters/ipc/listener_test.go +161 -0
@@ -0,0 +1,161 @@
1+package ipc
2+
3+import (
4+ "errors"
5+ "net"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestListenAcceptRoundTrip(t *testing.T) {
15+ dir := t.TempDir()
16+
17+ ln, err := Listen(dir)
18+ if err != nil {
19+ t.Fatalf("Listen() error = %v", err)
20+ }
21+ defer ln.Close()
22+
23+ wantPath := filepath.Join(dir, "daemon.sock")
24+ if ln.Addr() != wantPath {
25+ t.Fatalf("Addr() = %q, want %q", ln.Addr(), wantPath)
26+ }
27+
28+ fi, err := os.Stat(wantPath)
29+ if err != nil {
30+ t.Fatalf("stat socket: %v", err)
31+ }
32+ if perm := fi.Mode().Perm(); perm != 0o600 {
33+ t.Fatalf("socket perm = %o, want 0600", perm)
34+ }
35+
36+ errCh := make(chan error, 1)
37+ go func() {
38+ conn, err := net.Dial("unix", wantPath)
39+ if err != nil {
40+ errCh <- err
41+ return
42+ }
43+ defer conn.Close()
44+ client := NewTransport(conn)
45+ errCh <- client.Send(ports.Frame{Type: ports.MsgPing})
46+ }()
47+
48+ transport, err := ln.Accept()
49+ if err != nil {
50+ t.Fatalf("Accept() error = %v", err)
51+ }
52+ defer transport.Close()
53+
54+ got, err := transport.Recv()
55+ if err != nil {
56+ t.Fatalf("Recv() error = %v", err)
57+ }
58+ if got.Type != ports.MsgPing {
59+ t.Fatalf("Recv() type = %v, want MsgPing", got.Type)
60+ }
61+ if err := <-errCh; err != nil {
62+ t.Fatalf("client dial/send error = %v", err)
63+ }
64+}
65+
66+func TestListenMkdirsSocketDir(t *testing.T) {
67+ base := t.TempDir()
68+ dir := filepath.Join(base, "nested", "socketdir")
69+
70+ ln, err := Listen(dir)
71+ if err != nil {
72+ t.Fatalf("Listen() error = %v", err)
73+ }
74+ defer ln.Close()
75+
76+ fi, err := os.Stat(dir)
77+ if err != nil {
78+ t.Fatalf("stat dir: %v", err)
79+ }
80+ if !fi.IsDir() {
81+ t.Fatalf("%s is not a directory", dir)
82+ }
83+ if perm := fi.Mode().Perm(); perm != 0o700 {
84+ t.Fatalf("dir perm = %o, want 0700", perm)
85+ }
86+}
87+
88+func TestListenStaleSocketCleanedUp(t *testing.T) {
89+ dir := t.TempDir()
90+ sockPath := filepath.Join(dir, "daemon.sock")
91+
92+ // Create a socket file with nobody listening behind it: bind, then
93+ // close without unlinking, to simulate a daemon that died without
94+ // cleaning up after itself.
95+ addr, err := net.ResolveUnixAddr("unix", sockPath)
96+ if err != nil {
97+ t.Fatalf("ResolveUnixAddr() error = %v", err)
98+ }
99+ dead, err := net.ListenUnix("unix", addr)
100+ if err != nil {
101+ t.Fatalf("ListenUnix() error = %v", err)
102+ }
103+ dead.SetUnlinkOnClose(false)
104+ if err := dead.Close(); err != nil {
105+ t.Fatalf("closing dead listener: %v", err)
106+ }
107+ if _, err := os.Stat(sockPath); err != nil {
108+ t.Fatalf("expected stale socket file to exist: %v", err)
109+ }
110+
111+ // A real Listen() should detect the dead peer and recover.
112+ ln, err := Listen(dir)
113+ if err != nil {
114+ t.Fatalf("Listen() over stale socket error = %v, want success", err)
115+ }
116+ defer ln.Close()
117+}
118+
119+func TestListenLiveDaemonRejected(t *testing.T) {
120+ dir := t.TempDir()
121+
122+ first, err := Listen(dir)
123+ if err != nil {
124+ t.Fatalf("first Listen() error = %v", err)
125+ }
126+ defer first.Close()
127+
128+ _, err = Listen(dir)
129+ if !errors.Is(err, ErrDaemonRunning) {
130+ t.Fatalf("second Listen() error = %v, want ErrDaemonRunning", err)
131+ }
132+}
133+
134+func TestSocketDirUsesXDGRuntimeDir(t *testing.T) {
135+ t.Setenv("XDG_RUNTIME_DIR", "/tmp/example-xdg")
136+
137+ got := SocketDir()
138+ want := "/tmp/example-xdg/vev"
139+ if got != want {
140+ t.Fatalf("SocketDir() = %q, want %q", got, want)
141+ }
142+}
143+
144+func TestSocketDirFallsBackWithoutXDGRuntimeDir(t *testing.T) {
145+ t.Setenv("XDG_RUNTIME_DIR", "")
146+
147+ got := SocketDir()
148+ uid := os.Getuid()
149+
150+ runUser := filepath.Join("/run/user", strconv.Itoa(uid))
151+ var want string
152+ if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
153+ want = filepath.Join(runUser, "vev")
154+ } else {
155+ want = "/tmp/vev-" + strconv.Itoa(uid)
156+ }
157+
158+ if got != want {
159+ t.Fatalf("SocketDir() = %q, want %q", got, want)
160+ }
161+}
@@ -0,0 +1,161 @@
1+package ipc
2+
3+import (
4+ "errors"
5+ "net"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestListenAcceptRoundTrip(t *testing.T) {
15+ dir := t.TempDir()
16+
17+ ln, err := Listen(dir)
18+ if err != nil {
19+ t.Fatalf("Listen() error = %v", err)
20+ }
21+ defer ln.Close()
22+
23+ wantPath := filepath.Join(dir, "daemon.sock")
24+ if ln.Addr() != wantPath {
25+ t.Fatalf("Addr() = %q, want %q", ln.Addr(), wantPath)
26+ }
27+
28+ fi, err := os.Stat(wantPath)
29+ if err != nil {
30+ t.Fatalf("stat socket: %v", err)
31+ }
32+ if perm := fi.Mode().Perm(); perm != 0o600 {
33+ t.Fatalf("socket perm = %o, want 0600", perm)
34+ }
35+
36+ errCh := make(chan error, 1)
37+ go func() {
38+ conn, err := net.Dial("unix", wantPath)
39+ if err != nil {
40+ errCh <- err
41+ return
42+ }
43+ defer conn.Close()
44+ client := NewTransport(conn)
45+ errCh <- client.Send(ports.Frame{Type: ports.MsgPing})
46+ }()
47+
48+ transport, err := ln.Accept()
49+ if err != nil {
50+ t.Fatalf("Accept() error = %v", err)
51+ }
52+ defer transport.Close()
53+
54+ got, err := transport.Recv()
55+ if err != nil {
56+ t.Fatalf("Recv() error = %v", err)
57+ }
58+ if got.Type != ports.MsgPing {
59+ t.Fatalf("Recv() type = %v, want MsgPing", got.Type)
60+ }
61+ if err := <-errCh; err != nil {
62+ t.Fatalf("client dial/send error = %v", err)
63+ }
64+}
65+
66+func TestListenMkdirsSocketDir(t *testing.T) {
67+ base := t.TempDir()
68+ dir := filepath.Join(base, "nested", "socketdir")
69+
70+ ln, err := Listen(dir)
71+ if err != nil {
72+ t.Fatalf("Listen() error = %v", err)
73+ }
74+ defer ln.Close()
75+
76+ fi, err := os.Stat(dir)
77+ if err != nil {
78+ t.Fatalf("stat dir: %v", err)
79+ }
80+ if !fi.IsDir() {
81+ t.Fatalf("%s is not a directory", dir)
82+ }
83+ if perm := fi.Mode().Perm(); perm != 0o700 {
84+ t.Fatalf("dir perm = %o, want 0700", perm)
85+ }
86+}
87+
88+func TestListenStaleSocketCleanedUp(t *testing.T) {
89+ dir := t.TempDir()
90+ sockPath := filepath.Join(dir, "daemon.sock")
91+
92+ // Create a socket file with nobody listening behind it: bind, then
93+ // close without unlinking, to simulate a daemon that died without
94+ // cleaning up after itself.
95+ addr, err := net.ResolveUnixAddr("unix", sockPath)
96+ if err != nil {
97+ t.Fatalf("ResolveUnixAddr() error = %v", err)
98+ }
99+ dead, err := net.ListenUnix("unix", addr)
100+ if err != nil {
101+ t.Fatalf("ListenUnix() error = %v", err)
102+ }
103+ dead.SetUnlinkOnClose(false)
104+ if err := dead.Close(); err != nil {
105+ t.Fatalf("closing dead listener: %v", err)
106+ }
107+ if _, err := os.Stat(sockPath); err != nil {
108+ t.Fatalf("expected stale socket file to exist: %v", err)
109+ }
110+
111+ // A real Listen() should detect the dead peer and recover.
112+ ln, err := Listen(dir)
113+ if err != nil {
114+ t.Fatalf("Listen() over stale socket error = %v, want success", err)
115+ }
116+ defer ln.Close()
117+}
118+
119+func TestListenLiveDaemonRejected(t *testing.T) {
120+ dir := t.TempDir()
121+
122+ first, err := Listen(dir)
123+ if err != nil {
124+ t.Fatalf("first Listen() error = %v", err)
125+ }
126+ defer first.Close()
127+
128+ _, err = Listen(dir)
129+ if !errors.Is(err, ErrDaemonRunning) {
130+ t.Fatalf("second Listen() error = %v, want ErrDaemonRunning", err)
131+ }
132+}
133+
134+func TestSocketDirUsesXDGRuntimeDir(t *testing.T) {
135+ t.Setenv("XDG_RUNTIME_DIR", "/tmp/example-xdg")
136+
137+ got := SocketDir()
138+ want := "/tmp/example-xdg/vev"
139+ if got != want {
140+ t.Fatalf("SocketDir() = %q, want %q", got, want)
141+ }
142+}
143+
144+func TestSocketDirFallsBackWithoutXDGRuntimeDir(t *testing.T) {
145+ t.Setenv("XDG_RUNTIME_DIR", "")
146+
147+ got := SocketDir()
148+ uid := os.Getuid()
149+
150+ runUser := filepath.Join("/run/user", strconv.Itoa(uid))
151+ var want string
152+ if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
153+ want = filepath.Join(runUser, "vev")
154+ } else {
155+ want = "/tmp/vev-" + strconv.Itoa(uid)
156+ }
157+
158+ if got != want {
159+ t.Fatalf("SocketDir() = %q, want %q", got, want)
160+ }
161+}
A internal/adapters/ipc/protocol.go +380 -0
@@ -0,0 +1,380 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// errShortPayload is returned when a payload ends before a required field
11+// has been fully read.
12+var errShortPayload = errors.New("ipc: payload too short")
13+
14+// errTrailingBytes is returned when a fixed-shape payload has bytes left
15+// over after every field has been consumed. Protocol strictness here
16+// catches version drift early instead of silently ignoring extra data.
17+var errTrailingBytes = errors.New("ipc: unexpected trailing bytes in payload")
18+
19+// Intent values carried by Hello, describing what the client wants to do.
20+const (
21+ IntentEphemeral uint8 = 0
22+ IntentNew uint8 = 1
23+ IntentAttach uint8 = 2
24+)
25+
26+// ErrorMsg codes.
27+const (
28+ ErrVersionMismatch uint16 = 1
29+ ErrNoSuchSession uint16 = 2
30+ ErrNameTaken uint16 = 3
31+ ErrInternal uint16 = 255
32+)
33+
34+// Detached reasons.
35+const (
36+ ReasonDetach uint8 = 0
37+ ReasonSessionKilled uint8 = 1
38+ ReasonServerShutdown uint8 = 2
39+)
40+
41+// Hello is sent by the client immediately after connecting.
42+type Hello struct {
43+ Version uint16
44+ Intent uint8
45+ Name string
46+ Size domain.Size
47+ TermEnv string
48+}
49+
50+// Input carries raw bytes typed/pasted by the client, destined for the PTY.
51+type Input struct {
52+ Data []byte
53+}
54+
55+// Resize notifies the daemon of a client-side terminal size change.
56+type Resize struct {
57+ Size domain.Size
58+}
59+
60+// Detach asks the daemon to detach the current client without killing the
61+// session.
62+type Detach struct{}
63+
64+// Ping is a keepalive/liveness probe sent by either side.
65+type Ping struct{}
66+
67+// Pong answers a Ping.
68+type Pong struct{}
69+
70+// Welcome is the daemon's reply to a successful Hello.
71+type Welcome struct {
72+ SessionID string
73+ SessionName string
74+ Ephemeral bool
75+}
76+
77+// ErrorMsg reports a protocol- or session-level failure to the client.
78+type ErrorMsg struct {
79+ Code uint16
80+ Text string
81+}
82+
83+// Output carries raw PTY output, destined for the client's terminal.
84+type Output struct {
85+ Data []byte
86+}
87+
88+// Detached tells a client it has been disconnected from its session and why.
89+type Detached struct {
90+ Reason uint8
91+}
92+
93+// payloadWriter builds a message payload by appending fields in wire order.
94+type payloadWriter struct {
95+ b []byte
96+}
97+
98+func (w *payloadWriter) putUint8(v uint8) {
99+ w.b = append(w.b, v)
100+}
101+
102+func (w *payloadWriter) putUint16(v uint16) {
103+ var tmp [2]byte
104+ binary.BigEndian.PutUint16(tmp[:], v)
105+ w.b = append(w.b, tmp[:]...)
106+}
107+
108+func (w *payloadWriter) putString(s string) {
109+ w.putUint16(uint16(len(s)))
110+ w.b = append(w.b, s...)
111+}
112+
113+// payloadReader consumes a message payload field by field in wire order,
114+// erroring instead of panicking on any short read.
115+type payloadReader struct {
116+ b []byte
117+}
118+
119+func (r *payloadReader) getUint8() (uint8, error) {
120+ if len(r.b) < 1 {
121+ return 0, errShortPayload
122+ }
123+ v := r.b[0]
124+ r.b = r.b[1:]
125+ return v, nil
126+}
127+
128+func (r *payloadReader) getUint16() (uint16, error) {
129+ if len(r.b) < 2 {
130+ return 0, errShortPayload
131+ }
132+ v := binary.BigEndian.Uint16(r.b)
133+ r.b = r.b[2:]
134+ return v, nil
135+}
136+
137+func (r *payloadReader) getString() (string, error) {
138+ n, err := r.getUint16()
139+ if err != nil {
140+ return "", err
141+ }
142+ if len(r.b) < int(n) {
143+ return "", errShortPayload
144+ }
145+ s := string(r.b[:n])
146+ r.b = r.b[n:]
147+ return s, nil
148+}
149+
150+// rest consumes and returns all remaining bytes, copied so the result is
151+// independent of the reader's backing array.
152+func (r *payloadReader) rest() []byte {
153+ b := append([]byte(nil), r.b...)
154+ r.b = nil
155+ return b
156+}
157+
158+// done reports an error if any bytes remain unconsumed.
159+func (r *payloadReader) done() error {
160+ if len(r.b) != 0 {
161+ return errTrailingBytes
162+ }
163+ return nil
164+}
165+
166+// MarshalHello encodes h into a Hello message payload.
167+func MarshalHello(h Hello) []byte {
168+ w := payloadWriter{}
169+ w.putUint16(h.Version)
170+ w.putUint8(h.Intent)
171+ w.putString(h.Name)
172+ w.putUint16(uint16(h.Size.Cols))
173+ w.putUint16(uint16(h.Size.Rows))
174+ w.putString(h.TermEnv)
175+ return w.b
176+}
177+
178+// UnmarshalHello decodes a Hello message payload.
179+func UnmarshalHello(b []byte) (Hello, error) {
180+ r := payloadReader{b: b}
181+ var h Hello
182+ var err error
183+
184+ if h.Version, err = r.getUint16(); err != nil {
185+ return Hello{}, err
186+ }
187+ if h.Intent, err = r.getUint8(); err != nil {
188+ return Hello{}, err
189+ }
190+ if h.Name, err = r.getString(); err != nil {
191+ return Hello{}, err
192+ }
193+ cols, err := r.getUint16()
194+ if err != nil {
195+ return Hello{}, err
196+ }
197+ rows, err := r.getUint16()
198+ if err != nil {
199+ return Hello{}, err
200+ }
201+ h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
202+ if h.TermEnv, err = r.getString(); err != nil {
203+ return Hello{}, err
204+ }
205+ if err := r.done(); err != nil {
206+ return Hello{}, err
207+ }
208+ return h, nil
209+}
210+
211+// MarshalInput encodes m into an Input message payload.
212+func MarshalInput(m Input) []byte {
213+ return append([]byte(nil), m.Data...)
214+}
215+
216+// UnmarshalInput decodes an Input message payload. The whole payload is the
217+// data; there is no separate length prefix.
218+func UnmarshalInput(b []byte) (Input, error) {
219+ r := payloadReader{b: b}
220+ return Input{Data: r.rest()}, nil
221+}
222+
223+// MarshalResize encodes m into a Resize message payload.
224+func MarshalResize(m Resize) []byte {
225+ w := payloadWriter{}
226+ w.putUint16(uint16(m.Size.Cols))
227+ w.putUint16(uint16(m.Size.Rows))
228+ return w.b
229+}
230+
231+// UnmarshalResize decodes a Resize message payload.
232+func UnmarshalResize(b []byte) (Resize, error) {
233+ r := payloadReader{b: b}
234+ cols, err := r.getUint16()
235+ if err != nil {
236+ return Resize{}, err
237+ }
238+ rows, err := r.getUint16()
239+ if err != nil {
240+ return Resize{}, err
241+ }
242+ if err := r.done(); err != nil {
243+ return Resize{}, err
244+ }
245+ return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil
246+}
247+
248+// MarshalDetach encodes a Detach message payload (always empty).
249+func MarshalDetach(Detach) []byte {
250+ return nil
251+}
252+
253+// UnmarshalDetach decodes a Detach message payload.
254+func UnmarshalDetach(b []byte) (Detach, error) {
255+ r := payloadReader{b: b}
256+ if err := r.done(); err != nil {
257+ return Detach{}, err
258+ }
259+ return Detach{}, nil
260+}
261+
262+// MarshalPing encodes a Ping message payload (always empty).
263+func MarshalPing(Ping) []byte {
264+ return nil
265+}
266+
267+// UnmarshalPing decodes a Ping message payload.
268+func UnmarshalPing(b []byte) (Ping, error) {
269+ r := payloadReader{b: b}
270+ if err := r.done(); err != nil {
271+ return Ping{}, err
272+ }
273+ return Ping{}, nil
274+}
275+
276+// MarshalPong encodes a Pong message payload (always empty).
277+func MarshalPong(Pong) []byte {
278+ return nil
279+}
280+
281+// UnmarshalPong decodes a Pong message payload.
282+func UnmarshalPong(b []byte) (Pong, error) {
283+ r := payloadReader{b: b}
284+ if err := r.done(); err != nil {
285+ return Pong{}, err
286+ }
287+ return Pong{}, nil
288+}
289+
290+// MarshalWelcome encodes m into a Welcome message payload.
291+func MarshalWelcome(m Welcome) []byte {
292+ w := payloadWriter{}
293+ w.putString(m.SessionID)
294+ w.putString(m.SessionName)
295+ if m.Ephemeral {
296+ w.putUint8(1)
297+ } else {
298+ w.putUint8(0)
299+ }
300+ return w.b
301+}
302+
303+// UnmarshalWelcome decodes a Welcome message payload.
304+func UnmarshalWelcome(b []byte) (Welcome, error) {
305+ r := payloadReader{b: b}
306+ var m Welcome
307+ var err error
308+
309+ if m.SessionID, err = r.getString(); err != nil {
310+ return Welcome{}, err
311+ }
312+ if m.SessionName, err = r.getString(); err != nil {
313+ return Welcome{}, err
314+ }
315+ eph, err := r.getUint8()
316+ if err != nil {
317+ return Welcome{}, err
318+ }
319+ m.Ephemeral = eph != 0
320+ if err := r.done(); err != nil {
321+ return Welcome{}, err
322+ }
323+ return m, nil
324+}
325+
326+// MarshalErrorMsg encodes m into an ErrorMsg message payload.
327+func MarshalErrorMsg(m ErrorMsg) []byte {
328+ w := payloadWriter{}
329+ w.putUint16(m.Code)
330+ w.putString(m.Text)
331+ return w.b
332+}
333+
334+// UnmarshalErrorMsg decodes an ErrorMsg message payload.
335+func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
336+ r := payloadReader{b: b}
337+ var m ErrorMsg
338+ var err error
339+
340+ if m.Code, err = r.getUint16(); err != nil {
341+ return ErrorMsg{}, err
342+ }
343+ if m.Text, err = r.getString(); err != nil {
344+ return ErrorMsg{}, err
345+ }
346+ if err := r.done(); err != nil {
347+ return ErrorMsg{}, err
348+ }
349+ return m, nil
350+}
351+
352+// MarshalOutput encodes m into an Output message payload.
353+func MarshalOutput(m Output) []byte {
354+ return append([]byte(nil), m.Data...)
355+}
356+
357+// UnmarshalOutput decodes an Output message payload. The whole payload is
358+// the data; there is no separate length prefix.
359+func UnmarshalOutput(b []byte) (Output, error) {
360+ r := payloadReader{b: b}
361+ return Output{Data: r.rest()}, nil
362+}
363+
364+// MarshalDetached encodes m into a Detached message payload.
365+func MarshalDetached(m Detached) []byte {
366+ return []byte{m.Reason}
367+}
368+
369+// UnmarshalDetached decodes a Detached message payload.
370+func UnmarshalDetached(b []byte) (Detached, error) {
371+ r := payloadReader{b: b}
372+ reason, err := r.getUint8()
373+ if err != nil {
374+ return Detached{}, err
375+ }
376+ if err := r.done(); err != nil {
377+ return Detached{}, err
378+ }
379+ return Detached{Reason: reason}, nil
380+}
@@ -0,0 +1,380 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// errShortPayload is returned when a payload ends before a required field
11+// has been fully read.
12+var errShortPayload = errors.New("ipc: payload too short")
13+
14+// errTrailingBytes is returned when a fixed-shape payload has bytes left
15+// over after every field has been consumed. Protocol strictness here
16+// catches version drift early instead of silently ignoring extra data.
17+var errTrailingBytes = errors.New("ipc: unexpected trailing bytes in payload")
18+
19+// Intent values carried by Hello, describing what the client wants to do.
20+const (
21+ IntentEphemeral uint8 = 0
22+ IntentNew uint8 = 1
23+ IntentAttach uint8 = 2
24+)
25+
26+// ErrorMsg codes.
27+const (
28+ ErrVersionMismatch uint16 = 1
29+ ErrNoSuchSession uint16 = 2
30+ ErrNameTaken uint16 = 3
31+ ErrInternal uint16 = 255
32+)
33+
34+// Detached reasons.
35+const (
36+ ReasonDetach uint8 = 0
37+ ReasonSessionKilled uint8 = 1
38+ ReasonServerShutdown uint8 = 2
39+)
40+
41+// Hello is sent by the client immediately after connecting.
42+type Hello struct {
43+ Version uint16
44+ Intent uint8
45+ Name string
46+ Size domain.Size
47+ TermEnv string
48+}
49+
50+// Input carries raw bytes typed/pasted by the client, destined for the PTY.
51+type Input struct {
52+ Data []byte
53+}
54+
55+// Resize notifies the daemon of a client-side terminal size change.
56+type Resize struct {
57+ Size domain.Size
58+}
59+
60+// Detach asks the daemon to detach the current client without killing the
61+// session.
62+type Detach struct{}
63+
64+// Ping is a keepalive/liveness probe sent by either side.
65+type Ping struct{}
66+
67+// Pong answers a Ping.
68+type Pong struct{}
69+
70+// Welcome is the daemon's reply to a successful Hello.
71+type Welcome struct {
72+ SessionID string
73+ SessionName string
74+ Ephemeral bool
75+}
76+
77+// ErrorMsg reports a protocol- or session-level failure to the client.
78+type ErrorMsg struct {
79+ Code uint16
80+ Text string
81+}
82+
83+// Output carries raw PTY output, destined for the client's terminal.
84+type Output struct {
85+ Data []byte
86+}
87+
88+// Detached tells a client it has been disconnected from its session and why.
89+type Detached struct {
90+ Reason uint8
91+}
92+
93+// payloadWriter builds a message payload by appending fields in wire order.
94+type payloadWriter struct {
95+ b []byte
96+}
97+
98+func (w *payloadWriter) putUint8(v uint8) {
99+ w.b = append(w.b, v)
100+}
101+
102+func (w *payloadWriter) putUint16(v uint16) {
103+ var tmp [2]byte
104+ binary.BigEndian.PutUint16(tmp[:], v)
105+ w.b = append(w.b, tmp[:]...)
106+}
107+
108+func (w *payloadWriter) putString(s string) {
109+ w.putUint16(uint16(len(s)))
110+ w.b = append(w.b, s...)
111+}
112+
113+// payloadReader consumes a message payload field by field in wire order,
114+// erroring instead of panicking on any short read.
115+type payloadReader struct {
116+ b []byte
117+}
118+
119+func (r *payloadReader) getUint8() (uint8, error) {
120+ if len(r.b) < 1 {
121+ return 0, errShortPayload
122+ }
123+ v := r.b[0]
124+ r.b = r.b[1:]
125+ return v, nil
126+}
127+
128+func (r *payloadReader) getUint16() (uint16, error) {
129+ if len(r.b) < 2 {
130+ return 0, errShortPayload
131+ }
132+ v := binary.BigEndian.Uint16(r.b)
133+ r.b = r.b[2:]
134+ return v, nil
135+}
136+
137+func (r *payloadReader) getString() (string, error) {
138+ n, err := r.getUint16()
139+ if err != nil {
140+ return "", err
141+ }
142+ if len(r.b) < int(n) {
143+ return "", errShortPayload
144+ }
145+ s := string(r.b[:n])
146+ r.b = r.b[n:]
147+ return s, nil
148+}
149+
150+// rest consumes and returns all remaining bytes, copied so the result is
151+// independent of the reader's backing array.
152+func (r *payloadReader) rest() []byte {
153+ b := append([]byte(nil), r.b...)
154+ r.b = nil
155+ return b
156+}
157+
158+// done reports an error if any bytes remain unconsumed.
159+func (r *payloadReader) done() error {
160+ if len(r.b) != 0 {
161+ return errTrailingBytes
162+ }
163+ return nil
164+}
165+
166+// MarshalHello encodes h into a Hello message payload.
167+func MarshalHello(h Hello) []byte {
168+ w := payloadWriter{}
169+ w.putUint16(h.Version)
170+ w.putUint8(h.Intent)
171+ w.putString(h.Name)
172+ w.putUint16(uint16(h.Size.Cols))
173+ w.putUint16(uint16(h.Size.Rows))
174+ w.putString(h.TermEnv)
175+ return w.b
176+}
177+
178+// UnmarshalHello decodes a Hello message payload.
179+func UnmarshalHello(b []byte) (Hello, error) {
180+ r := payloadReader{b: b}
181+ var h Hello
182+ var err error
183+
184+ if h.Version, err = r.getUint16(); err != nil {
185+ return Hello{}, err
186+ }
187+ if h.Intent, err = r.getUint8(); err != nil {
188+ return Hello{}, err
189+ }
190+ if h.Name, err = r.getString(); err != nil {
191+ return Hello{}, err
192+ }
193+ cols, err := r.getUint16()
194+ if err != nil {
195+ return Hello{}, err
196+ }
197+ rows, err := r.getUint16()
198+ if err != nil {
199+ return Hello{}, err
200+ }
201+ h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
202+ if h.TermEnv, err = r.getString(); err != nil {
203+ return Hello{}, err
204+ }
205+ if err := r.done(); err != nil {
206+ return Hello{}, err
207+ }
208+ return h, nil
209+}
210+
211+// MarshalInput encodes m into an Input message payload.
212+func MarshalInput(m Input) []byte {
213+ return append([]byte(nil), m.Data...)
214+}
215+
216+// UnmarshalInput decodes an Input message payload. The whole payload is the
217+// data; there is no separate length prefix.
218+func UnmarshalInput(b []byte) (Input, error) {
219+ r := payloadReader{b: b}
220+ return Input{Data: r.rest()}, nil
221+}
222+
223+// MarshalResize encodes m into a Resize message payload.
224+func MarshalResize(m Resize) []byte {
225+ w := payloadWriter{}
226+ w.putUint16(uint16(m.Size.Cols))
227+ w.putUint16(uint16(m.Size.Rows))
228+ return w.b
229+}
230+
231+// UnmarshalResize decodes a Resize message payload.
232+func UnmarshalResize(b []byte) (Resize, error) {
233+ r := payloadReader{b: b}
234+ cols, err := r.getUint16()
235+ if err != nil {
236+ return Resize{}, err
237+ }
238+ rows, err := r.getUint16()
239+ if err != nil {
240+ return Resize{}, err
241+ }
242+ if err := r.done(); err != nil {
243+ return Resize{}, err
244+ }
245+ return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil
246+}
247+
248+// MarshalDetach encodes a Detach message payload (always empty).
249+func MarshalDetach(Detach) []byte {
250+ return nil
251+}
252+
253+// UnmarshalDetach decodes a Detach message payload.
254+func UnmarshalDetach(b []byte) (Detach, error) {
255+ r := payloadReader{b: b}
256+ if err := r.done(); err != nil {
257+ return Detach{}, err
258+ }
259+ return Detach{}, nil
260+}
261+
262+// MarshalPing encodes a Ping message payload (always empty).
263+func MarshalPing(Ping) []byte {
264+ return nil
265+}
266+
267+// UnmarshalPing decodes a Ping message payload.
268+func UnmarshalPing(b []byte) (Ping, error) {
269+ r := payloadReader{b: b}
270+ if err := r.done(); err != nil {
271+ return Ping{}, err
272+ }
273+ return Ping{}, nil
274+}
275+
276+// MarshalPong encodes a Pong message payload (always empty).
277+func MarshalPong(Pong) []byte {
278+ return nil
279+}
280+
281+// UnmarshalPong decodes a Pong message payload.
282+func UnmarshalPong(b []byte) (Pong, error) {
283+ r := payloadReader{b: b}
284+ if err := r.done(); err != nil {
285+ return Pong{}, err
286+ }
287+ return Pong{}, nil
288+}
289+
290+// MarshalWelcome encodes m into a Welcome message payload.
291+func MarshalWelcome(m Welcome) []byte {
292+ w := payloadWriter{}
293+ w.putString(m.SessionID)
294+ w.putString(m.SessionName)
295+ if m.Ephemeral {
296+ w.putUint8(1)
297+ } else {
298+ w.putUint8(0)
299+ }
300+ return w.b
301+}
302+
303+// UnmarshalWelcome decodes a Welcome message payload.
304+func UnmarshalWelcome(b []byte) (Welcome, error) {
305+ r := payloadReader{b: b}
306+ var m Welcome
307+ var err error
308+
309+ if m.SessionID, err = r.getString(); err != nil {
310+ return Welcome{}, err
311+ }
312+ if m.SessionName, err = r.getString(); err != nil {
313+ return Welcome{}, err
314+ }
315+ eph, err := r.getUint8()
316+ if err != nil {
317+ return Welcome{}, err
318+ }
319+ m.Ephemeral = eph != 0
320+ if err := r.done(); err != nil {
321+ return Welcome{}, err
322+ }
323+ return m, nil
324+}
325+
326+// MarshalErrorMsg encodes m into an ErrorMsg message payload.
327+func MarshalErrorMsg(m ErrorMsg) []byte {
328+ w := payloadWriter{}
329+ w.putUint16(m.Code)
330+ w.putString(m.Text)
331+ return w.b
332+}
333+
334+// UnmarshalErrorMsg decodes an ErrorMsg message payload.
335+func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
336+ r := payloadReader{b: b}
337+ var m ErrorMsg
338+ var err error
339+
340+ if m.Code, err = r.getUint16(); err != nil {
341+ return ErrorMsg{}, err
342+ }
343+ if m.Text, err = r.getString(); err != nil {
344+ return ErrorMsg{}, err
345+ }
346+ if err := r.done(); err != nil {
347+ return ErrorMsg{}, err
348+ }
349+ return m, nil
350+}
351+
352+// MarshalOutput encodes m into an Output message payload.
353+func MarshalOutput(m Output) []byte {
354+ return append([]byte(nil), m.Data...)
355+}
356+
357+// UnmarshalOutput decodes an Output message payload. The whole payload is
358+// the data; there is no separate length prefix.
359+func UnmarshalOutput(b []byte) (Output, error) {
360+ r := payloadReader{b: b}
361+ return Output{Data: r.rest()}, nil
362+}
363+
364+// MarshalDetached encodes m into a Detached message payload.
365+func MarshalDetached(m Detached) []byte {
366+ return []byte{m.Reason}
367+}
368+
369+// UnmarshalDetached decodes a Detached message payload.
370+func UnmarshalDetached(b []byte) (Detached, error) {
371+ r := payloadReader{b: b}
372+ reason, err := r.getUint8()
373+ if err != nil {
374+ return Detached{}, err
375+ }
376+ if err := r.done(); err != nil {
377+ return Detached{}, err
378+ }
379+ return Detached{Reason: reason}, nil
380+}
A internal/adapters/ipc/protocol_test.go +340 -0
@@ -0,0 +1,340 @@
1+package ipc
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+// mustNotPanic runs f and fails the test (instead of crashing the test
13+// binary) if f panics.
14+func mustNotPanic(t *testing.T, f func()) {
15+ t.Helper()
16+ defer func() {
17+ if r := recover(); r != nil {
18+ t.Fatalf("panicked: %v", r)
19+ }
20+ }()
21+ f()
22+}
23+
24+// assertAllPrefixesFail decodes every strict prefix of full and requires an
25+// error (never a panic, never a successful decode) for each one. This
26+// exercises truncation at every possible boundary — mid-length,
27+// mid-string-len, mid-string — in one sweep.
28+func assertAllPrefixesFail[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
29+ t.Helper()
30+ for n := 0; n < len(full); n++ {
31+ n := n
32+ t.Run(fmt.Sprintf("truncated_at_%d", n), func(t *testing.T) {
33+ mustNotPanic(t, func() {
34+ if _, err := unmarshal(full[:n]); err == nil {
35+ t.Fatalf("expected error decoding %d/%d truncated bytes, got nil", n, len(full))
36+ }
37+ })
38+ })
39+ }
40+}
41+
42+// assertTrailingGarbageFails decodes full with extra bytes appended and
43+// requires an error for a fixed-shape message.
44+func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
45+ t.Helper()
46+ garbage := append(append([]byte(nil), full...), 0xff)
47+ mustNotPanic(t, func() {
48+ if _, err := unmarshal(garbage); err == nil {
49+ t.Fatalf("expected error decoding payload with trailing garbage, got nil")
50+ }
51+ })
52+}
53+
54+func TestHelloGoldenAndRoundTrip(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ msg Hello
58+ want []byte
59+ }{
60+ {
61+ name: "typical",
62+ msg: Hello{
63+ Version: 1,
64+ Intent: IntentNew,
65+ Name: "w0",
66+ Size: domain.Size{Cols: 80, Rows: 24},
67+ TermEnv: "xterm-256color",
68+ },
69+ want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72},
70+ },
71+ {
72+ name: "empty strings",
73+ msg: Hello{
74+ Version: 1,
75+ Intent: IntentEphemeral,
76+ Name: "",
77+ Size: domain.Size{Cols: 0, Rows: 0},
78+ TermEnv: "",
79+ },
80+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ got := MarshalHello(tt.msg)
87+ if !bytes.Equal(got, tt.want) {
88+ t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
89+ }
90+ back, err := UnmarshalHello(got)
91+ if err != nil {
92+ t.Fatalf("UnmarshalHello() error = %v", err)
93+ }
94+ if !reflect.DeepEqual(back, tt.msg) {
95+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
96+ }
97+ })
98+ }
99+
100+ full := MarshalHello(tests[0].msg)
101+ assertAllPrefixesFail(t, full, UnmarshalHello)
102+ assertTrailingGarbageFails(t, full, UnmarshalHello)
103+}
104+
105+func TestInputGoldenAndRoundTrip(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ msg Input
109+ want []byte
110+ }{
111+ {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},
112+ {name: "empty", msg: Input{Data: nil}, want: []byte{}},
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ got := MarshalInput(tt.msg)
118+ if !bytes.Equal(got, tt.want) {
119+ t.Fatalf("MarshalInput() = %#v, want %#v", got, tt.want)
120+ }
121+ back, err := UnmarshalInput(got)
122+ if err != nil {
123+ t.Fatalf("UnmarshalInput() error = %v", err)
124+ }
125+ if !reflect.DeepEqual(back, tt.msg) {
126+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
127+ }
128+ })
129+ }
130+
131+ // Input is rest-of-payload: any byte sequence, including a truncated
132+ // one, is a valid (if empty or partial) Data value. No error path
133+ // exists to exercise beyond "never panics".
134+ mustNotPanic(t, func() {
135+ if _, err := UnmarshalInput([]byte{0x01}); err != nil {
136+ t.Fatalf("UnmarshalInput() unexpected error = %v", err)
137+ }
138+ })
139+}
140+
141+func TestResizeGoldenAndRoundTrip(t *testing.T) {
142+ msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
143+ want := []byte{0x00, 0x64, 0x00, 0x28}
144+
145+ got := MarshalResize(msg)
146+ if !bytes.Equal(got, want) {
147+ t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
148+ }
149+ back, err := UnmarshalResize(got)
150+ if err != nil {
151+ t.Fatalf("UnmarshalResize() error = %v", err)
152+ }
153+ if !reflect.DeepEqual(back, msg) {
154+ t.Fatalf("round trip = %#v, want %#v", back, msg)
155+ }
156+
157+ assertAllPrefixesFail(t, got, UnmarshalResize)
158+ assertTrailingGarbageFails(t, got, UnmarshalResize)
159+}
160+
161+func TestDetachGoldenAndRoundTrip(t *testing.T) {
162+ got := MarshalDetach(Detach{})
163+ if len(got) != 0 {
164+ t.Fatalf("MarshalDetach() = %#v, want empty", got)
165+ }
166+ back, err := UnmarshalDetach(got)
167+ if err != nil {
168+ t.Fatalf("UnmarshalDetach() error = %v", err)
169+ }
170+ if back != (Detach{}) {
171+ t.Fatalf("round trip = %#v, want %#v", back, Detach{})
172+ }
173+ assertTrailingGarbageFails(t, got, UnmarshalDetach)
174+}
175+
176+func TestPingGoldenAndRoundTrip(t *testing.T) {
177+ got := MarshalPing(Ping{})
178+ if len(got) != 0 {
179+ t.Fatalf("MarshalPing() = %#v, want empty", got)
180+ }
181+ back, err := UnmarshalPing(got)
182+ if err != nil {
183+ t.Fatalf("UnmarshalPing() error = %v", err)
184+ }
185+ if back != (Ping{}) {
186+ t.Fatalf("round trip = %#v, want %#v", back, Ping{})
187+ }
188+ assertTrailingGarbageFails(t, got, UnmarshalPing)
189+}
190+
191+func TestPongGoldenAndRoundTrip(t *testing.T) {
192+ got := MarshalPong(Pong{})
193+ if len(got) != 0 {
194+ t.Fatalf("MarshalPong() = %#v, want empty", got)
195+ }
196+ back, err := UnmarshalPong(got)
197+ if err != nil {
198+ t.Fatalf("UnmarshalPong() error = %v", err)
199+ }
200+ if back != (Pong{}) {
201+ t.Fatalf("round trip = %#v, want %#v", back, Pong{})
202+ }
203+ assertTrailingGarbageFails(t, got, UnmarshalPong)
204+}
205+
206+func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ msg Welcome
210+ want []byte
211+ }{
212+ {
213+ name: "ephemeral",
214+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},
215+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},
216+ },
217+ {
218+ name: "non-ephemeral empty name",
219+ msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
220+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ got := MarshalWelcome(tt.msg)
227+ if !bytes.Equal(got, tt.want) {
228+ t.Fatalf("MarshalWelcome() = %#v, want %#v", got, tt.want)
229+ }
230+ back, err := UnmarshalWelcome(got)
231+ if err != nil {
232+ t.Fatalf("UnmarshalWelcome() error = %v", err)
233+ }
234+ if !reflect.DeepEqual(back, tt.msg) {
235+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
236+ }
237+ })
238+ }
239+
240+ full := MarshalWelcome(tests[0].msg)
241+ assertAllPrefixesFail(t, full, UnmarshalWelcome)
242+ assertTrailingGarbageFails(t, full, UnmarshalWelcome)
243+}
244+
245+func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
246+ msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
247+ want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
248+
249+ got := MarshalErrorMsg(msg)
250+ if !bytes.Equal(got, want) {
251+ t.Fatalf("MarshalErrorMsg() = %#v, want %#v", got, want)
252+ }
253+ back, err := UnmarshalErrorMsg(got)
254+ if err != nil {
255+ t.Fatalf("UnmarshalErrorMsg() error = %v", err)
256+ }
257+ if !reflect.DeepEqual(back, msg) {
258+ t.Fatalf("round trip = %#v, want %#v", back, msg)
259+ }
260+
261+ assertAllPrefixesFail(t, got, UnmarshalErrorMsg)
262+ assertTrailingGarbageFails(t, got, UnmarshalErrorMsg)
263+}
264+
265+func TestOutputGoldenAndRoundTrip(t *testing.T) {
266+ msg := Output{Data: []byte("hello\n")}
267+ want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
268+
269+ got := MarshalOutput(msg)
270+ if !bytes.Equal(got, want) {
271+ t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
272+ }
273+ back, err := UnmarshalOutput(got)
274+ if err != nil {
275+ t.Fatalf("UnmarshalOutput() error = %v", err)
276+ }
277+ if !reflect.DeepEqual(back, msg) {
278+ t.Fatalf("round trip = %#v, want %#v", back, msg)
279+ }
280+}
281+
282+func TestDetachedGoldenAndRoundTrip(t *testing.T) {
283+ msg := Detached{Reason: ReasonSessionKilled}
284+ want := []byte{0x01}
285+
286+ got := MarshalDetached(msg)
287+ if !bytes.Equal(got, want) {
288+ t.Fatalf("MarshalDetached() = %#v, want %#v", got, want)
289+ }
290+ back, err := UnmarshalDetached(got)
291+ if err != nil {
292+ t.Fatalf("UnmarshalDetached() error = %v", err)
293+ }
294+ if !reflect.DeepEqual(back, msg) {
295+ t.Fatalf("round trip = %#v, want %#v", back, msg)
296+ }
297+
298+ assertAllPrefixesFail(t, got, UnmarshalDetached)
299+ assertTrailingGarbageFails(t, got, UnmarshalDetached)
300+}
301+
302+// TestMsgTypeConstantsDistinct guards the enumerations defined in this
303+// package against accidental collisions (Intent, ErrorMsg code, Detached
304+// reason are each independent small spaces, but duplicates within one
305+// space would silently corrupt the protocol).
306+func TestMsgTypeConstantsDistinct(t *testing.T) {
307+ t.Run("intents", func(t *testing.T) {
308+ vals := map[uint8]string{
309+ IntentEphemeral: "IntentEphemeral",
310+ IntentNew: "IntentNew",
311+ IntentAttach: "IntentAttach",
312+ }
313+ if len(vals) != 3 {
314+ t.Fatalf("expected 3 distinct intent values, got %d", len(vals))
315+ }
316+ })
317+
318+ t.Run("error codes", func(t *testing.T) {
319+ vals := map[uint16]string{
320+ ErrVersionMismatch: "ErrVersionMismatch",
321+ ErrNoSuchSession: "ErrNoSuchSession",
322+ ErrNameTaken: "ErrNameTaken",
323+ ErrInternal: "ErrInternal",
324+ }
325+ if len(vals) != 4 {
326+ t.Fatalf("expected 4 distinct error codes, got %d", len(vals))
327+ }
328+ })
329+
330+ t.Run("detached reasons", func(t *testing.T) {
331+ vals := map[uint8]string{
332+ ReasonDetach: "ReasonDetach",
333+ ReasonSessionKilled: "ReasonSessionKilled",
334+ ReasonServerShutdown: "ReasonServerShutdown",
335+ }
336+ if len(vals) != 3 {
337+ t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))
338+ }
339+ })
340+}
@@ -0,0 +1,340 @@
1+package ipc
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+// mustNotPanic runs f and fails the test (instead of crashing the test
13+// binary) if f panics.
14+func mustNotPanic(t *testing.T, f func()) {
15+ t.Helper()
16+ defer func() {
17+ if r := recover(); r != nil {
18+ t.Fatalf("panicked: %v", r)
19+ }
20+ }()
21+ f()
22+}
23+
24+// assertAllPrefixesFail decodes every strict prefix of full and requires an
25+// error (never a panic, never a successful decode) for each one. This
26+// exercises truncation at every possible boundary — mid-length,
27+// mid-string-len, mid-string — in one sweep.
28+func assertAllPrefixesFail[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
29+ t.Helper()
30+ for n := 0; n < len(full); n++ {
31+ n := n
32+ t.Run(fmt.Sprintf("truncated_at_%d", n), func(t *testing.T) {
33+ mustNotPanic(t, func() {
34+ if _, err := unmarshal(full[:n]); err == nil {
35+ t.Fatalf("expected error decoding %d/%d truncated bytes, got nil", n, len(full))
36+ }
37+ })
38+ })
39+ }
40+}
41+
42+// assertTrailingGarbageFails decodes full with extra bytes appended and
43+// requires an error for a fixed-shape message.
44+func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func([]byte) (T, error)) {
45+ t.Helper()
46+ garbage := append(append([]byte(nil), full...), 0xff)
47+ mustNotPanic(t, func() {
48+ if _, err := unmarshal(garbage); err == nil {
49+ t.Fatalf("expected error decoding payload with trailing garbage, got nil")
50+ }
51+ })
52+}
53+
54+func TestHelloGoldenAndRoundTrip(t *testing.T) {
55+ tests := []struct {
56+ name string
57+ msg Hello
58+ want []byte
59+ }{
60+ {
61+ name: "typical",
62+ msg: Hello{
63+ Version: 1,
64+ Intent: IntentNew,
65+ Name: "w0",
66+ Size: domain.Size{Cols: 80, Rows: 24},
67+ TermEnv: "xterm-256color",
68+ },
69+ want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72},
70+ },
71+ {
72+ name: "empty strings",
73+ msg: Hello{
74+ Version: 1,
75+ Intent: IntentEphemeral,
76+ Name: "",
77+ Size: domain.Size{Cols: 0, Rows: 0},
78+ TermEnv: "",
79+ },
80+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ got := MarshalHello(tt.msg)
87+ if !bytes.Equal(got, tt.want) {
88+ t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
89+ }
90+ back, err := UnmarshalHello(got)
91+ if err != nil {
92+ t.Fatalf("UnmarshalHello() error = %v", err)
93+ }
94+ if !reflect.DeepEqual(back, tt.msg) {
95+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
96+ }
97+ })
98+ }
99+
100+ full := MarshalHello(tests[0].msg)
101+ assertAllPrefixesFail(t, full, UnmarshalHello)
102+ assertTrailingGarbageFails(t, full, UnmarshalHello)
103+}
104+
105+func TestInputGoldenAndRoundTrip(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ msg Input
109+ want []byte
110+ }{
111+ {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},
112+ {name: "empty", msg: Input{Data: nil}, want: []byte{}},
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ got := MarshalInput(tt.msg)
118+ if !bytes.Equal(got, tt.want) {
119+ t.Fatalf("MarshalInput() = %#v, want %#v", got, tt.want)
120+ }
121+ back, err := UnmarshalInput(got)
122+ if err != nil {
123+ t.Fatalf("UnmarshalInput() error = %v", err)
124+ }
125+ if !reflect.DeepEqual(back, tt.msg) {
126+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
127+ }
128+ })
129+ }
130+
131+ // Input is rest-of-payload: any byte sequence, including a truncated
132+ // one, is a valid (if empty or partial) Data value. No error path
133+ // exists to exercise beyond "never panics".
134+ mustNotPanic(t, func() {
135+ if _, err := UnmarshalInput([]byte{0x01}); err != nil {
136+ t.Fatalf("UnmarshalInput() unexpected error = %v", err)
137+ }
138+ })
139+}
140+
141+func TestResizeGoldenAndRoundTrip(t *testing.T) {
142+ msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
143+ want := []byte{0x00, 0x64, 0x00, 0x28}
144+
145+ got := MarshalResize(msg)
146+ if !bytes.Equal(got, want) {
147+ t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
148+ }
149+ back, err := UnmarshalResize(got)
150+ if err != nil {
151+ t.Fatalf("UnmarshalResize() error = %v", err)
152+ }
153+ if !reflect.DeepEqual(back, msg) {
154+ t.Fatalf("round trip = %#v, want %#v", back, msg)
155+ }
156+
157+ assertAllPrefixesFail(t, got, UnmarshalResize)
158+ assertTrailingGarbageFails(t, got, UnmarshalResize)
159+}
160+
161+func TestDetachGoldenAndRoundTrip(t *testing.T) {
162+ got := MarshalDetach(Detach{})
163+ if len(got) != 0 {
164+ t.Fatalf("MarshalDetach() = %#v, want empty", got)
165+ }
166+ back, err := UnmarshalDetach(got)
167+ if err != nil {
168+ t.Fatalf("UnmarshalDetach() error = %v", err)
169+ }
170+ if back != (Detach{}) {
171+ t.Fatalf("round trip = %#v, want %#v", back, Detach{})
172+ }
173+ assertTrailingGarbageFails(t, got, UnmarshalDetach)
174+}
175+
176+func TestPingGoldenAndRoundTrip(t *testing.T) {
177+ got := MarshalPing(Ping{})
178+ if len(got) != 0 {
179+ t.Fatalf("MarshalPing() = %#v, want empty", got)
180+ }
181+ back, err := UnmarshalPing(got)
182+ if err != nil {
183+ t.Fatalf("UnmarshalPing() error = %v", err)
184+ }
185+ if back != (Ping{}) {
186+ t.Fatalf("round trip = %#v, want %#v", back, Ping{})
187+ }
188+ assertTrailingGarbageFails(t, got, UnmarshalPing)
189+}
190+
191+func TestPongGoldenAndRoundTrip(t *testing.T) {
192+ got := MarshalPong(Pong{})
193+ if len(got) != 0 {
194+ t.Fatalf("MarshalPong() = %#v, want empty", got)
195+ }
196+ back, err := UnmarshalPong(got)
197+ if err != nil {
198+ t.Fatalf("UnmarshalPong() error = %v", err)
199+ }
200+ if back != (Pong{}) {
201+ t.Fatalf("round trip = %#v, want %#v", back, Pong{})
202+ }
203+ assertTrailingGarbageFails(t, got, UnmarshalPong)
204+}
205+
206+func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ msg Welcome
210+ want []byte
211+ }{
212+ {
213+ name: "ephemeral",
214+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},
215+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},
216+ },
217+ {
218+ name: "non-ephemeral empty name",
219+ msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
220+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ got := MarshalWelcome(tt.msg)
227+ if !bytes.Equal(got, tt.want) {
228+ t.Fatalf("MarshalWelcome() = %#v, want %#v", got, tt.want)
229+ }
230+ back, err := UnmarshalWelcome(got)
231+ if err != nil {
232+ t.Fatalf("UnmarshalWelcome() error = %v", err)
233+ }
234+ if !reflect.DeepEqual(back, tt.msg) {
235+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
236+ }
237+ })
238+ }
239+
240+ full := MarshalWelcome(tests[0].msg)
241+ assertAllPrefixesFail(t, full, UnmarshalWelcome)
242+ assertTrailingGarbageFails(t, full, UnmarshalWelcome)
243+}
244+
245+func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
246+ msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"}
247+ want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e}
248+
249+ got := MarshalErrorMsg(msg)
250+ if !bytes.Equal(got, want) {
251+ t.Fatalf("MarshalErrorMsg() = %#v, want %#v", got, want)
252+ }
253+ back, err := UnmarshalErrorMsg(got)
254+ if err != nil {
255+ t.Fatalf("UnmarshalErrorMsg() error = %v", err)
256+ }
257+ if !reflect.DeepEqual(back, msg) {
258+ t.Fatalf("round trip = %#v, want %#v", back, msg)
259+ }
260+
261+ assertAllPrefixesFail(t, got, UnmarshalErrorMsg)
262+ assertTrailingGarbageFails(t, got, UnmarshalErrorMsg)
263+}
264+
265+func TestOutputGoldenAndRoundTrip(t *testing.T) {
266+ msg := Output{Data: []byte("hello\n")}
267+ want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
268+
269+ got := MarshalOutput(msg)
270+ if !bytes.Equal(got, want) {
271+ t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
272+ }
273+ back, err := UnmarshalOutput(got)
274+ if err != nil {
275+ t.Fatalf("UnmarshalOutput() error = %v", err)
276+ }
277+ if !reflect.DeepEqual(back, msg) {
278+ t.Fatalf("round trip = %#v, want %#v", back, msg)
279+ }
280+}
281+
282+func TestDetachedGoldenAndRoundTrip(t *testing.T) {
283+ msg := Detached{Reason: ReasonSessionKilled}
284+ want := []byte{0x01}
285+
286+ got := MarshalDetached(msg)
287+ if !bytes.Equal(got, want) {
288+ t.Fatalf("MarshalDetached() = %#v, want %#v", got, want)
289+ }
290+ back, err := UnmarshalDetached(got)
291+ if err != nil {
292+ t.Fatalf("UnmarshalDetached() error = %v", err)
293+ }
294+ if !reflect.DeepEqual(back, msg) {
295+ t.Fatalf("round trip = %#v, want %#v", back, msg)
296+ }
297+
298+ assertAllPrefixesFail(t, got, UnmarshalDetached)
299+ assertTrailingGarbageFails(t, got, UnmarshalDetached)
300+}
301+
302+// TestMsgTypeConstantsDistinct guards the enumerations defined in this
303+// package against accidental collisions (Intent, ErrorMsg code, Detached
304+// reason are each independent small spaces, but duplicates within one
305+// space would silently corrupt the protocol).
306+func TestMsgTypeConstantsDistinct(t *testing.T) {
307+ t.Run("intents", func(t *testing.T) {
308+ vals := map[uint8]string{
309+ IntentEphemeral: "IntentEphemeral",
310+ IntentNew: "IntentNew",
311+ IntentAttach: "IntentAttach",
312+ }
313+ if len(vals) != 3 {
314+ t.Fatalf("expected 3 distinct intent values, got %d", len(vals))
315+ }
316+ })
317+
318+ t.Run("error codes", func(t *testing.T) {
319+ vals := map[uint16]string{
320+ ErrVersionMismatch: "ErrVersionMismatch",
321+ ErrNoSuchSession: "ErrNoSuchSession",
322+ ErrNameTaken: "ErrNameTaken",
323+ ErrInternal: "ErrInternal",
324+ }
325+ if len(vals) != 4 {
326+ t.Fatalf("expected 4 distinct error codes, got %d", len(vals))
327+ }
328+ })
329+
330+ t.Run("detached reasons", func(t *testing.T) {
331+ vals := map[uint8]string{
332+ ReasonDetach: "ReasonDetach",
333+ ReasonSessionKilled: "ReasonSessionKilled",
334+ ReasonServerShutdown: "ReasonServerShutdown",
335+ }
336+ if len(vals) != 3 {
337+ t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))
338+ }
339+ })
340+}
A internal/adapters/ipc/transport.go +109 -0
@@ -0,0 +1,109 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "io"
7+ "net"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// maxFrameLen is the largest permitted frame length (the length field
13+// covers the type byte plus payload, not including the 4-byte length
14+// prefix itself).
15+const maxFrameLen = 16 << 20 // 16 MiB
16+
17+// frameHeaderLen is the size, in bytes, of the length prefix that precedes
18+// every frame on the wire.
19+const frameHeaderLen = 4
20+
21+// ErrZeroLengthFrame is returned by Recv when a frame's length field is
22+// zero. A valid frame always carries at least its type byte.
23+var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
24+
25+// ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
26+// maxFrameLen, and by Send when a payload is too large to encode.
27+var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
28+
29+// unixTransport implements ports.Transport over a net.Conn (in practice an
30+// AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
31+// tests exercise it over net.Pipe).
32+//
33+// unixTransport is safe for one concurrent caller of Send and one
34+// concurrent caller of Recv at the same time (i.e. a single reader
35+// goroutine and a single writer goroutine); it is not safe for concurrent
36+// calls to Send from multiple goroutines, nor Recv from multiple
37+// goroutines.
38+type unixTransport struct {
39+ conn net.Conn
40+
41+ // readBuf is reused across Recv calls (grow-once strategy): it grows
42+ // to fit the largest frame seen so far and is never shrunk. The Frame
43+ // returned to the caller is always a freshly allocated, right-sized
44+ // copy, so callers may retain it indefinitely.
45+ readBuf []byte
46+}
47+
48+// NewTransport wraps conn as a ports.Transport speaking vev's framed
49+// binary protocol.
50+func NewTransport(conn net.Conn) ports.Transport {
51+ return &unixTransport{conn: conn}
52+}
53+
54+// Send writes f as a single frame: a 4-byte big-endian length (covering
55+// the type byte and payload), the type byte, then the payload — assembled
56+// into one buffer and written with a single Write call.
57+func (t *unixTransport) Send(f ports.Frame) error {
58+ n := 1 + len(f.Payload) // type + payload
59+ if n > maxFrameLen {
60+ return ErrFrameTooLarge
61+ }
62+
63+ buf := make([]byte, frameHeaderLen+n)
64+ binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
65+ buf[frameHeaderLen] = byte(f.Type)
66+ copy(buf[frameHeaderLen+1:], f.Payload)
67+
68+ _, err := t.conn.Write(buf)
69+ return err
70+}
71+
72+// Recv reads one frame: a 4-byte length, then a body of that many bytes
73+// (type byte + payload). It blocks until a full frame arrives, the
74+// connection is closed (io.EOF), or an error occurs.
75+func (t *unixTransport) Recv() (ports.Frame, error) {
76+ var hdr [frameHeaderLen]byte
77+ if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
78+ return ports.Frame{}, err
79+ }
80+
81+ n := binary.BigEndian.Uint32(hdr[:])
82+ if n == 0 {
83+ return ports.Frame{}, ErrZeroLengthFrame
84+ }
85+ if n > maxFrameLen {
86+ return ports.Frame{}, ErrFrameTooLarge
87+ }
88+
89+ if cap(t.readBuf) < int(n) {
90+ t.readBuf = make([]byte, n)
91+ } else {
92+ t.readBuf = t.readBuf[:n]
93+ }
94+ if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
95+ return ports.Frame{}, err
96+ }
97+
98+ var payload []byte
99+ if n > 1 {
100+ payload = make([]byte, n-1)
101+ copy(payload, t.readBuf[1:])
102+ }
103+ return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
104+}
105+
106+// Close closes the underlying connection.
107+func (t *unixTransport) Close() error {
108+ return t.conn.Close()
109+}
@@ -0,0 +1,109 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "io"
7+ "net"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// maxFrameLen is the largest permitted frame length (the length field
13+// covers the type byte plus payload, not including the 4-byte length
14+// prefix itself).
15+const maxFrameLen = 16 << 20 // 16 MiB
16+
17+// frameHeaderLen is the size, in bytes, of the length prefix that precedes
18+// every frame on the wire.
19+const frameHeaderLen = 4
20+
21+// ErrZeroLengthFrame is returned by Recv when a frame's length field is
22+// zero. A valid frame always carries at least its type byte.
23+var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
24+
25+// ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
26+// maxFrameLen, and by Send when a payload is too large to encode.
27+var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
28+
29+// unixTransport implements ports.Transport over a net.Conn (in practice an
30+// AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
31+// tests exercise it over net.Pipe).
32+//
33+// unixTransport is safe for one concurrent caller of Send and one
34+// concurrent caller of Recv at the same time (i.e. a single reader
35+// goroutine and a single writer goroutine); it is not safe for concurrent
36+// calls to Send from multiple goroutines, nor Recv from multiple
37+// goroutines.
38+type unixTransport struct {
39+ conn net.Conn
40+
41+ // readBuf is reused across Recv calls (grow-once strategy): it grows
42+ // to fit the largest frame seen so far and is never shrunk. The Frame
43+ // returned to the caller is always a freshly allocated, right-sized
44+ // copy, so callers may retain it indefinitely.
45+ readBuf []byte
46+}
47+
48+// NewTransport wraps conn as a ports.Transport speaking vev's framed
49+// binary protocol.
50+func NewTransport(conn net.Conn) ports.Transport {
51+ return &unixTransport{conn: conn}
52+}
53+
54+// Send writes f as a single frame: a 4-byte big-endian length (covering
55+// the type byte and payload), the type byte, then the payload — assembled
56+// into one buffer and written with a single Write call.
57+func (t *unixTransport) Send(f ports.Frame) error {
58+ n := 1 + len(f.Payload) // type + payload
59+ if n > maxFrameLen {
60+ return ErrFrameTooLarge
61+ }
62+
63+ buf := make([]byte, frameHeaderLen+n)
64+ binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
65+ buf[frameHeaderLen] = byte(f.Type)
66+ copy(buf[frameHeaderLen+1:], f.Payload)
67+
68+ _, err := t.conn.Write(buf)
69+ return err
70+}
71+
72+// Recv reads one frame: a 4-byte length, then a body of that many bytes
73+// (type byte + payload). It blocks until a full frame arrives, the
74+// connection is closed (io.EOF), or an error occurs.
75+func (t *unixTransport) Recv() (ports.Frame, error) {
76+ var hdr [frameHeaderLen]byte
77+ if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
78+ return ports.Frame{}, err
79+ }
80+
81+ n := binary.BigEndian.Uint32(hdr[:])
82+ if n == 0 {
83+ return ports.Frame{}, ErrZeroLengthFrame
84+ }
85+ if n > maxFrameLen {
86+ return ports.Frame{}, ErrFrameTooLarge
87+ }
88+
89+ if cap(t.readBuf) < int(n) {
90+ t.readBuf = make([]byte, n)
91+ } else {
92+ t.readBuf = t.readBuf[:n]
93+ }
94+ if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
95+ return ports.Frame{}, err
96+ }
97+
98+ var payload []byte
99+ if n > 1 {
100+ payload = make([]byte, n-1)
101+ copy(payload, t.readBuf[1:])
102+ }
103+ return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
104+}
105+
106+// Close closes the underlying connection.
107+func (t *unixTransport) Close() error {
108+ return t.conn.Close()
109+}
A internal/adapters/ipc/transport_test.go +241 -0
@@ -0,0 +1,241 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "io"
7+ "net"
8+ "reflect"
9+ "sync"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestTransportSendRecvBothDirections(t *testing.T) {
17+ c1, c2 := net.Pipe()
18+ defer c1.Close()
19+ defer c2.Close()
20+
21+ client := NewTransport(c1)
22+ server := NewTransport(c2)
23+
24+ clientToServer := ports.Frame{Type: ports.MsgHello, Payload: MarshalHello(Hello{Version: 1, Intent: IntentNew, Name: "w0"})}
25+ serverToClient := ports.Frame{Type: ports.MsgWelcome, Payload: MarshalWelcome(Welcome{SessionID: "s1", SessionName: "main"})}
26+
27+ var wg sync.WaitGroup
28+ wg.Add(1)
29+ go func() {
30+ defer wg.Done()
31+ if err := client.Send(clientToServer); err != nil {
32+ t.Errorf("client.Send() error = %v", err)
33+ }
34+ }()
35+
36+ got, err := server.Recv()
37+ wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
38+ if err != nil {
39+ t.Fatalf("server.Recv() error = %v", err)
40+ }
41+ if !reflect.DeepEqual(got, clientToServer) {
42+ t.Fatalf("server.Recv() = %#v, want %#v", got, clientToServer)
43+ }
44+
45+ wg.Add(1)
46+ go func() {
47+ defer wg.Done()
48+ if err := server.Send(serverToClient); err != nil {
49+ t.Errorf("server.Send() error = %v", err)
50+ }
51+ }()
52+
53+ got, err = client.Recv()
54+ wg.Wait()
55+ if err != nil {
56+ t.Fatalf("client.Recv() error = %v", err)
57+ }
58+ if !reflect.DeepEqual(got, serverToClient) {
59+ t.Fatalf("client.Recv() = %#v, want %#v", got, serverToClient)
60+ }
61+}
62+
63+func TestTransportEOFOnClose(t *testing.T) {
64+ c1, c2 := net.Pipe()
65+ server := NewTransport(c2)
66+
67+ if err := c1.Close(); err != nil {
68+ t.Fatalf("c1.Close() error = %v", err)
69+ }
70+
71+ _, err := server.Recv()
72+ if !errors.Is(err, io.EOF) {
73+ t.Fatalf("server.Recv() error = %v, want io.EOF", err)
74+ }
75+}
76+
77+func TestTransportCloseClosesUnderlyingConn(t *testing.T) {
78+ c1, c2 := net.Pipe()
79+ client := NewTransport(c1)
80+ defer c2.Close()
81+
82+ if err := client.Close(); err != nil {
83+ t.Fatalf("Close() error = %v", err)
84+ }
85+
86+ // Writing to the closed end should now fail.
87+ if _, err := c1.Write([]byte("x")); err == nil {
88+ t.Fatalf("expected write on closed conn to fail")
89+ }
90+}
91+
92+func TestTransportLargeFrameNearCap(t *testing.T) {
93+ c1, c2 := net.Pipe()
94+ defer c1.Close()
95+ defer c2.Close()
96+
97+ client := NewTransport(c1)
98+ server := NewTransport(c2)
99+
100+ // Largest payload whose frame length (1 + len(payload)) still fits
101+ // exactly at maxFrameLen.
102+ payload := make([]byte, maxFrameLen-1)
103+ for i := range payload {
104+ payload[i] = byte(i)
105+ }
106+ want := ports.Frame{Type: ports.MsgOutput, Payload: payload}
107+
108+ var wg sync.WaitGroup
109+ wg.Add(1)
110+ go func() {
111+ defer wg.Done()
112+ if err := client.Send(want); err != nil {
113+ t.Errorf("client.Send() error = %v", err)
114+ }
115+ }()
116+
117+ got, err := server.Recv()
118+ wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
119+ if err != nil {
120+ t.Fatalf("server.Recv() error = %v", err)
121+ }
122+ if got.Type != want.Type || len(got.Payload) != len(want.Payload) {
123+ t.Fatalf("server.Recv() = type %v len %d, want type %v len %d", got.Type, len(got.Payload), want.Type, len(want.Payload))
124+ }
125+ for i := range got.Payload {
126+ if got.Payload[i] != want.Payload[i] {
127+ t.Fatalf("payload mismatch at byte %d: got %d want %d", i, got.Payload[i], want.Payload[i])
128+ }
129+ }
130+}
131+
132+func TestTransportManySmallFramesBackToBack(t *testing.T) {
133+ c1, c2 := net.Pipe()
134+ defer c1.Close()
135+ defer c2.Close()
136+
137+ client := NewTransport(c1)
138+ server := NewTransport(c2)
139+
140+ const count = 200
141+ frames := make([]ports.Frame, count)
142+ for i := range frames {
143+ frames[i] = ports.Frame{Type: ports.MsgPing, Payload: nil}
144+ }
145+ // Vary a couple to prove ordering and content are both preserved.
146+ frames[7] = ports.Frame{Type: ports.MsgInput, Payload: MarshalInput(Input{Data: []byte("hop")})}
147+ frames[150] = ports.Frame{Type: ports.MsgResize, Payload: MarshalResize(Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
148+
149+ var wg sync.WaitGroup
150+ wg.Add(1)
151+ go func() {
152+ defer wg.Done()
153+ for _, f := range frames {
154+ if err := client.Send(f); err != nil {
155+ t.Errorf("client.Send() error = %v", err)
156+ return
157+ }
158+ }
159+ }()
160+
161+ // Report mismatches with Errorf (not Fatalf) and keep draining: the
162+ // sender goroutine's net.Pipe writes block until read, so bailing out
163+ // early here would deadlock (or leak) the sender.
164+ for i, want := range frames {
165+ got, err := server.Recv()
166+ if err != nil {
167+ t.Errorf("server.Recv() frame %d error = %v", i, err)
168+ continue
169+ }
170+ if !reflect.DeepEqual(got, want) {
171+ t.Errorf("server.Recv() frame %d = %#v, want %#v", i, got, want)
172+ }
173+ }
174+ wg.Wait()
175+}
176+
177+func TestTransportRecvOversizeFrame(t *testing.T) {
178+ c1, c2 := net.Pipe()
179+ defer c1.Close()
180+ defer c2.Close()
181+
182+ server := NewTransport(c2)
183+
184+ go func() {
185+ var hdr [4]byte
186+ binary.BigEndian.PutUint32(hdr[:], maxFrameLen+1)
187+ _, _ = c1.Write(hdr[:])
188+ }()
189+
190+ _, err := server.Recv()
191+ if !errors.Is(err, ErrFrameTooLarge) {
192+ t.Fatalf("server.Recv() error = %v, want ErrFrameTooLarge", err)
193+ }
194+}
195+
196+func TestTransportRecvZeroLengthFrame(t *testing.T) {
197+ c1, c2 := net.Pipe()
198+ defer c1.Close()
199+ defer c2.Close()
200+
201+ server := NewTransport(c2)
202+
203+ go func() {
204+ var hdr [4]byte
205+ binary.BigEndian.PutUint32(hdr[:], 0)
206+ _, _ = c1.Write(hdr[:])
207+ }()
208+
209+ _, err := server.Recv()
210+ if !errors.Is(err, ErrZeroLengthFrame) {
211+ t.Fatalf("server.Recv() error = %v, want ErrZeroLengthFrame", err)
212+ }
213+}
214+
215+func TestTransportRecvTruncatedHeader(t *testing.T) {
216+ c1, c2 := net.Pipe()
217+ server := NewTransport(c2)
218+
219+ go func() {
220+ _, _ = c1.Write([]byte{0x00, 0x01}) // only 2 of 4 header bytes
221+ _ = c1.Close()
222+ }()
223+
224+ _, err := server.Recv()
225+ if err == nil {
226+ t.Fatalf("server.Recv() expected error on truncated header, got nil")
227+ }
228+}
229+
230+func TestTransportSendOversizePayloadRejected(t *testing.T) {
231+ c1, c2 := net.Pipe()
232+ defer c1.Close()
233+ defer c2.Close()
234+
235+ client := NewTransport(c1)
236+ payload := make([]byte, maxFrameLen) // +1 byte type => exceeds max
237+ err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
238+ if !errors.Is(err, ErrFrameTooLarge) {
239+ t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
240+ }
241+}
@@ -0,0 +1,241 @@
1+package ipc
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "io"
7+ "net"
8+ "reflect"
9+ "sync"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestTransportSendRecvBothDirections(t *testing.T) {
17+ c1, c2 := net.Pipe()
18+ defer c1.Close()
19+ defer c2.Close()
20+
21+ client := NewTransport(c1)
22+ server := NewTransport(c2)
23+
24+ clientToServer := ports.Frame{Type: ports.MsgHello, Payload: MarshalHello(Hello{Version: 1, Intent: IntentNew, Name: "w0"})}
25+ serverToClient := ports.Frame{Type: ports.MsgWelcome, Payload: MarshalWelcome(Welcome{SessionID: "s1", SessionName: "main"})}
26+
27+ var wg sync.WaitGroup
28+ wg.Add(1)
29+ go func() {
30+ defer wg.Done()
31+ if err := client.Send(clientToServer); err != nil {
32+ t.Errorf("client.Send() error = %v", err)
33+ }
34+ }()
35+
36+ got, err := server.Recv()
37+ wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
38+ if err != nil {
39+ t.Fatalf("server.Recv() error = %v", err)
40+ }
41+ if !reflect.DeepEqual(got, clientToServer) {
42+ t.Fatalf("server.Recv() = %#v, want %#v", got, clientToServer)
43+ }
44+
45+ wg.Add(1)
46+ go func() {
47+ defer wg.Done()
48+ if err := server.Send(serverToClient); err != nil {
49+ t.Errorf("server.Send() error = %v", err)
50+ }
51+ }()
52+
53+ got, err = client.Recv()
54+ wg.Wait()
55+ if err != nil {
56+ t.Fatalf("client.Recv() error = %v", err)
57+ }
58+ if !reflect.DeepEqual(got, serverToClient) {
59+ t.Fatalf("client.Recv() = %#v, want %#v", got, serverToClient)
60+ }
61+}
62+
63+func TestTransportEOFOnClose(t *testing.T) {
64+ c1, c2 := net.Pipe()
65+ server := NewTransport(c2)
66+
67+ if err := c1.Close(); err != nil {
68+ t.Fatalf("c1.Close() error = %v", err)
69+ }
70+
71+ _, err := server.Recv()
72+ if !errors.Is(err, io.EOF) {
73+ t.Fatalf("server.Recv() error = %v, want io.EOF", err)
74+ }
75+}
76+
77+func TestTransportCloseClosesUnderlyingConn(t *testing.T) {
78+ c1, c2 := net.Pipe()
79+ client := NewTransport(c1)
80+ defer c2.Close()
81+
82+ if err := client.Close(); err != nil {
83+ t.Fatalf("Close() error = %v", err)
84+ }
85+
86+ // Writing to the closed end should now fail.
87+ if _, err := c1.Write([]byte("x")); err == nil {
88+ t.Fatalf("expected write on closed conn to fail")
89+ }
90+}
91+
92+func TestTransportLargeFrameNearCap(t *testing.T) {
93+ c1, c2 := net.Pipe()
94+ defer c1.Close()
95+ defer c2.Close()
96+
97+ client := NewTransport(c1)
98+ server := NewTransport(c2)
99+
100+ // Largest payload whose frame length (1 + len(payload)) still fits
101+ // exactly at maxFrameLen.
102+ payload := make([]byte, maxFrameLen-1)
103+ for i := range payload {
104+ payload[i] = byte(i)
105+ }
106+ want := ports.Frame{Type: ports.MsgOutput, Payload: payload}
107+
108+ var wg sync.WaitGroup
109+ wg.Add(1)
110+ go func() {
111+ defer wg.Done()
112+ if err := client.Send(want); err != nil {
113+ t.Errorf("client.Send() error = %v", err)
114+ }
115+ }()
116+
117+ got, err := server.Recv()
118+ wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
119+ if err != nil {
120+ t.Fatalf("server.Recv() error = %v", err)
121+ }
122+ if got.Type != want.Type || len(got.Payload) != len(want.Payload) {
123+ t.Fatalf("server.Recv() = type %v len %d, want type %v len %d", got.Type, len(got.Payload), want.Type, len(want.Payload))
124+ }
125+ for i := range got.Payload {
126+ if got.Payload[i] != want.Payload[i] {
127+ t.Fatalf("payload mismatch at byte %d: got %d want %d", i, got.Payload[i], want.Payload[i])
128+ }
129+ }
130+}
131+
132+func TestTransportManySmallFramesBackToBack(t *testing.T) {
133+ c1, c2 := net.Pipe()
134+ defer c1.Close()
135+ defer c2.Close()
136+
137+ client := NewTransport(c1)
138+ server := NewTransport(c2)
139+
140+ const count = 200
141+ frames := make([]ports.Frame, count)
142+ for i := range frames {
143+ frames[i] = ports.Frame{Type: ports.MsgPing, Payload: nil}
144+ }
145+ // Vary a couple to prove ordering and content are both preserved.
146+ frames[7] = ports.Frame{Type: ports.MsgInput, Payload: MarshalInput(Input{Data: []byte("hop")})}
147+ frames[150] = ports.Frame{Type: ports.MsgResize, Payload: MarshalResize(Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
148+
149+ var wg sync.WaitGroup
150+ wg.Add(1)
151+ go func() {
152+ defer wg.Done()
153+ for _, f := range frames {
154+ if err := client.Send(f); err != nil {
155+ t.Errorf("client.Send() error = %v", err)
156+ return
157+ }
158+ }
159+ }()
160+
161+ // Report mismatches with Errorf (not Fatalf) and keep draining: the
162+ // sender goroutine's net.Pipe writes block until read, so bailing out
163+ // early here would deadlock (or leak) the sender.
164+ for i, want := range frames {
165+ got, err := server.Recv()
166+ if err != nil {
167+ t.Errorf("server.Recv() frame %d error = %v", i, err)
168+ continue
169+ }
170+ if !reflect.DeepEqual(got, want) {
171+ t.Errorf("server.Recv() frame %d = %#v, want %#v", i, got, want)
172+ }
173+ }
174+ wg.Wait()
175+}
176+
177+func TestTransportRecvOversizeFrame(t *testing.T) {
178+ c1, c2 := net.Pipe()
179+ defer c1.Close()
180+ defer c2.Close()
181+
182+ server := NewTransport(c2)
183+
184+ go func() {
185+ var hdr [4]byte
186+ binary.BigEndian.PutUint32(hdr[:], maxFrameLen+1)
187+ _, _ = c1.Write(hdr[:])
188+ }()
189+
190+ _, err := server.Recv()
191+ if !errors.Is(err, ErrFrameTooLarge) {
192+ t.Fatalf("server.Recv() error = %v, want ErrFrameTooLarge", err)
193+ }
194+}
195+
196+func TestTransportRecvZeroLengthFrame(t *testing.T) {
197+ c1, c2 := net.Pipe()
198+ defer c1.Close()
199+ defer c2.Close()
200+
201+ server := NewTransport(c2)
202+
203+ go func() {
204+ var hdr [4]byte
205+ binary.BigEndian.PutUint32(hdr[:], 0)
206+ _, _ = c1.Write(hdr[:])
207+ }()
208+
209+ _, err := server.Recv()
210+ if !errors.Is(err, ErrZeroLengthFrame) {
211+ t.Fatalf("server.Recv() error = %v, want ErrZeroLengthFrame", err)
212+ }
213+}
214+
215+func TestTransportRecvTruncatedHeader(t *testing.T) {
216+ c1, c2 := net.Pipe()
217+ server := NewTransport(c2)
218+
219+ go func() {
220+ _, _ = c1.Write([]byte{0x00, 0x01}) // only 2 of 4 header bytes
221+ _ = c1.Close()
222+ }()
223+
224+ _, err := server.Recv()
225+ if err == nil {
226+ t.Fatalf("server.Recv() expected error on truncated header, got nil")
227+ }
228+}
229+
230+func TestTransportSendOversizePayloadRejected(t *testing.T) {
231+ c1, c2 := net.Pipe()
232+ defer c1.Close()
233+ defer c2.Close()
234+
235+ client := NewTransport(c1)
236+ payload := make([]byte, maxFrameLen) // +1 byte type => exceeds max
237+ err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
238+ if !errors.Is(err, ErrFrameTooLarge) {
239+ t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
240+ }
241+}

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