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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||