detached f5b4f9b

fix: address final punch list

commit
f5b4f9b9fa9643dd7330d7666d50f8ca4baf4275
author
brice · · 2026-07-02 17:06 UTC
parent
28e6f55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +69 -15
M docs/performance.md +12 -6
@@ -12,13 +12,19 @@ Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `se
1212
1313 ## Local baseline status
1414
15-Full end-to-end vev-vs-tmux workload baselines were not run for this change because they require an interactive terminal/daemon pair and external tracing tools around both processes. The reproducible methodology is:
15+Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair and external tracing tools around both processes. Run them later from an interactive shell with these exact command shapes, substituting only the vev binary path, tmux binary path, terminal size, duration, and sample file path:
1616
17-1. Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, and workload duration.
18-2. Start vev daemon with pprof enabled only when collecting profiles.
19-3. Run each workload (`yes`, `seq`, `cat`) for the same fixed duration and terminal size under vev and tmux.
20-4. Capture client/daemon syscall summaries with `strace -c` (or platform equivalent), bytes written per rendered frame, and heap profiles or `-benchmem` allocation counts.
21-5. Store raw command lines and outputs beside this document before comparing.
17+```sh
18+VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-yes.strace vev --daemon
19+strace -ff -c -o vev-client-yes.strace vev new perf-yes -- sh -lc 'timeout 30s yes'
20+strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes'
21+strace -ff -c -o vev-client-seq.strace vev new perf-seq -- sh -lc 'timeout 30s sh -c "while :; do seq 1 10000; done"'
22+strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"'
23+strace -ff -c -o vev-client-cat.strace vev new perf-cat -- sh -lc 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'
24+strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'
25+```
26+
27+Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.
2228
2329 Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc -run '^$' -bench=. -benchmem`:
2430
@@ -12,13 +12,19 @@ Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `se
12 12
13 ## Local baseline status13 ## Local baseline status
14 14
15-Full end-to-end vev-vs-tmux workload baselines were not run for this change because they require an interactive terminal/daemon pair and external tracing tools around both processes. The reproducible methodology is:15+Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair and external tracing tools around both processes. Run them later from an interactive shell with these exact command shapes, substituting only the vev binary path, tmux binary path, terminal size, duration, and sample file path:
16 16
17-1. Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, and workload duration.17+```sh
18-2. Start vev daemon with pprof enabled only when collecting profiles.18+VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-yes.strace vev --daemon
19-3. Run each workload (`yes`, `seq`, `cat`) for the same fixed duration and terminal size under vev and tmux.19+strace -ff -c -o vev-client-yes.strace vev new perf-yes -- sh -lc 'timeout 30s yes'
20-4. Capture client/daemon syscall summaries with `strace -c` (or platform equivalent), bytes written per rendered frame, and heap profiles or `-benchmem` allocation counts.20+strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes'
21-5. Store raw command lines and outputs beside this document before comparing.21+strace -ff -c -o vev-client-seq.strace vev new perf-seq -- sh -lc 'timeout 30s sh -c "while :; do seq 1 10000; done"'
22+strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"'
23+strace -ff -c -o vev-client-cat.strace vev new perf-cat -- sh -lc 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'
24+strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'
25+```
26+
27+Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.
22 28
23 Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc -run '^$' -bench=. -benchmem`:29 Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc -run '^$' -bench=. -benchmem`:
24 30
M internal/adapters/sshstdio/transport.go +11 -3
@@ -10,6 +10,7 @@ import (
1010 "fmt"
1111 "io"
1212 "os/exec"
13+ "strings"
1314 "sync"
1415
1516 "github.com/bnema/vev/internal/ports"
@@ -96,15 +97,22 @@ type CommandSpec struct {
9697 Args []string
9798 }
9899
99-// BuildCommand constructs the ssh subprocess argv without invoking a shell.
100+// BuildCommand constructs the local ssh subprocess argv without invoking a
101+// local shell. OpenSSH sends the remote command as one string for the remote
102+// user's shell to interpret, so every remote argv word is POSIX single-quoted.
100103 func BuildCommand(target, session string) CommandSpec {
101- args := []string{target, "vev", "_stdio"}
104+ remote := []string{shellQuote("vev"), shellQuote("_stdio")}
102105 if session != "" {
103- args = append(args, session)
106+ remote = append(remote, shellQuote(session))
104107 }
108+ args := []string{"--", target, strings.Join(remote, " ")}
105109 return CommandSpec{Path: "ssh", Args: args}
106110 }
107111
112+func shellQuote(s string) string {
113+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
114+}
115+
108116 // Dial starts ssh target vev _stdio [session] and returns a Transport over the
109117 // child process' stdio. The subprocess is started with exec.Command argv, never
110118 // through a shell.
@@ -10,6 +10,7 @@ import (
10 "fmt"10 "fmt"
11 "io"11 "io"
12 "os/exec"12 "os/exec"
13+ "strings"
13 "sync"14 "sync"
14 15
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
@@ -96,15 +97,22 @@ type CommandSpec struct {
96 Args []string97 Args []string
97 }98 }
98 99
99-// BuildCommand constructs the ssh subprocess argv without invoking a shell.100+// BuildCommand constructs the local ssh subprocess argv without invoking a
101+// local shell. OpenSSH sends the remote command as one string for the remote
102+// user's shell to interpret, so every remote argv word is POSIX single-quoted.
100 func BuildCommand(target, session string) CommandSpec {103 func BuildCommand(target, session string) CommandSpec {
101- args := []string{target, "vev", "_stdio"}104+ remote := []string{shellQuote("vev"), shellQuote("_stdio")}
102 if session != "" {105 if session != "" {
103- args = append(args, session)106+ remote = append(remote, shellQuote(session))
104 }107 }
108+ args := []string{"--", target, strings.Join(remote, " ")}
105 return CommandSpec{Path: "ssh", Args: args}109 return CommandSpec{Path: "ssh", Args: args}
106 }110 }
107 111
112+func shellQuote(s string) string {
113+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
114+}
115+
108 // Dial starts ssh target vev _stdio [session] and returns a Transport over the116 // Dial starts ssh target vev _stdio [session] and returns a Transport over the
109 // child process' stdio. The subprocess is started with exec.Command argv, never117 // child process' stdio. The subprocess is started with exec.Command argv, never
110 // through a shell.118 // through a shell.
M internal/adapters/sshstdio/transport_test.go +4 -3
@@ -16,9 +16,10 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
1616 session string
1717 want []string
1818 }{
19- {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}},
20- {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}},
21- {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}},
19+ {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},
20+ {name: "session shell metacharacters are quoted in remote command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio' 'work; rm -rf /'"}},
21+ {name: "single quote in session is posix escaped", target: "user@example.com", session: "it's fine", want: []string{"--", "user@example.com", "'vev' '_stdio' 'it'\\''s fine'"}},
22+ {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio' 'work'"}},
2223 }
2324
2425 for _, tt := range tests {
@@ -16,9 +16,10 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
16 session string16 session string
17 want []string17 want []string
18 }{18 }{
19- {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}},19+ {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},
20- {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}},20+ {name: "session shell metacharacters are quoted in remote command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio' 'work; rm -rf /'"}},
21- {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}},21+ {name: "single quote in session is posix escaped", target: "user@example.com", session: "it's fine", want: []string{"--", "user@example.com", "'vev' '_stdio' 'it'\\''s fine'"}},
22+ {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio' 'work'"}},
22 }23 }
23 24
24 for _, tt := range tests {25 for _, tt := range tests {
M internal/usecase/copy/mode.go +11 -1
@@ -7,7 +7,12 @@ import (
77 "github.com/bnema/vev/pkg/renderer"
88 )
99
10-const OSC52ChunkSize = 75_000
10+// OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
11+// exactly one OSC 52 sequence. Splitting one clipboard copy across multiple OSC
12+// 52 sequences replaces the clipboard repeatedly in common terminals, corrupting
13+// the intended result, so oversized selections are deferred until a terminal-
14+// specific continuation protocol is supported.
15+const OSC52MaxPayloadBytes = 75_000
1116
1217 // Snapshot is the copy-mode document: scrollback followed by the live screen.
1318 type Snapshot struct {
@@ -170,7 +175,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
170175 }
171176
172177 // OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.
178+// Oversized payloads return no sequence rather than emitting corrupting
179+// multi-sequence replacements.
173180 func OSC52(text string) [][]byte {
181+ if len([]byte(text)) > OSC52MaxPayloadBytes {
182+ return nil
183+ }
174184 encoded := base64.StdEncoding.EncodeToString([]byte(text))
175185 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
176186 }
@@ -7,7 +7,12 @@ import (
7 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10-const OSC52ChunkSize = 75_00010+// OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
11+// exactly one OSC 52 sequence. Splitting one clipboard copy across multiple OSC
12+// 52 sequences replaces the clipboard repeatedly in common terminals, corrupting
13+// the intended result, so oversized selections are deferred until a terminal-
14+// specific continuation protocol is supported.
15+const OSC52MaxPayloadBytes = 75_000
11 16
12 // Snapshot is the copy-mode document: scrollback followed by the live screen.17 // Snapshot is the copy-mode document: scrollback followed by the live screen.
13 type Snapshot struct {18 type Snapshot struct {
@@ -170,7 +175,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
170 }175 }
171 176
172 // OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.177 // OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.
178+// Oversized payloads return no sequence rather than emitting corrupting
179+// multi-sequence replacements.
173 func OSC52(text string) [][]byte {180 func OSC52(text string) [][]byte {
181+ if len([]byte(text)) > OSC52MaxPayloadBytes {
182+ return nil
183+ }
174 encoded := base64.StdEncoding.EncodeToString([]byte(text))184 encoded := base64.StdEncoding.EncodeToString([]byte(text))
175 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}185 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
176 }186 }
M internal/usecase/copy/mode_test.go +8 -1
@@ -110,7 +110,7 @@ func TestOSC52Base64Encoding(t *testing.T) {
110110 text string
111111 }{
112112 {name: "small payload", text: "hello\nworld"},
113- {name: "large payload", text: strings.Repeat("x", OSC52ChunkSize)},
113+ {name: "maximum payload", text: strings.Repeat("x", OSC52MaxPayloadBytes)},
114114 }
115115 for _, tt := range tests {
116116 t.Run(tt.name, func(t *testing.T) {
@@ -128,6 +128,13 @@ func TestOSC52Base64Encoding(t *testing.T) {
128128 }
129129 }
130130
131+func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
132+ chunks := OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1))
133+ if len(chunks) != 0 {
134+ t.Fatalf("len(chunks) = %d, want 0 to avoid corrupt multi-sequence clipboard replacement", len(chunks))
135+ }
136+}
137+
131138 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
132139 t.Helper()
133140 s := string(chunk)
@@ -110,7 +110,7 @@ func TestOSC52Base64Encoding(t *testing.T) {
110 text string110 text string
111 }{111 }{
112 {name: "small payload", text: "hello\nworld"},112 {name: "small payload", text: "hello\nworld"},
113- {name: "large payload", text: strings.Repeat("x", OSC52ChunkSize)},113+ {name: "maximum payload", text: strings.Repeat("x", OSC52MaxPayloadBytes)},
114 }114 }
115 for _, tt := range tests {115 for _, tt := range tests {
116 t.Run(tt.name, func(t *testing.T) {116 t.Run(tt.name, func(t *testing.T) {
@@ -128,6 +128,13 @@ func TestOSC52Base64Encoding(t *testing.T) {
128 }128 }
129 }129 }
130 130
131+func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
132+ chunks := OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1))
133+ if len(chunks) != 0 {
134+ t.Fatalf("len(chunks) = %d, want 0 to avoid corrupt multi-sequence clipboard replacement", len(chunks))
135+ }
136+}
137+
131 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {138 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
132 t.Helper()139 t.Helper()
133 s := string(chunk)140 s := string(chunk)
M internal/usecase/daemon/daemon.go +3 -1
@@ -831,7 +831,9 @@ func (h daemonKeyHandler) Forward(data []byte) {
831831 if win == nil {
832832 return
833833 }
834- _, _ = win.pty.Write(data)
834+ if _, err := win.pty.Write(data); err != nil {
835+ h.d.log.Error("pty write failed", "err", err, "session", h.sess.name)
836+ }
835837 }
836838
837839 func (h daemonKeyHandler) Action(action keys.Action) {
@@ -831,7 +831,9 @@ func (h daemonKeyHandler) Forward(data []byte) {
831 if win == nil {831 if win == nil {
832 return832 return
833 }833 }
834- _, _ = win.pty.Write(data)834+ if _, err := win.pty.Write(data); err != nil {
835+ h.d.log.Error("pty write failed", "err", err, "session", h.sess.name)
836+ }
835 }837 }
836 838
837 func (h daemonKeyHandler) Action(action keys.Action) {839 func (h daemonKeyHandler) Action(action keys.Action) {
M internal/usecase/daemon/daemon_test.go +20 -0
@@ -1,7 +1,9 @@
11 package daemon
22
33 import (
4+ "bytes"
45 "context"
6+ "errors"
57 "io"
68 "log/slog"
79 "strings"
@@ -229,6 +231,24 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
229231 return d, sess, ac, sends
230232 }
231233
234+func TestPTYWriteErrorIsLogged(t *testing.T) {
235+ var logs bytes.Buffer
236+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
237+ errBoom := errors.New("boom")
238+ p := portsmocks.NewMockPTY(t)
239+ p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
240+ win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
241+ sess := &session{id: "manual", name: "work", windows: []*window{win}}
242+ ac := &attachedClient{}
243+
244+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input"))
245+
246+ got := logs.String()
247+ if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
248+ t.Fatalf("log output %q does not contain PTY write failure details", got)
249+ }
250+}
251+
232252 func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
233253 t.Helper()
234254 ptys := make([]ports.PTY, 0, n)
@@ -1,7 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "errors"
5 "io"7 "io"
6 "log/slog"8 "log/slog"
7 "strings"9 "strings"
@@ -229,6 +231,24 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
229 return d, sess, ac, sends231 return d, sess, ac, sends
230 }232 }
231 233
234+func TestPTYWriteErrorIsLogged(t *testing.T) {
235+ var logs bytes.Buffer
236+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
237+ errBoom := errors.New("boom")
238+ p := portsmocks.NewMockPTY(t)
239+ p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
240+ win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
241+ sess := &session{id: "manual", name: "work", windows: []*window{win}}
242+ ac := &attachedClient{}
243+
244+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input"))
245+
246+ got := logs.String()
247+ if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
248+ t.Fatalf("log output %q does not contain PTY write failure details", got)
249+ }
250+}
251+
232 func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {252 func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
233 t.Helper()253 t.Helper()
234 ptys := make([]ports.PTY, 0, n)254 ptys := make([]ports.PTY, 0, n)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close