detached e9c558f

Merge remote-tracking branch 'origin/main' into feature/clipboard-image-transfer

# Conflicts:
#	internal/app/run.go
#	internal/app/run_test.go
#	internal/usecase/client/client.go
#	internal/usecase/client/client_test.go
#	internal/usecase/daemon/clipboard.go
#	internal/usecase/daemon/clipboard_test.go
#	internal/usecase/daemon/session.go
commit
e9c558fb63ca335b979a599a27bcd8183d026882
author
brice · · 2026-07-04 16:49 UTC
merge of
f71e65c + 71edbd4 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +1248 -184
M AGENTS.md +1 -0
@@ -19,6 +19,7 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem # m
1919
2020 - Format with **goimports**, not gofmt — lint enforces it.
2121 - Daemon pprof is opt-in via `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (see `docs/performance.md`).
22+- Live logs are under `$XDG_STATE_HOME/vev` (or `~/.local/state/vev` when `XDG_STATE_HOME` is unset): `vev-daemon.log`, `vev-client.log`, `vev-stdio.log`, plus matching `*-crash.log` files.
2223 - Feature branches live in git worktrees under `.worktrees/<branch-name>`.
2324
2425 ## Architecture
@@ -19,6 +19,7 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem # m
19 19
20 - Format with **goimports**, not gofmt — lint enforces it.20 - Format with **goimports**, not gofmt — lint enforces it.
21 - Daemon pprof is opt-in via `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (see `docs/performance.md`).21 - Daemon pprof is opt-in via `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (see `docs/performance.md`).
22+- Live logs are under `$XDG_STATE_HOME/vev` (or `~/.local/state/vev` when `XDG_STATE_HOME` is unset): `vev-daemon.log`, `vev-client.log`, `vev-stdio.log`, plus matching `*-crash.log` files.
22 - Feature branches live in git worktrees under `.worktrees/<branch-name>`.23 - Feature branches live in git worktrees under `.worktrees/<branch-name>`.
23 24
24 ## Architecture25 ## Architecture
M README.md +4 -17
@@ -1,6 +1,9 @@
11 # vev
22
3-A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).
3+There is many terminal multiplexers but this one is mine. Written in Go, with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Purely focused on performance, rendering of high output of vertical text, agentic coding and remote work over ssh.
4+
5+> [!IMPORTANT]
6+> This is totally in alpha
47
58 ## Install
69
@@ -81,19 +84,3 @@ Visual mode freezes a view over history while the program keeps running undernea
8184 | v or Space | toggle line selection |
8285 | y or Enter | copy selection and exit |
8386 | q, Esc, Ctrl-C | exit without copying |
84-
85-Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.
86-
87-## Mouse
88-
89-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
90-
91-vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
92-
93-## Development
94-
95-```sh
96-go test ./... -race
97-```
98-
99-`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
@@ -1,6 +1,9 @@
1 # vev1 # vev
2 2
3-A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).3+There is many terminal multiplexers but this one is mine. Written in Go, with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Purely focused on performance, rendering of high output of vertical text, agentic coding and remote work over ssh.
4+
5+> [!IMPORTANT]
6+> This is totally in alpha
4 7
5 ## Install8 ## Install
6 9
@@ -81,19 +84,3 @@ Visual mode freezes a view over history while the program keeps running undernea
81 | v or Space | toggle line selection |84 | v or Space | toggle line selection |
82 | y or Enter | copy selection and exit |85 | y or Enter | copy selection and exit |
83 | q, Esc, Ctrl-C | exit without copying |86 | q, Esc, Ctrl-C | exit without copying |
84-
85-Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.
86-
87-## Mouse
88-
89-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
90-
91-vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
92-
93-## Development
94-
95-```sh
96-go test ./... -race
97-```
98-
99-`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
M internal/adapters/dgram/ssh_dialer.go +7 -1
@@ -2,6 +2,7 @@ package dgram
22
33 import (
44 "context"
5+ "log/slog"
56 "net"
67 "strings"
78 "time"
@@ -35,12 +36,17 @@ type RemoteDialer struct {
3536 Target string
3637 Session string
3738 ProbeTimeout time.Duration
39+ Log *slog.Logger
3840 }
3941
4042 func NewRemoteDialer(target, session string) RemoteDialer {
4143 return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
4244 }
4345
46+func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {
47+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}
48+}
49+
4450 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
4551 // The UDP bootstrap currently requires a long-lived remote proxy process. Do
4652 // not select it as the app's remote attach path until that process can detach
@@ -50,7 +56,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
5056 return nil, ctx.Err()
5157 default:
5258 }
53- return sshstdio.DialContext(ctx, d.Target, d.Session)
59+ return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log)
5460 }
5561
5662 func sshTargetHost(target string) string {
@@ -2,6 +2,7 @@ package dgram
2 2
3 import (3 import (
4 "context"4 "context"
5+ "log/slog"
5 "net"6 "net"
6 "strings"7 "strings"
7 "time"8 "time"
@@ -35,12 +36,17 @@ type RemoteDialer struct {
35 Target string36 Target string
36 Session string37 Session string
37 ProbeTimeout time.Duration38 ProbeTimeout time.Duration
39+ Log *slog.Logger
38 }40 }
39 41
40 func NewRemoteDialer(target, session string) RemoteDialer {42 func NewRemoteDialer(target, session string) RemoteDialer {
41 return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}43 return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
42 }44 }
43 45
46+func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {
47+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}
48+}
49+
44 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {50 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
45 // The UDP bootstrap currently requires a long-lived remote proxy process. Do51 // The UDP bootstrap currently requires a long-lived remote proxy process. Do
46 // not select it as the app's remote attach path until that process can detach52 // not select it as the app's remote attach path until that process can detach
@@ -50,7 +56,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
50 return nil, ctx.Err()56 return nil, ctx.Err()
51 default:57 default:
52 }58 }
53- return sshstdio.DialContext(ctx, d.Target, d.Session)59+ return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log)
54 }60 }
55 61
56 func sshTargetHost(target string) string {62 func sshTargetHost(target string) string {
M internal/adapters/dgram/transport.go +15 -6
@@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) {
288288 return
289289 }
290290 if reliable {
291- if t.enqueueReliable(seq, f) {
291+ ack, queued := t.enqueueReliable(seq, f)
292+ if ack {
292293 t.sendAck(seq)
293294 }
295+ if queued {
296+ t.signalDelivery()
297+ }
294298 return
295299 }
296300 t.deliver(f)
297301 }
298302 }
299303
300-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
304+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) {
301305 t.deliverMu.Lock()
302306 defer t.deliverMu.Unlock()
303307 if seq < t.nextRecvSeq {
304- return true
308+ return true, false
305309 }
306310 if _, exists := t.recvBuf[seq]; exists {
307- return true
311+ return true, false
308312 }
309313 if len(t.recvBuf) >= maxRecvBuffer {
310314 if seq != t.nextRecvSeq {
311- return false
315+ return false, false
312316 }
313317 for bufferedSeq := range t.recvBuf {
314318 delete(t.recvBuf, bufferedSeq)
@@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
316320 }
317321 }
318322 t.recvBuf[seq] = f
323+ return true, true
324+}
325+
326+func (t *Transport) signalDelivery() {
327+ t.deliverMu.Lock()
319328 t.deliverCond.Signal()
320- return true
329+ t.deliverMu.Unlock()
321330 }
322331
323332 func (t *Transport) deliveryLoop() {
@@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) {
288 return288 return
289 }289 }
290 if reliable {290 if reliable {
291- if t.enqueueReliable(seq, f) {291+ ack, queued := t.enqueueReliable(seq, f)
292+ if ack {
292 t.sendAck(seq)293 t.sendAck(seq)
293 }294 }
295+ if queued {
296+ t.signalDelivery()
297+ }
294 return298 return
295 }299 }
296 t.deliver(f)300 t.deliver(f)
297 }301 }
298 }302 }
299 303
300-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {304+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ack bool, queued bool) {
301 t.deliverMu.Lock()305 t.deliverMu.Lock()
302 defer t.deliverMu.Unlock()306 defer t.deliverMu.Unlock()
303 if seq < t.nextRecvSeq {307 if seq < t.nextRecvSeq {
304- return true308+ return true, false
305 }309 }
306 if _, exists := t.recvBuf[seq]; exists {310 if _, exists := t.recvBuf[seq]; exists {
307- return true311+ return true, false
308 }312 }
309 if len(t.recvBuf) >= maxRecvBuffer {313 if len(t.recvBuf) >= maxRecvBuffer {
310 if seq != t.nextRecvSeq {314 if seq != t.nextRecvSeq {
311- return false315+ return false, false
312 }316 }
313 for bufferedSeq := range t.recvBuf {317 for bufferedSeq := range t.recvBuf {
314 delete(t.recvBuf, bufferedSeq)318 delete(t.recvBuf, bufferedSeq)
@@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
316 }320 }
317 }321 }
318 t.recvBuf[seq] = f322 t.recvBuf[seq] = f
323+ return true, true
324+}
325+
326+func (t *Transport) signalDelivery() {
327+ t.deliverMu.Lock()
319 t.deliverCond.Signal()328 t.deliverCond.Signal()
320- return true329+ t.deliverMu.Unlock()
321 }330 }
322 331
323 func (t *Transport) deliveryLoop() {332 func (t *Transport) deliveryLoop() {
M internal/adapters/dgram/transport_test.go +2 -3
@@ -59,9 +59,8 @@ func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
5959 }
6060 peer := p.peers[addr.String()]
6161 peer.mu.Lock()
62- peerClosed := peer.closed
63- peer.mu.Unlock()
64- if peerClosed {
62+ defer peer.mu.Unlock()
63+ if peer.closed {
6564 return 0, errors.New("peer closed")
6665 }
6766 peer.in <- packet{append([]byte(nil), b...), p.addr}
@@ -59,9 +59,8 @@ func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
59 }59 }
60 peer := p.peers[addr.String()]60 peer := p.peers[addr.String()]
61 peer.mu.Lock()61 peer.mu.Lock()
62- peerClosed := peer.closed62+ defer peer.mu.Unlock()
63- peer.mu.Unlock()63+ if peer.closed {
64- if peerClosed {
65 return 0, errors.New("peer closed")64 return 0, errors.New("peer closed")
66 }65 }
67 peer.in <- packet{append([]byte(nil), b...), p.addr}66 peer.in <- packet{append([]byte(nil), b...), p.addr}
M internal/adapters/sshstdio/transport.go +21 -4
@@ -11,6 +11,7 @@ import (
1111 "errors"
1212 "fmt"
1313 "io"
14+ "log/slog"
1415 "os/exec"
1516 "strings"
1617 "sync"
@@ -95,7 +96,7 @@ func (t *transport) Recv() (ports.Frame, error) {
9596
9697 func (t *transport) Close() error { return t.close() }
9798
98-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {
99+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
99100 return func() error {
100101 _ = stdin.Close()
101102
@@ -114,6 +115,13 @@ func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, time
114115 }
115116
116117 stderrText := strings.TrimSpace(stderr.String())
118+ if log != nil {
119+ attrs := []any{"target", target, "session", session, "err", err}
120+ if stderrText != "" {
121+ attrs = append(attrs, "stderr", stderrText)
122+ }
123+ log.Warn("ssh exited non-cleanly", attrs...)
124+ }
117125 if stderrText != "" {
118126 return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
119127 }
@@ -157,11 +165,17 @@ func Dial(target, session string) (ports.Transport, error) {
157165 }
158166
159167 // DialContext is like Dial, but the context is propagated to ssh startup so a
160-// canceled attach attempt interrupts the local ssh process.
161-func DialContext(ctx context.Context, target, session string) (ports.Transport, error) {
168+// canceled attach attempt interrupts the local ssh process. Callers may pass a
169+// logger to record ssh start failures and non-clean exits without logging the
170+// generated command line.
171+func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
162172 if err := ctx.Err(); err != nil {
163173 return nil, err
164174 }
175+ var log *slog.Logger
176+ if len(logger) > 0 {
177+ log = logger[0]
178+ }
165179 spec := BuildCommand(target, session)
166180 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
167181 stdin, err := cmd.StdinPipe()
@@ -175,8 +189,11 @@ func DialContext(ctx context.Context, target, session string) (ports.Transport,
175189 var stderr bytes.Buffer
176190 cmd.Stderr = &stderr
177191 if err := cmd.Start(); err != nil {
192+ if log != nil {
193+ log.Error("ssh start failed", "target", target, "session", session, "err", err)
194+ }
178195 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
179196 }
180197
181- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil
198+ return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil
182199 }
@@ -11,6 +11,7 @@ import (
11 "errors"11 "errors"
12 "fmt"12 "fmt"
13 "io"13 "io"
14+ "log/slog"
14 "os/exec"15 "os/exec"
15 "strings"16 "strings"
16 "sync"17 "sync"
@@ -95,7 +96,7 @@ func (t *transport) Recv() (ports.Frame, error) {
95 96
96 func (t *transport) Close() error { return t.close() }97 func (t *transport) Close() error { return t.close() }
97 98
98-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {99+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
99 return func() error {100 return func() error {
100 _ = stdin.Close()101 _ = stdin.Close()
101 102
@@ -114,6 +115,13 @@ func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, time
114 }115 }
115 116
116 stderrText := strings.TrimSpace(stderr.String())117 stderrText := strings.TrimSpace(stderr.String())
118+ if log != nil {
119+ attrs := []any{"target", target, "session", session, "err", err}
120+ if stderrText != "" {
121+ attrs = append(attrs, "stderr", stderrText)
122+ }
123+ log.Warn("ssh exited non-cleanly", attrs...)
124+ }
117 if stderrText != "" {125 if stderrText != "" {
118 return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)126 return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
119 }127 }
@@ -157,11 +165,17 @@ func Dial(target, session string) (ports.Transport, error) {
157 }165 }
158 166
159 // DialContext is like Dial, but the context is propagated to ssh startup so a167 // DialContext is like Dial, but the context is propagated to ssh startup so a
160-// canceled attach attempt interrupts the local ssh process.168+// canceled attach attempt interrupts the local ssh process. Callers may pass a
161-func DialContext(ctx context.Context, target, session string) (ports.Transport, error) {169+// logger to record ssh start failures and non-clean exits without logging the
170+// generated command line.
171+func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
162 if err := ctx.Err(); err != nil {172 if err := ctx.Err(); err != nil {
163 return nil, err173 return nil, err
164 }174 }
175+ var log *slog.Logger
176+ if len(logger) > 0 {
177+ log = logger[0]
178+ }
165 spec := BuildCommand(target, session)179 spec := BuildCommand(target, session)
166 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)180 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
167 stdin, err := cmd.StdinPipe()181 stdin, err := cmd.StdinPipe()
@@ -175,8 +189,11 @@ func DialContext(ctx context.Context, target, session string) (ports.Transport,
175 var stderr bytes.Buffer189 var stderr bytes.Buffer
176 cmd.Stderr = &stderr190 cmd.Stderr = &stderr
177 if err := cmd.Start(); err != nil {191 if err := cmd.Start(); err != nil {
192+ if log != nil {
193+ log.Error("ssh start failed", "target", target, "session", session, "err", err)
194+ }
178 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)195 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
179 }196 }
180 197
181- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil198+ return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil
182 }199 }
M internal/adapters/sshstdio/transport_test.go +31 -1
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "io"
8+ "log/slog"
89 "os/exec"
910 "strings"
1011 "testing"
@@ -125,6 +126,35 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
125126 }
126127 }
127128
129+func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
130+ cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
131+ stdin, err := cmd.StdinPipe()
132+ if err != nil {
133+ t.Fatalf("StdinPipe: %v", err)
134+ }
135+ var stderr bytes.Buffer
136+ cmd.Stderr = &stderr
137+ if err := cmd.Start(); err != nil {
138+ t.Fatalf("Start: %v", err)
139+ }
140+
141+ var logBuf bytes.Buffer
142+ log := slog.New(slog.NewJSONHandler(&logBuf, nil))
143+ err = newProcessCloser(cmd, stdin, &stderr, time.Second, log, "user@example.com", "work")()
144+ if err == nil {
145+ t.Fatal("Close error = nil, want non-clean ssh exit")
146+ }
147+ entry := logBuf.String()
148+ for _, want := range []string{"ssh exited non-cleanly", "remote failure", "user@example.com", "work"} {
149+ if !strings.Contains(entry, want) {
150+ t.Fatalf("log entry = %q, want substring %q", entry, want)
151+ }
152+ }
153+ if strings.Contains(entry, "'vev' '_stdio'") || strings.Contains(entry, "-- user@example.com") {
154+ t.Fatalf("log entry includes generated command line: %q", entry)
155+ }
156+}
157+
128158 func TestProcessCloser(t *testing.T) {
129159 tests := []struct {
130160 name string
@@ -161,7 +191,7 @@ func TestProcessCloser(t *testing.T) {
161191 }
162192
163193 started := time.Now()
164- err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()
194+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")()
165195 elapsed := time.Since(started)
166196
167197 if err == nil {
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "log/slog"
8 "os/exec"9 "os/exec"
9 "strings"10 "strings"
10 "testing"11 "testing"
@@ -125,6 +126,35 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
125 }126 }
126 }127 }
127 128
129+func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
130+ cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
131+ stdin, err := cmd.StdinPipe()
132+ if err != nil {
133+ t.Fatalf("StdinPipe: %v", err)
134+ }
135+ var stderr bytes.Buffer
136+ cmd.Stderr = &stderr
137+ if err := cmd.Start(); err != nil {
138+ t.Fatalf("Start: %v", err)
139+ }
140+
141+ var logBuf bytes.Buffer
142+ log := slog.New(slog.NewJSONHandler(&logBuf, nil))
143+ err = newProcessCloser(cmd, stdin, &stderr, time.Second, log, "user@example.com", "work")()
144+ if err == nil {
145+ t.Fatal("Close error = nil, want non-clean ssh exit")
146+ }
147+ entry := logBuf.String()
148+ for _, want := range []string{"ssh exited non-cleanly", "remote failure", "user@example.com", "work"} {
149+ if !strings.Contains(entry, want) {
150+ t.Fatalf("log entry = %q, want substring %q", entry, want)
151+ }
152+ }
153+ if strings.Contains(entry, "'vev' '_stdio'") || strings.Contains(entry, "-- user@example.com") {
154+ t.Fatalf("log entry includes generated command line: %q", entry)
155+ }
156+}
157+
128 func TestProcessCloser(t *testing.T) {158 func TestProcessCloser(t *testing.T) {
129 tests := []struct {159 tests := []struct {
130 name string160 name string
@@ -161,7 +191,7 @@ func TestProcessCloser(t *testing.T) {
161 }191 }
162 192
163 started := time.Now()193 started := time.Now()
164- err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()194+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")()
165 elapsed := time.Since(started)195 elapsed := time.Since(started)
166 196
167 if err == nil {197 if err == nil {
D internal/app/logging.go +0 -50
@@ -1,50 +0,0 @@
1-package app
2-
3-import (
4- "fmt"
5- "log/slog"
6- "os"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/vev/internal/platform"
11-)
12-
13-// logFileName is the fixed name of vev's shared log file, used by both the
14-// daemon and the raw-attached client (which must never log to the console).
15-const logFileName = "vev.log"
16-
17-// parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
18-func parseLogLevel(s string) slog.Level {
19- switch strings.ToLower(strings.TrimSpace(s)) {
20- case "debug":
21- return slog.LevelDebug
22- case "warn", "warning":
23- return slog.LevelWarn
24- case "error":
25- return slog.LevelError
26- default:
27- return slog.LevelInfo
28- }
29-}
30-
31-// setupLogging installs a JSON slog handler writing to the shared log file
32-// and returns the open file so the caller can close it on exit. The level
33-// comes from VEV_LOG (default info). Both the daemon and the client route
34-// diagnostics here — the client because console output while the terminal is
35-// raw would corrupt the display.
36-//
37-// No log rotation is performed for the MVP (future work).
38-func setupLogging() (*os.File, error) {
39- dir := platform.StateDir()
40- if err := os.MkdirAll(dir, 0o700); err != nil {
41- return nil, fmt.Errorf("vev: creating state dir: %w", err)
42- }
43- f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
44- if err != nil {
45- return nil, fmt.Errorf("vev: opening log file: %w", err)
46- }
47- handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))})
48- slog.SetDefault(slog.New(handler))
49- return f, nil
50-}
@@ -1,50 +0,0 @@
1-package app
2-
3-import (
4- "fmt"
5- "log/slog"
6- "os"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/vev/internal/platform"
11-)
12-
13-// logFileName is the fixed name of vev's shared log file, used by both the
14-// daemon and the raw-attached client (which must never log to the console).
15-const logFileName = "vev.log"
16-
17-// parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
18-func parseLogLevel(s string) slog.Level {
19- switch strings.ToLower(strings.TrimSpace(s)) {
20- case "debug":
21- return slog.LevelDebug
22- case "warn", "warning":
23- return slog.LevelWarn
24- case "error":
25- return slog.LevelError
26- default:
27- return slog.LevelInfo
28- }
29-}
30-
31-// setupLogging installs a JSON slog handler writing to the shared log file
32-// and returns the open file so the caller can close it on exit. The level
33-// comes from VEV_LOG (default info). Both the daemon and the client route
34-// diagnostics here — the client because console output while the terminal is
35-// raw would corrupt the display.
36-//
37-// No log rotation is performed for the MVP (future work).
38-func setupLogging() (*os.File, error) {
39- dir := platform.StateDir()
40- if err := os.MkdirAll(dir, 0o700); err != nil {
41- return nil, fmt.Errorf("vev: creating state dir: %w", err)
42- }
43- f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
44- if err != nil {
45- return nil, fmt.Errorf("vev: opening log file: %w", err)
46- }
47- handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))})
48- slog.SetDefault(slog.New(handler))
49- return f, nil
50-}
M internal/app/run.go +72 -27
@@ -29,6 +29,7 @@ import (
2929 "github.com/bnema/vev/internal/adapters/sshstdio"
3030 "github.com/bnema/vev/internal/adapters/term"
3131 "github.com/bnema/vev/internal/domain"
32+ "github.com/bnema/vev/internal/logging"
3233 "github.com/bnema/vev/internal/persist"
3334 "github.com/bnema/vev/internal/platform"
3435 "github.com/bnema/vev/internal/ports"
@@ -213,19 +214,30 @@ func dispatch(ctx context.Context, cmd command) error {
213214 }
214215 }
215216
217+func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
218+ return logging.Setup(logging.Config{
219+ Dir: platform.StateDir(),
220+ Component: component,
221+ Level: logging.EnvLevel(),
222+ MaxBytes: logging.DefaultMaxBytes,
223+ RotateAtRuntime: rotateAtRuntime,
224+ })
225+}
226+
216227 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
217228 // entered by an auto-spawned child): it sets up logging, binds the socket,
218229 // constructs the daemon, and serves until the last session exits or a
219230 // termination signal arrives (graceful shutdown notifies attached clients).
220231 func runDaemon() error {
221- logFile, err := setupLogging()
232+ log, logCloser, err := configureLogging(logging.Daemon, true)
222233 if err != nil {
223234 return err
224235 }
225- defer func() { _ = logFile.Close() }()
236+ defer func() { _ = logCloser.Close() }()
226237
227238 ln, err := ipc.Listen(ipc.SocketDir())
228239 if err != nil {
240+ log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
229241 return fmt.Errorf("vev: daemon listen: %w", err)
230242 }
231243 defer func() { _ = ln.Close() }()
@@ -236,25 +248,27 @@ func runDaemon() error {
236248 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
237249 go func() {
238250 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
239- slog.Error("pprof server exited", "err", err)
251+ log.Error("pprof server exited", "err", err)
240252 }
241253 }()
242- slog.Info("pprof enabled", "addr", addr)
254+ log.Info("pprof enabled", "addr", addr)
243255 }
244256
245- slog.Info("daemon starting", "socket", ln.Addr())
257+ log.Info("daemon starting", "socket", ln.Addr())
246258 daemonOpts := []daemon.Option(nil)
247- if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {
248- slog.Warn("opening session store failed; persistence disabled", "err", err)
259+ storePath := persist.StorePath(platform.StateDir())
260+ if store, err := kv.Open(storePath); err != nil {
261+ log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
249262 } else {
263+ log.Info("session persistence enabled", "path", storePath)
250264 daemonOpts = append(daemonOpts, daemon.WithStore(store))
251265 }
252- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
266+ d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...)
253267 if err := d.Serve(ctx, ln); err != nil {
254- slog.Error("daemon exited", "err", err)
268+ log.Error("daemon exited", "err", err)
255269 return err
256270 }
257- slog.Info("daemon exited cleanly")
271+ log.Info("daemon exited cleanly")
258272 return nil
259273 }
260274
@@ -262,13 +276,13 @@ func runDaemon() error {
262276 // attach loop. Logging goes to the shared file: the client must never write
263277 // to the console while the terminal is raw.
264278 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
265- logFile, err := setupLogging()
279+ log, logCloser, err := configureLogging(logging.Client, false)
266280 if err != nil {
267281 return err
268282 }
269- defer func() { _ = logFile.Close() }()
283+ defer func() { _ = logCloser.Close() }()
270284
271- return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{
285+ return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
272286 localDialer: defaultLocalDialer,
273287 remoteDialer: defaultRemoteDialer,
274288 runClient: client.Run,
@@ -279,15 +293,15 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
279293
280294 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
281295
282-func defaultRemoteDialer(target, session string) ports.Dialer {
283- return dgram.NewRemoteDialer(target, session)
296+func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {
297+ return dgram.NewRemoteDialerWithLogger(target, session, log)
284298 }
285299
286300 type runAttachDeps struct {
287- attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
301+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
288302 localDialer func() ports.Dialer
289- remoteDialer func(target, session string) ports.Dialer
290- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error
303+ remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
304+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
291305 createDetached func(context.Context, string) error
292306 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
293307 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
@@ -296,7 +310,7 @@ type runAttachDeps struct {
296310 clipboard ports.ClipboardReader
297311 }
298312
299-func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {
313+func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
300314 if activeSession != "" {
301315 if remoteTarget == "" && intent == ports.IntentNew {
302316 return deps.createDetached(ctx, name)
@@ -313,7 +327,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
313327 if remoteDialer == nil {
314328 remoteDialer = defaultRemoteDialer
315329 }
316- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard)
330+ if log != nil {
331+ log.Info("attaching to remote session", "target", remoteTarget, "name", name)
332+ }
333+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
317334 }
318335
319336 attachLocal := deps.attachLocal
@@ -322,13 +339,18 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
322339 if localDialer == nil {
323340 localDialer = defaultLocalDialer
324341 }
325- attachLocal = func(ctx context.Context, intent uint8, name string) error {
326- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil)
342+ attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
343+ if log != nil {
344+ log.Info("attaching to local session", "intent", intent, "name", name)
345+ }
346+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
327347 }
328348 }
329349 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
330- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
331- attach: attachLocal,
350+ confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
351+ attach: func(ctx context.Context, intent uint8, name string) error {
352+ return attachLocal(ctx, intent, name, log)
353+ },
332354 killDaemon: requestDaemonStop,
333355 settleAfterKill: waitForDaemonStop,
334356 })
@@ -496,6 +518,13 @@ func requestDaemonStop(ctx context.Context) error {
496518 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
497519 // framed protocol between process stdio and the daemon socket.
498520 func runStdio(ctx context.Context) error {
521+ log, logCloser, err := configureLogging(logging.Stdio, false)
522+ if err != nil {
523+ return err
524+ }
525+ defer func() { _ = logCloser.Close() }()
526+ log.Debug("stdio proxy starting")
527+
499528 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
500529 if err != nil {
501530 return err
@@ -503,13 +532,20 @@ func runStdio(ctx context.Context) error {
503532 defer func() { _ = transport.Close() }()
504533
505534 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
506- return proxyTransports(ctx, stdio, transport)
535+ return proxyTransports(ctx, stdio, transport, log)
507536 }
508537
509538 // runUDPBootstrap is the hidden remote-side bootstrap used before switching to
510539 // authenticated datagrams. It deliberately prints only an ephemeral UDP port and
511540 // random session key to stdout so callers can fall back to _stdio if setup fails.
512541 func runUDPBootstrap(ctx context.Context, session string) error {
542+ log, logCloser, err := configureLogging(logging.Stdio, false)
543+ if err != nil {
544+ return err
545+ }
546+ defer func() { _ = logCloser.Close() }()
547+ log.Debug("udp bootstrap starting", "session", session)
548+
513549 select {
514550 case <-ctx.Done():
515551 return ctx.Err()
@@ -549,7 +585,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
549585 if session != "" {
550586 dgramTr = preHelloNameTransport{Transport: dg, session: session}
551587 }
552- return proxyTransports(ctx, dgramTr, daemonTr)
588+ return proxyTransports(ctx, dgramTr, daemonTr, log)
553589 }
554590
555591 type preHelloNameTransport struct {
@@ -573,7 +609,7 @@ func (t preHelloNameTransport) Recv() (ports.Frame, error) {
573609 return f, nil
574610 }
575611
576-func proxyTransports(ctx context.Context, a, b ports.Transport) error {
612+func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
577613 errCh := make(chan error, 2)
578614 copyFrames := func(dst, src ports.Transport) {
579615 for {
@@ -593,11 +629,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
593629
594630 select {
595631 case <-ctx.Done():
632+ if log != nil {
633+ log.Info("stdio proxy stopped by context", "err", ctx.Err())
634+ }
596635 return ctx.Err()
597636 case err := <-errCh:
598637 if errors.Is(err, io.EOF) {
638+ if log != nil {
639+ log.Info("stdio proxy reached clean EOF")
640+ }
599641 return nil
600642 }
643+ if log != nil {
644+ log.Error("stdio proxy copy failed", "err", err)
645+ }
601646 return err
602647 }
603648 }
@@ -29,6 +29,7 @@ import (
29 "github.com/bnema/vev/internal/adapters/sshstdio"29 "github.com/bnema/vev/internal/adapters/sshstdio"
30 "github.com/bnema/vev/internal/adapters/term"30 "github.com/bnema/vev/internal/adapters/term"
31 "github.com/bnema/vev/internal/domain"31 "github.com/bnema/vev/internal/domain"
32+ "github.com/bnema/vev/internal/logging"
32 "github.com/bnema/vev/internal/persist"33 "github.com/bnema/vev/internal/persist"
33 "github.com/bnema/vev/internal/platform"34 "github.com/bnema/vev/internal/platform"
34 "github.com/bnema/vev/internal/ports"35 "github.com/bnema/vev/internal/ports"
@@ -213,19 +214,30 @@ func dispatch(ctx context.Context, cmd command) error {
213 }214 }
214 }215 }
215 216
217+func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
218+ return logging.Setup(logging.Config{
219+ Dir: platform.StateDir(),
220+ Component: component,
221+ Level: logging.EnvLevel(),
222+ MaxBytes: logging.DefaultMaxBytes,
223+ RotateAtRuntime: rotateAtRuntime,
224+ })
225+}
226+
216 // runDaemon runs the daemon in the foreground (the hidden --daemon path,227 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
217 // entered by an auto-spawned child): it sets up logging, binds the socket,228 // entered by an auto-spawned child): it sets up logging, binds the socket,
218 // constructs the daemon, and serves until the last session exits or a229 // constructs the daemon, and serves until the last session exits or a
219 // termination signal arrives (graceful shutdown notifies attached clients).230 // termination signal arrives (graceful shutdown notifies attached clients).
220 func runDaemon() error {231 func runDaemon() error {
221- logFile, err := setupLogging()232+ log, logCloser, err := configureLogging(logging.Daemon, true)
222 if err != nil {233 if err != nil {
223 return err234 return err
224 }235 }
225- defer func() { _ = logFile.Close() }()236+ defer func() { _ = logCloser.Close() }()
226 237
227 ln, err := ipc.Listen(ipc.SocketDir())238 ln, err := ipc.Listen(ipc.SocketDir())
228 if err != nil {239 if err != nil {
240+ log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
229 return fmt.Errorf("vev: daemon listen: %w", err)241 return fmt.Errorf("vev: daemon listen: %w", err)
230 }242 }
231 defer func() { _ = ln.Close() }()243 defer func() { _ = ln.Close() }()
@@ -236,25 +248,27 @@ func runDaemon() error {
236 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {248 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
237 go func() {249 go func() {
238 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {250 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
239- slog.Error("pprof server exited", "err", err)251+ log.Error("pprof server exited", "err", err)
240 }252 }
241 }()253 }()
242- slog.Info("pprof enabled", "addr", addr)254+ log.Info("pprof enabled", "addr", addr)
243 }255 }
244 256
245- slog.Info("daemon starting", "socket", ln.Addr())257+ log.Info("daemon starting", "socket", ln.Addr())
246 daemonOpts := []daemon.Option(nil)258 daemonOpts := []daemon.Option(nil)
247- if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {259+ storePath := persist.StorePath(platform.StateDir())
248- slog.Warn("opening session store failed; persistence disabled", "err", err)260+ if store, err := kv.Open(storePath); err != nil {
261+ log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
249 } else {262 } else {
263+ log.Info("session persistence enabled", "path", storePath)
250 daemonOpts = append(daemonOpts, daemon.WithStore(store))264 daemonOpts = append(daemonOpts, daemon.WithStore(store))
251 }265 }
252- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)266+ d := daemon.New(pty.NewFactory(), clock.New(), log, daemonOpts...)
253 if err := d.Serve(ctx, ln); err != nil {267 if err := d.Serve(ctx, ln); err != nil {
254- slog.Error("daemon exited", "err", err)268+ log.Error("daemon exited", "err", err)
255 return err269 return err
256 }270 }
257- slog.Info("daemon exited cleanly")271+ log.Info("daemon exited cleanly")
258 return nil272 return nil
259 }273 }
260 274
@@ -262,13 +276,13 @@ func runDaemon() error {
262 // attach loop. Logging goes to the shared file: the client must never write276 // attach loop. Logging goes to the shared file: the client must never write
263 // to the console while the terminal is raw.277 // to the console while the terminal is raw.
264 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {278 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
265- logFile, err := setupLogging()279+ log, logCloser, err := configureLogging(logging.Client, false)
266 if err != nil {280 if err != nil {
267 return err281 return err
268 }282 }
269- defer func() { _ = logFile.Close() }()283+ defer func() { _ = logCloser.Close() }()
270 284
271- return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{285+ return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
272 localDialer: defaultLocalDialer,286 localDialer: defaultLocalDialer,
273 remoteDialer: defaultRemoteDialer,287 remoteDialer: defaultRemoteDialer,
274 runClient: client.Run,288 runClient: client.Run,
@@ -279,15 +293,15 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
279 293
280 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }294 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
281 295
282-func defaultRemoteDialer(target, session string) ports.Dialer {296+func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {
283- return dgram.NewRemoteDialer(target, session)297+ return dgram.NewRemoteDialerWithLogger(target, session, log)
284 }298 }
285 299
286 type runAttachDeps struct {300 type runAttachDeps struct {
287- attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests301+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
288 localDialer func() ports.Dialer302 localDialer func() ports.Dialer
289- remoteDialer func(target, session string) ports.Dialer303+ remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
290- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader) error304+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
291 createDetached func(context.Context, string) error305 createDetached func(context.Context, string) error
292 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see306 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
293 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).307 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
@@ -296,7 +310,7 @@ type runAttachDeps struct {
296 clipboard ports.ClipboardReader310 clipboard ports.ClipboardReader
297 }311 }
298 312
299-func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {313+func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
300 if activeSession != "" {314 if activeSession != "" {
301 if remoteTarget == "" && intent == ports.IntentNew {315 if remoteTarget == "" && intent == ports.IntentNew {
302 return deps.createDetached(ctx, name)316 return deps.createDetached(ctx, name)
@@ -313,7 +327,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
313 if remoteDialer == nil {327 if remoteDialer == nil {
314 remoteDialer = defaultRemoteDialer328 remoteDialer = defaultRemoteDialer
315 }329 }
316- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name, true, deps.clipboard)330+ if log != nil {
331+ log.Info("attaching to remote session", "target", remoteTarget, "name", name)
332+ }
333+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
317 }334 }
318 335
319 attachLocal := deps.attachLocal336 attachLocal := deps.attachLocal
@@ -322,13 +339,18 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
322 if localDialer == nil {339 if localDialer == nil {
323 localDialer = defaultLocalDialer340 localDialer = defaultLocalDialer
324 }341 }
325- attachLocal = func(ctx context.Context, intent uint8, name string) error {342+ attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
326- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil)343+ if log != nil {
344+ log.Info("attaching to local session", "intent", intent, "name", name)
345+ }
346+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
327 }347 }
328 }348 }
329 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{349 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
330- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),350+ confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
331- attach: attachLocal,351+ attach: func(ctx context.Context, intent uint8, name string) error {
352+ return attachLocal(ctx, intent, name, log)
353+ },
332 killDaemon: requestDaemonStop,354 killDaemon: requestDaemonStop,
333 settleAfterKill: waitForDaemonStop,355 settleAfterKill: waitForDaemonStop,
334 })356 })
@@ -496,6 +518,13 @@ func requestDaemonStop(ctx context.Context) error {
496 // connects to the per-user daemon (auto-spawning it if needed) and proxies the518 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
497 // framed protocol between process stdio and the daemon socket.519 // framed protocol between process stdio and the daemon socket.
498 func runStdio(ctx context.Context) error {520 func runStdio(ctx context.Context) error {
521+ log, logCloser, err := configureLogging(logging.Stdio, false)
522+ if err != nil {
523+ return err
524+ }
525+ defer func() { _ = logCloser.Close() }()
526+ log.Debug("stdio proxy starting")
527+
499 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)528 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
500 if err != nil {529 if err != nil {
501 return err530 return err
@@ -503,13 +532,20 @@ func runStdio(ctx context.Context) error {
503 defer func() { _ = transport.Close() }()532 defer func() { _ = transport.Close() }()
504 533
505 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)534 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
506- return proxyTransports(ctx, stdio, transport)535+ return proxyTransports(ctx, stdio, transport, log)
507 }536 }
508 537
509 // runUDPBootstrap is the hidden remote-side bootstrap used before switching to538 // runUDPBootstrap is the hidden remote-side bootstrap used before switching to
510 // authenticated datagrams. It deliberately prints only an ephemeral UDP port and539 // authenticated datagrams. It deliberately prints only an ephemeral UDP port and
511 // random session key to stdout so callers can fall back to _stdio if setup fails.540 // random session key to stdout so callers can fall back to _stdio if setup fails.
512 func runUDPBootstrap(ctx context.Context, session string) error {541 func runUDPBootstrap(ctx context.Context, session string) error {
542+ log, logCloser, err := configureLogging(logging.Stdio, false)
543+ if err != nil {
544+ return err
545+ }
546+ defer func() { _ = logCloser.Close() }()
547+ log.Debug("udp bootstrap starting", "session", session)
548+
513 select {549 select {
514 case <-ctx.Done():550 case <-ctx.Done():
515 return ctx.Err()551 return ctx.Err()
@@ -549,7 +585,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
549 if session != "" {585 if session != "" {
550 dgramTr = preHelloNameTransport{Transport: dg, session: session}586 dgramTr = preHelloNameTransport{Transport: dg, session: session}
551 }587 }
552- return proxyTransports(ctx, dgramTr, daemonTr)588+ return proxyTransports(ctx, dgramTr, daemonTr, log)
553 }589 }
554 590
555 type preHelloNameTransport struct {591 type preHelloNameTransport struct {
@@ -573,7 +609,7 @@ func (t preHelloNameTransport) Recv() (ports.Frame, error) {
573 return f, nil609 return f, nil
574 }610 }
575 611
576-func proxyTransports(ctx context.Context, a, b ports.Transport) error {612+func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
577 errCh := make(chan error, 2)613 errCh := make(chan error, 2)
578 copyFrames := func(dst, src ports.Transport) {614 copyFrames := func(dst, src ports.Transport) {
579 for {615 for {
@@ -593,11 +629,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
593 629
594 select {630 select {
595 case <-ctx.Done():631 case <-ctx.Done():
632+ if log != nil {
633+ log.Info("stdio proxy stopped by context", "err", ctx.Err())
634+ }
596 return ctx.Err()635 return ctx.Err()
597 case err := <-errCh:636 case err := <-errCh:
598 if errors.Is(err, io.EOF) {637 if errors.Is(err, io.EOF) {
638+ if log != nil {
639+ log.Info("stdio proxy reached clean EOF")
640+ }
599 return nil641 return nil
600 }642 }
643+ if log != nil {
644+ log.Error("stdio proxy copy failed", "err", err)
645+ }
601 return err646 return err
602 }647 }
603 }648 }
M internal/app/run_test.go +10 -9
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "io"
8+ "log/slog"
89 "os"
910 "path/filepath"
1011 "strings"
@@ -346,8 +347,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
346347
347348 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
348349 called := false
349- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{
350- attachLocal: func(context.Context, uint8, string) error {
350+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
351+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
351352 called = true
352353 return nil
353354 },
@@ -369,8 +370,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
369370
370371 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
371372 var gotName string
372- err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{
373- attachLocal: func(context.Context, uint8, string) error {
373+ err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
374+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
374375 t.Fatal("nested new should not attach to the session")
375376 return nil
376377 },
@@ -403,13 +404,13 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
403404 var gotRemote bool
404405 var gotClipboard ports.ClipboardReader
405406 clip := &fakeClipboardReader{}
406- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
407- remoteDialer: func(target, session string) ports.Dialer {
407+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
408+ remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
408409 gotTarget, gotSession = target, session
409410 return namedDialer{name: "remote"}
410411 },
411412 clipboard: clip,
412- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
413+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
413414 gotDialer = d.(namedDialer).name
414415 gotRemote = remote
415416 gotClipboard = clipboard
@@ -437,10 +438,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
437438 var gotDialer string
438439 gotRemote := true
439440 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
440- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
441+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
441442 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
442443 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
443- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {
444+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
444445 gotDialer = d.(namedDialer).name
445446 gotRemote = remote
446447 gotClipboard = clipboard
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "log/slog"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
10 "strings"11 "strings"
@@ -346,8 +347,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
346 347
347 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {348 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
348 called := false349 called := false
349- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{350+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
350- attachLocal: func(context.Context, uint8, string) error {351+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
351 called = true352 called = true
352 return nil353 return nil
353 },354 },
@@ -369,8 +370,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
369 370
370 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {371 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
371 var gotName string372 var gotName string
372- err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{373+ err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
373- attachLocal: func(context.Context, uint8, string) error {374+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
374 t.Fatal("nested new should not attach to the session")375 t.Fatal("nested new should not attach to the session")
375 return nil376 return nil
376 },377 },
@@ -403,13 +404,13 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
403 var gotRemote bool404 var gotRemote bool
404 var gotClipboard ports.ClipboardReader405 var gotClipboard ports.ClipboardReader
405 clip := &fakeClipboardReader{}406 clip := &fakeClipboardReader{}
406- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{407+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
407- remoteDialer: func(target, session string) ports.Dialer {408+ remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
408 gotTarget, gotSession = target, session409 gotTarget, gotSession = target, session
409 return namedDialer{name: "remote"}410 return namedDialer{name: "remote"}
410 },411 },
411 clipboard: clip,412 clipboard: clip,
412- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {413+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
413 gotDialer = d.(namedDialer).name414 gotDialer = d.(namedDialer).name
414 gotRemote = remote415 gotRemote = remote
415 gotClipboard = clipboard416 gotClipboard = clipboard
@@ -437,10 +438,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
437 var gotDialer string438 var gotDialer string
438 gotRemote := true439 gotRemote := true
439 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})440 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
440- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{441+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
441 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },442 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
442 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach443 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
443- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) error {444+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
444 gotDialer = d.(namedDialer).name445 gotDialer = d.(namedDialer).name
445 gotRemote = remote446 gotRemote = remote
446 gotClipboard = clipboard447 gotClipboard = clipboard
M internal/app/spawn.go +10 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "log/slog"
78 "os"
89 "os/exec"
910 "path/filepath"
@@ -57,6 +58,7 @@ var defaultBackoff = backoffConfig{
5758 // backoff until the socket is live or the budget expires.
5859 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
5960 if t, err := dial(dir); err == nil {
61+ slog.Debug("daemon already reachable", "socket_dir", dir)
6062 return t, nil
6163 }
6264
@@ -69,9 +71,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
6971 // dialable (or spawn fails) so late-arriving clients keep waiting
7072 // rather than spawning a second daemon.
7173 defer release()
74+ slog.Info("spawning daemon", "socket_dir", dir)
7275 if err := spawn(); err != nil {
76+ slog.Error("daemon spawn failed", "err", err)
7377 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
7478 }
79+ } else {
80+ slog.Debug("waiting for daemon spawned by another process", "socket_dir", dir)
7581 }
7682
7783 return retryDial(ctx, dir, dial, cfg)
@@ -95,6 +101,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
95101
96102 // Lock exists: take it over only if it looks abandoned.
97103 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {
104+ age := time.Since(info.ModTime())
105+ slog.Warn("taking over stale daemon spawn lock", "path", path, "age", age)
98106 _ = os.Remove(path)
99107 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
100108 return func() { _ = os.Remove(path) }, true, nil
@@ -115,6 +123,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
115123 return t, nil
116124 }
117125 if !time.Now().Before(deadline) {
126+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
118127 return nil, ErrDaemonUnreachable
119128 }
120129 select {
@@ -153,6 +162,7 @@ func realSpawn() error {
153162 if err := cmd.Start(); err != nil {
154163 return err
155164 }
165+ slog.Info("daemon process started", "pid", cmd.Process.Pid)
156166 // Detach: we neither wait for nor signal the daemon after launch.
157167 return cmd.Process.Release()
158168 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "log/slog"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "path/filepath"10 "path/filepath"
@@ -57,6 +58,7 @@ var defaultBackoff = backoffConfig{
57 // backoff until the socket is live or the budget expires.58 // backoff until the socket is live or the budget expires.
58 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {59 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
59 if t, err := dial(dir); err == nil {60 if t, err := dial(dir); err == nil {
61+ slog.Debug("daemon already reachable", "socket_dir", dir)
60 return t, nil62 return t, nil
61 }63 }
62 64
@@ -69,9 +71,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
69 // dialable (or spawn fails) so late-arriving clients keep waiting71 // dialable (or spawn fails) so late-arriving clients keep waiting
70 // rather than spawning a second daemon.72 // rather than spawning a second daemon.
71 defer release()73 defer release()
74+ slog.Info("spawning daemon", "socket_dir", dir)
72 if err := spawn(); err != nil {75 if err := spawn(); err != nil {
76+ slog.Error("daemon spawn failed", "err", err)
73 return nil, fmt.Errorf("vev: spawning daemon: %w", err)77 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
74 }78 }
79+ } else {
80+ slog.Debug("waiting for daemon spawned by another process", "socket_dir", dir)
75 }81 }
76 82
77 return retryDial(ctx, dir, dial, cfg)83 return retryDial(ctx, dir, dial, cfg)
@@ -95,6 +101,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
95 101
96 // Lock exists: take it over only if it looks abandoned.102 // Lock exists: take it over only if it looks abandoned.
97 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {103 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {
104+ age := time.Since(info.ModTime())
105+ slog.Warn("taking over stale daemon spawn lock", "path", path, "age", age)
98 _ = os.Remove(path)106 _ = os.Remove(path)
99 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {107 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
100 return func() { _ = os.Remove(path) }, true, nil108 return func() { _ = os.Remove(path) }, true, nil
@@ -115,6 +123,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
115 return t, nil123 return t, nil
116 }124 }
117 if !time.Now().Before(deadline) {125 if !time.Now().Before(deadline) {
126+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
118 return nil, ErrDaemonUnreachable127 return nil, ErrDaemonUnreachable
119 }128 }
120 select {129 select {
@@ -153,6 +162,7 @@ func realSpawn() error {
153 if err := cmd.Start(); err != nil {162 if err := cmd.Start(); err != nil {
154 return err163 return err
155 }164 }
165+ slog.Info("daemon process started", "pid", cmd.Process.Pid)
156 // Detach: we neither wait for nor signal the daemon after launch.166 // Detach: we neither wait for nor signal the daemon after launch.
157 return cmd.Process.Release()167 return cmd.Process.Release()
158 }168 }
A internal/logging/logging.go +97 -0
@@ -0,0 +1,97 @@
1+package logging
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "runtime/debug"
9+ "strings"
10+ "sync"
11+)
12+
13+type Component string
14+
15+const (
16+ Daemon Component = "daemon"
17+ Client Component = "client"
18+ Stdio Component = "stdio"
19+)
20+
21+const DefaultMaxBytes = 20 << 20
22+
23+type Config struct {
24+ Dir string
25+ Component Component
26+ Level slog.Level
27+ MaxBytes int64
28+ RotateAtRuntime bool
29+}
30+
31+func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
32+ if err := os.MkdirAll(cfg.Dir, 0o700); err != nil {
33+ return nil, nil, err
34+ }
35+
36+ component := cfg.Component
37+ logPath := filepath.Join(cfg.Dir, "vev-"+string(component)+".log")
38+ writer, err := newRotatingWriter(logPath, cfg.MaxBytes, cfg.RotateAtRuntime)
39+ if err != nil {
40+ return nil, nil, err
41+ }
42+
43+ crashPath := filepath.Join(cfg.Dir, "vev-"+string(component)+"-crash.log")
44+ crashFile, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
45+ if err != nil {
46+ _ = writer.Close()
47+ return nil, nil, err
48+ }
49+ if err := debug.SetCrashOutput(crashFile, debug.CrashOptions{}); err != nil {
50+ _ = crashFile.Close()
51+ _ = writer.Close()
52+ return nil, nil, err
53+ }
54+
55+ logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: cfg.Level})).With("component", string(component))
56+ slog.SetDefault(logger)
57+
58+ return logger, &setupCloser{log: writer, crash: crashFile}, nil
59+}
60+
61+func ParseLevel(s string) slog.Level {
62+ switch strings.ToLower(strings.TrimSpace(s)) {
63+ case "debug":
64+ return slog.LevelDebug
65+ case "warn", "warning":
66+ return slog.LevelWarn
67+ case "error":
68+ return slog.LevelError
69+ default:
70+ return slog.LevelInfo
71+ }
72+}
73+
74+func EnvLevel() slog.Level {
75+ return ParseLevel(os.Getenv("VEV_LOG"))
76+}
77+
78+type setupCloser struct {
79+ once sync.Once
80+ log io.Closer
81+ crash io.Closer
82+ err error
83+}
84+
85+func (c *setupCloser) Close() error {
86+ c.once.Do(func() {
87+ if c.log != nil {
88+ c.err = c.log.Close()
89+ }
90+ if c.crash != nil {
91+ if err := c.crash.Close(); c.err == nil {
92+ c.err = err
93+ }
94+ }
95+ })
96+ return c.err
97+}
@@ -0,0 +1,97 @@
1+package logging
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "runtime/debug"
9+ "strings"
10+ "sync"
11+)
12+
13+type Component string
14+
15+const (
16+ Daemon Component = "daemon"
17+ Client Component = "client"
18+ Stdio Component = "stdio"
19+)
20+
21+const DefaultMaxBytes = 20 << 20
22+
23+type Config struct {
24+ Dir string
25+ Component Component
26+ Level slog.Level
27+ MaxBytes int64
28+ RotateAtRuntime bool
29+}
30+
31+func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
32+ if err := os.MkdirAll(cfg.Dir, 0o700); err != nil {
33+ return nil, nil, err
34+ }
35+
36+ component := cfg.Component
37+ logPath := filepath.Join(cfg.Dir, "vev-"+string(component)+".log")
38+ writer, err := newRotatingWriter(logPath, cfg.MaxBytes, cfg.RotateAtRuntime)
39+ if err != nil {
40+ return nil, nil, err
41+ }
42+
43+ crashPath := filepath.Join(cfg.Dir, "vev-"+string(component)+"-crash.log")
44+ crashFile, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
45+ if err != nil {
46+ _ = writer.Close()
47+ return nil, nil, err
48+ }
49+ if err := debug.SetCrashOutput(crashFile, debug.CrashOptions{}); err != nil {
50+ _ = crashFile.Close()
51+ _ = writer.Close()
52+ return nil, nil, err
53+ }
54+
55+ logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: cfg.Level})).With("component", string(component))
56+ slog.SetDefault(logger)
57+
58+ return logger, &setupCloser{log: writer, crash: crashFile}, nil
59+}
60+
61+func ParseLevel(s string) slog.Level {
62+ switch strings.ToLower(strings.TrimSpace(s)) {
63+ case "debug":
64+ return slog.LevelDebug
65+ case "warn", "warning":
66+ return slog.LevelWarn
67+ case "error":
68+ return slog.LevelError
69+ default:
70+ return slog.LevelInfo
71+ }
72+}
73+
74+func EnvLevel() slog.Level {
75+ return ParseLevel(os.Getenv("VEV_LOG"))
76+}
77+
78+type setupCloser struct {
79+ once sync.Once
80+ log io.Closer
81+ crash io.Closer
82+ err error
83+}
84+
85+func (c *setupCloser) Close() error {
86+ c.once.Do(func() {
87+ if c.log != nil {
88+ c.err = c.log.Close()
89+ }
90+ if c.crash != nil {
91+ if err := c.crash.Close(); c.err == nil {
92+ c.err = err
93+ }
94+ }
95+ })
96+ return c.err
97+}
A internal/logging/logging_test.go +259 -0
@@ -0,0 +1,259 @@
1+package logging
2+
3+import (
4+ "encoding/json"
5+ "log/slog"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func TestParseLevelAndEnvLevel(t *testing.T) {
15+ tests := []struct {
16+ in string
17+ want slog.Level
18+ }{
19+ {"debug", slog.LevelDebug},
20+ {" DEBUG ", slog.LevelDebug},
21+ {"info", slog.LevelInfo},
22+ {"", slog.LevelInfo},
23+ {"warn", slog.LevelWarn},
24+ {"warning", slog.LevelWarn},
25+ {"error", slog.LevelError},
26+ {"bogus", slog.LevelInfo},
27+ }
28+ for _, tt := range tests {
29+ if got := ParseLevel(tt.in); got != tt.want {
30+ t.Fatalf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want)
31+ }
32+ }
33+
34+ t.Setenv("VEV_LOG", "debug")
35+ if got := EnvLevel(); got != slog.LevelDebug {
36+ t.Fatalf("EnvLevel() = %v, want %v", got, slog.LevelDebug)
37+ }
38+}
39+
40+func TestSetupWritesJSONWithComponent(t *testing.T) {
41+ dir := t.TempDir()
42+ logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
43+ if err != nil {
44+ t.Fatalf("Setup() error = %v", err)
45+ }
46+ defer func() {
47+ if err := closer.Close(); err != nil {
48+ t.Errorf("Close() error = %v", err)
49+ }
50+ }()
51+
52+ logger.Info("hello", "answer", 42)
53+
54+ data, err := os.ReadFile(filepath.Join(dir, "vev-client.log"))
55+ if err != nil {
56+ t.Fatalf("ReadFile() error = %v", err)
57+ }
58+ var entry map[string]any
59+ if err := json.Unmarshal(data, &entry); err != nil {
60+ t.Fatalf("log entry is not JSON: %v; data=%q", err, data)
61+ }
62+ if entry["component"] != "client" {
63+ t.Fatalf("component = %v, want client; entry=%v", entry["component"], entry)
64+ }
65+ if entry["msg"] != "hello" || entry["answer"] != float64(42) {
66+ t.Fatalf("unexpected entry fields: %v", entry)
67+ }
68+ if _, err := os.Stat(filepath.Join(dir, "vev-client-crash.log")); err != nil {
69+ t.Fatalf("crash log not created: %v", err)
70+ }
71+}
72+
73+func TestRuntimeRotation(t *testing.T) {
74+ dir := t.TempDir()
75+ path := filepath.Join(dir, "vev-daemon.log")
76+ w, err := newRotatingWriter(path, 10, true)
77+ if err != nil {
78+ t.Fatalf("newRotatingWriter() error = %v", err)
79+ }
80+ defer func() {
81+ if err := w.Close(); err != nil {
82+ t.Errorf("Close() error = %v", err)
83+ }
84+ }()
85+
86+ if _, err := w.Write([]byte("0123456789")); err != nil {
87+ t.Fatalf("Write() error = %v", err)
88+ }
89+ if _, err := os.Stat(path + ".old"); err != nil {
90+ t.Fatalf("rotated file missing: %v", err)
91+ }
92+ live, err := os.ReadFile(path)
93+ if err != nil {
94+ t.Fatalf("read live file: %v", err)
95+ }
96+ if len(live) != 0 {
97+ t.Fatalf("live file length = %d, want 0 after rotation", len(live))
98+ }
99+ old, err := os.ReadFile(path + ".old")
100+ if err != nil {
101+ t.Fatalf("read old file: %v", err)
102+ }
103+ if string(old) != "0123456789" {
104+ t.Fatalf("old file = %q, want original write", old)
105+ }
106+}
107+
108+func TestStartupOnlyRotation(t *testing.T) {
109+ dir := t.TempDir()
110+ path := filepath.Join(dir, "vev-client.log")
111+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
112+ t.Fatalf("seed log: %v", err)
113+ }
114+ w, err := newRotatingWriter(path, 5, false)
115+ if err != nil {
116+ t.Fatalf("newRotatingWriter() error = %v", err)
117+ }
118+ defer func() {
119+ if err := w.Close(); err != nil {
120+ t.Errorf("Close() error = %v", err)
121+ }
122+ }()
123+
124+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
125+ t.Fatalf("startup rotation old = %q, err = %v", old, err)
126+ }
127+ if _, err := w.Write([]byte("first")); err != nil {
128+ t.Fatalf("Write(first) error = %v", err)
129+ }
130+ if _, err := w.Write([]byte("second")); err != nil {
131+ t.Fatalf("Write(second) error = %v", err)
132+ }
133+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
134+ t.Fatalf("startup-only mode rotated during writes; old = %q, err = %v", old, err)
135+ }
136+ live, err := os.ReadFile(path)
137+ if err != nil {
138+ t.Fatalf("read live file: %v", err)
139+ }
140+ if string(live) != "firstsecond" {
141+ t.Fatalf("live file = %q, want appended writes", live)
142+ }
143+}
144+
145+func TestCrashOutputCapturesPanicStack(t *testing.T) {
146+ dir := t.TempDir()
147+ cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
148+ cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
149+ if err := cmd.Run(); err == nil {
150+ t.Fatal("crash helper exited successfully, want panic failure")
151+ }
152+
153+ data, err := os.ReadFile(filepath.Join(dir, "vev-daemon-crash.log"))
154+ if err != nil {
155+ t.Fatalf("read crash log: %v", err)
156+ }
157+ if !strings.Contains(string(data), "intentional crash for SetCrashOutput test") || !strings.Contains(string(data), "goroutine") {
158+ t.Fatalf("crash log did not contain panic stack; data=%q", data)
159+ }
160+}
161+
162+func TestCrashOutputHelper(t *testing.T) {
163+ if os.Getenv("VEV_CRASH_HELPER") != "1" {
164+ return
165+ }
166+ if _, _, err := Setup(Config{Dir: os.Getenv("VEV_CRASH_DIR"), Component: Daemon, Level: slog.LevelInfo}); err != nil {
167+ panic(err)
168+ }
169+ panic("intentional crash for SetCrashOutput test")
170+}
171+
172+func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
173+ dir := t.TempDir()
174+ path := filepath.Join(dir, "vev-client.log")
175+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
176+ t.Fatalf("seed log: %v", err)
177+ }
178+
179+ var wg sync.WaitGroup
180+ errCh := make(chan error, 8)
181+ for i := 0; i < cap(errCh); i++ {
182+ wg.Add(1)
183+ go func() {
184+ defer wg.Done()
185+ w, err := newRotatingWriter(path, 5, false)
186+ if err != nil {
187+ errCh <- err
188+ return
189+ }
190+ if _, err := w.Write([]byte("x")); err != nil {
191+ errCh <- err
192+ }
193+ if err := w.Close(); err != nil {
194+ errCh <- err
195+ }
196+ }()
197+ }
198+ wg.Wait()
199+ close(errCh)
200+ for err := range errCh {
201+ if err != nil {
202+ t.Fatalf("concurrent startup writer failed: %v", err)
203+ }
204+ }
205+ if _, err := os.Stat(path); err != nil {
206+ t.Fatalf("live log missing after concurrent startup rotation: %v", err)
207+ }
208+ if _, err := os.Stat(path + ".old"); err != nil {
209+ t.Fatalf("rotated log missing after concurrent startup rotation: %v", err)
210+ }
211+}
212+
213+func TestDoubleClose(t *testing.T) {
214+ _, closer, err := Setup(Config{Dir: t.TempDir(), Component: Stdio, Level: slog.LevelInfo})
215+ if err != nil {
216+ t.Fatalf("Setup() error = %v", err)
217+ }
218+ if err := closer.Close(); err != nil {
219+ t.Fatalf("first Close() error = %v", err)
220+ }
221+ if err := closer.Close(); err != nil {
222+ t.Fatalf("second Close() error = %v", err)
223+ }
224+}
225+
226+func TestRotationRenameFailureIsNonFatal(t *testing.T) {
227+ dir := t.TempDir()
228+ path := filepath.Join(dir, "vev-daemon.log")
229+ if err := os.WriteFile(path, nil, 0o600); err != nil {
230+ t.Fatalf("seed log: %v", err)
231+ }
232+ if err := os.Mkdir(path+".old", 0o700); err != nil {
233+ t.Fatalf("make rename obstacle: %v", err)
234+ }
235+ if err := os.WriteFile(filepath.Join(path+".old", "child"), []byte("x"), 0o600); err != nil {
236+ t.Fatalf("make non-empty obstacle: %v", err)
237+ }
238+
239+ w, err := newRotatingWriter(path, 4, true)
240+ if err != nil {
241+ t.Fatalf("newRotatingWriter() error = %v", err)
242+ }
243+ defer func() {
244+ if err := w.Close(); err != nil {
245+ t.Errorf("Close() error = %v", err)
246+ }
247+ }()
248+
249+ if _, err := w.Write([]byte("exceeds")); err != nil {
250+ t.Fatalf("Write() error = %v", err)
251+ }
252+ live, err := os.ReadFile(path)
253+ if err != nil {
254+ t.Fatalf("read live file: %v", err)
255+ }
256+ if !strings.Contains(string(live), "exceeds") {
257+ t.Fatalf("write was not preserved after failed rename: %q", live)
258+ }
259+}
@@ -0,0 +1,259 @@
1+package logging
2+
3+import (
4+ "encoding/json"
5+ "log/slog"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func TestParseLevelAndEnvLevel(t *testing.T) {
15+ tests := []struct {
16+ in string
17+ want slog.Level
18+ }{
19+ {"debug", slog.LevelDebug},
20+ {" DEBUG ", slog.LevelDebug},
21+ {"info", slog.LevelInfo},
22+ {"", slog.LevelInfo},
23+ {"warn", slog.LevelWarn},
24+ {"warning", slog.LevelWarn},
25+ {"error", slog.LevelError},
26+ {"bogus", slog.LevelInfo},
27+ }
28+ for _, tt := range tests {
29+ if got := ParseLevel(tt.in); got != tt.want {
30+ t.Fatalf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want)
31+ }
32+ }
33+
34+ t.Setenv("VEV_LOG", "debug")
35+ if got := EnvLevel(); got != slog.LevelDebug {
36+ t.Fatalf("EnvLevel() = %v, want %v", got, slog.LevelDebug)
37+ }
38+}
39+
40+func TestSetupWritesJSONWithComponent(t *testing.T) {
41+ dir := t.TempDir()
42+ logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
43+ if err != nil {
44+ t.Fatalf("Setup() error = %v", err)
45+ }
46+ defer func() {
47+ if err := closer.Close(); err != nil {
48+ t.Errorf("Close() error = %v", err)
49+ }
50+ }()
51+
52+ logger.Info("hello", "answer", 42)
53+
54+ data, err := os.ReadFile(filepath.Join(dir, "vev-client.log"))
55+ if err != nil {
56+ t.Fatalf("ReadFile() error = %v", err)
57+ }
58+ var entry map[string]any
59+ if err := json.Unmarshal(data, &entry); err != nil {
60+ t.Fatalf("log entry is not JSON: %v; data=%q", err, data)
61+ }
62+ if entry["component"] != "client" {
63+ t.Fatalf("component = %v, want client; entry=%v", entry["component"], entry)
64+ }
65+ if entry["msg"] != "hello" || entry["answer"] != float64(42) {
66+ t.Fatalf("unexpected entry fields: %v", entry)
67+ }
68+ if _, err := os.Stat(filepath.Join(dir, "vev-client-crash.log")); err != nil {
69+ t.Fatalf("crash log not created: %v", err)
70+ }
71+}
72+
73+func TestRuntimeRotation(t *testing.T) {
74+ dir := t.TempDir()
75+ path := filepath.Join(dir, "vev-daemon.log")
76+ w, err := newRotatingWriter(path, 10, true)
77+ if err != nil {
78+ t.Fatalf("newRotatingWriter() error = %v", err)
79+ }
80+ defer func() {
81+ if err := w.Close(); err != nil {
82+ t.Errorf("Close() error = %v", err)
83+ }
84+ }()
85+
86+ if _, err := w.Write([]byte("0123456789")); err != nil {
87+ t.Fatalf("Write() error = %v", err)
88+ }
89+ if _, err := os.Stat(path + ".old"); err != nil {
90+ t.Fatalf("rotated file missing: %v", err)
91+ }
92+ live, err := os.ReadFile(path)
93+ if err != nil {
94+ t.Fatalf("read live file: %v", err)
95+ }
96+ if len(live) != 0 {
97+ t.Fatalf("live file length = %d, want 0 after rotation", len(live))
98+ }
99+ old, err := os.ReadFile(path + ".old")
100+ if err != nil {
101+ t.Fatalf("read old file: %v", err)
102+ }
103+ if string(old) != "0123456789" {
104+ t.Fatalf("old file = %q, want original write", old)
105+ }
106+}
107+
108+func TestStartupOnlyRotation(t *testing.T) {
109+ dir := t.TempDir()
110+ path := filepath.Join(dir, "vev-client.log")
111+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
112+ t.Fatalf("seed log: %v", err)
113+ }
114+ w, err := newRotatingWriter(path, 5, false)
115+ if err != nil {
116+ t.Fatalf("newRotatingWriter() error = %v", err)
117+ }
118+ defer func() {
119+ if err := w.Close(); err != nil {
120+ t.Errorf("Close() error = %v", err)
121+ }
122+ }()
123+
124+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
125+ t.Fatalf("startup rotation old = %q, err = %v", old, err)
126+ }
127+ if _, err := w.Write([]byte("first")); err != nil {
128+ t.Fatalf("Write(first) error = %v", err)
129+ }
130+ if _, err := w.Write([]byte("second")); err != nil {
131+ t.Fatalf("Write(second) error = %v", err)
132+ }
133+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
134+ t.Fatalf("startup-only mode rotated during writes; old = %q, err = %v", old, err)
135+ }
136+ live, err := os.ReadFile(path)
137+ if err != nil {
138+ t.Fatalf("read live file: %v", err)
139+ }
140+ if string(live) != "firstsecond" {
141+ t.Fatalf("live file = %q, want appended writes", live)
142+ }
143+}
144+
145+func TestCrashOutputCapturesPanicStack(t *testing.T) {
146+ dir := t.TempDir()
147+ cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
148+ cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
149+ if err := cmd.Run(); err == nil {
150+ t.Fatal("crash helper exited successfully, want panic failure")
151+ }
152+
153+ data, err := os.ReadFile(filepath.Join(dir, "vev-daemon-crash.log"))
154+ if err != nil {
155+ t.Fatalf("read crash log: %v", err)
156+ }
157+ if !strings.Contains(string(data), "intentional crash for SetCrashOutput test") || !strings.Contains(string(data), "goroutine") {
158+ t.Fatalf("crash log did not contain panic stack; data=%q", data)
159+ }
160+}
161+
162+func TestCrashOutputHelper(t *testing.T) {
163+ if os.Getenv("VEV_CRASH_HELPER") != "1" {
164+ return
165+ }
166+ if _, _, err := Setup(Config{Dir: os.Getenv("VEV_CRASH_DIR"), Component: Daemon, Level: slog.LevelInfo}); err != nil {
167+ panic(err)
168+ }
169+ panic("intentional crash for SetCrashOutput test")
170+}
171+
172+func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
173+ dir := t.TempDir()
174+ path := filepath.Join(dir, "vev-client.log")
175+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
176+ t.Fatalf("seed log: %v", err)
177+ }
178+
179+ var wg sync.WaitGroup
180+ errCh := make(chan error, 8)
181+ for i := 0; i < cap(errCh); i++ {
182+ wg.Add(1)
183+ go func() {
184+ defer wg.Done()
185+ w, err := newRotatingWriter(path, 5, false)
186+ if err != nil {
187+ errCh <- err
188+ return
189+ }
190+ if _, err := w.Write([]byte("x")); err != nil {
191+ errCh <- err
192+ }
193+ if err := w.Close(); err != nil {
194+ errCh <- err
195+ }
196+ }()
197+ }
198+ wg.Wait()
199+ close(errCh)
200+ for err := range errCh {
201+ if err != nil {
202+ t.Fatalf("concurrent startup writer failed: %v", err)
203+ }
204+ }
205+ if _, err := os.Stat(path); err != nil {
206+ t.Fatalf("live log missing after concurrent startup rotation: %v", err)
207+ }
208+ if _, err := os.Stat(path + ".old"); err != nil {
209+ t.Fatalf("rotated log missing after concurrent startup rotation: %v", err)
210+ }
211+}
212+
213+func TestDoubleClose(t *testing.T) {
214+ _, closer, err := Setup(Config{Dir: t.TempDir(), Component: Stdio, Level: slog.LevelInfo})
215+ if err != nil {
216+ t.Fatalf("Setup() error = %v", err)
217+ }
218+ if err := closer.Close(); err != nil {
219+ t.Fatalf("first Close() error = %v", err)
220+ }
221+ if err := closer.Close(); err != nil {
222+ t.Fatalf("second Close() error = %v", err)
223+ }
224+}
225+
226+func TestRotationRenameFailureIsNonFatal(t *testing.T) {
227+ dir := t.TempDir()
228+ path := filepath.Join(dir, "vev-daemon.log")
229+ if err := os.WriteFile(path, nil, 0o600); err != nil {
230+ t.Fatalf("seed log: %v", err)
231+ }
232+ if err := os.Mkdir(path+".old", 0o700); err != nil {
233+ t.Fatalf("make rename obstacle: %v", err)
234+ }
235+ if err := os.WriteFile(filepath.Join(path+".old", "child"), []byte("x"), 0o600); err != nil {
236+ t.Fatalf("make non-empty obstacle: %v", err)
237+ }
238+
239+ w, err := newRotatingWriter(path, 4, true)
240+ if err != nil {
241+ t.Fatalf("newRotatingWriter() error = %v", err)
242+ }
243+ defer func() {
244+ if err := w.Close(); err != nil {
245+ t.Errorf("Close() error = %v", err)
246+ }
247+ }()
248+
249+ if _, err := w.Write([]byte("exceeds")); err != nil {
250+ t.Fatalf("Write() error = %v", err)
251+ }
252+ live, err := os.ReadFile(path)
253+ if err != nil {
254+ t.Fatalf("read live file: %v", err)
255+ }
256+ if !strings.Contains(string(live), "exceeds") {
257+ t.Fatalf("write was not preserved after failed rename: %q", live)
258+ }
259+}
A internal/logging/rotate.go +82 -0
@@ -0,0 +1,82 @@
1+package logging
2+
3+import (
4+ "os"
5+ "sync"
6+)
7+
8+type rotatingWriter struct {
9+ mu sync.Mutex
10+ path string
11+ max int64
12+ runtime bool
13+ file *os.File
14+ size int64
15+ closed bool
16+}
17+
18+func newRotatingWriter(path string, maxBytes int64, rotateAtRuntime bool) (*rotatingWriter, error) {
19+ if maxBytes <= 0 {
20+ maxBytes = DefaultMaxBytes
21+ }
22+ rw := &rotatingWriter{path: path, max: maxBytes, runtime: rotateAtRuntime}
23+ if err := rw.open(); err != nil {
24+ return nil, err
25+ }
26+ if !rotateAtRuntime && rw.size >= rw.max {
27+ rw.rotateLocked()
28+ }
29+ return rw, nil
30+}
31+
32+func (w *rotatingWriter) Write(p []byte) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+
36+ if w.closed {
37+ return 0, os.ErrClosed
38+ }
39+ n, err := w.file.Write(p)
40+ w.size += int64(n)
41+ if err == nil && w.runtime && w.size >= w.max {
42+ w.rotateLocked()
43+ }
44+ return n, err
45+}
46+
47+func (w *rotatingWriter) Close() error {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+
51+ if w.closed {
52+ return nil
53+ }
54+ w.closed = true
55+ return w.file.Close()
56+}
57+
58+func (w *rotatingWriter) open() error {
59+ f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
60+ if err != nil {
61+ return err
62+ }
63+ info, err := f.Stat()
64+ if err != nil {
65+ _ = f.Close()
66+ return err
67+ }
68+ w.file = f
69+ w.size = info.Size()
70+ return nil
71+}
72+
73+func (w *rotatingWriter) rotateLocked() {
74+ old := w.file
75+ _ = os.Rename(w.path, w.path+".old")
76+ if err := w.open(); err != nil {
77+ w.file = old
78+ w.size = w.max
79+ return
80+ }
81+ _ = old.Close()
82+}
@@ -0,0 +1,82 @@
1+package logging
2+
3+import (
4+ "os"
5+ "sync"
6+)
7+
8+type rotatingWriter struct {
9+ mu sync.Mutex
10+ path string
11+ max int64
12+ runtime bool
13+ file *os.File
14+ size int64
15+ closed bool
16+}
17+
18+func newRotatingWriter(path string, maxBytes int64, rotateAtRuntime bool) (*rotatingWriter, error) {
19+ if maxBytes <= 0 {
20+ maxBytes = DefaultMaxBytes
21+ }
22+ rw := &rotatingWriter{path: path, max: maxBytes, runtime: rotateAtRuntime}
23+ if err := rw.open(); err != nil {
24+ return nil, err
25+ }
26+ if !rotateAtRuntime && rw.size >= rw.max {
27+ rw.rotateLocked()
28+ }
29+ return rw, nil
30+}
31+
32+func (w *rotatingWriter) Write(p []byte) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+
36+ if w.closed {
37+ return 0, os.ErrClosed
38+ }
39+ n, err := w.file.Write(p)
40+ w.size += int64(n)
41+ if err == nil && w.runtime && w.size >= w.max {
42+ w.rotateLocked()
43+ }
44+ return n, err
45+}
46+
47+func (w *rotatingWriter) Close() error {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+
51+ if w.closed {
52+ return nil
53+ }
54+ w.closed = true
55+ return w.file.Close()
56+}
57+
58+func (w *rotatingWriter) open() error {
59+ f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
60+ if err != nil {
61+ return err
62+ }
63+ info, err := f.Stat()
64+ if err != nil {
65+ _ = f.Close()
66+ return err
67+ }
68+ w.file = f
69+ w.size = info.Size()
70+ return nil
71+}
72+
73+func (w *rotatingWriter) rotateLocked() {
74+ old := w.file
75+ _ = os.Rename(w.path, w.path+".old")
76+ if err := w.open(); err != nil {
77+ w.file = old
78+ w.size = w.max
79+ return
80+ }
81+ _ = old.Close()
82+}
A internal/ports/paste.go +9 -0
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
M internal/usecase/client/client.go +32 -15
@@ -126,8 +126,10 @@ const (
126126 // ClipboardReader configured (clipboard non-nil) — local attaches forward
127127 // 0x16 untouched so the locally running agent's own clipboard handling
128128 // applies.
129-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) {
130- log := slog.Default().With("component", "client")
129+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) {
130+ if log == nil {
131+ log = slog.Default()
132+ }
131133 ms := milestones{}
132134
133135 defer func() {
@@ -186,6 +188,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
186188 if resumeToken == 0 || ctx.Err() != nil {
187189 return err
188190 }
191+ log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
189192 drawStatus()
190193 if !reconnectSleep(ctx, backoff) {
191194 return ctx.Err()
@@ -196,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
196199 }
197200 ms.dialed = true
198201
199- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard)
202+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
200203 if result.welcomed {
201204 backoff = defaultReconnectBackoff.initial
202205 }
@@ -212,6 +215,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
212215 clearStatus()
213216 return result.err
214217 }
218+ log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
215219 drawStatus()
216220 if !reconnectSleep(ctx, backoff) {
217221 clearStatus()
@@ -258,7 +262,7 @@ func shouldReconnect(err error) bool {
258262 //
259263 // It is kept as a compatibility wrapper for callers that still own dialing.
260264 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
261- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil)
265+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
262266 }
263267
264268 type singleTransportDialer struct{ transport ports.Transport }
@@ -273,7 +277,7 @@ type attachResult struct {
273277 err error
274278 }
275279
276-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult {
280+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
277281 // 1. Handshake: send Hello with our size and TERM.
278282 size, err := term.Size()
279283 if err != nil {
@@ -313,6 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
313317 }
314318 resumeToken = welcome.ResumeToken
315319 ms.welcomed = true
320+ log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
316321 case ports.MsgError:
317322 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
318323 if derr != nil {
@@ -342,13 +347,13 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
342347 if remote {
343348 clip = clipboard
344349 }
345- go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
346- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip)
347- go runResize(loopCtx, term.ResizeEvents(), sendCh)
350+ go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
352+ go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
348353
349354 // 5. Output/main loop: the only goroutine that touches the terminal.
350355 recvCh := make(chan recvResult, 1)
351- go runRecv(loopCtx, transport, recvCh)
356+ go runRecv(loopCtx, transport, recvCh, log)
352357
353358 for {
354359 select {
@@ -380,13 +385,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
380385 if ferr := term.Flush(); ferr != nil {
381386 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
382387 }
383- ms.firstOutput = true
388+ if !ms.firstOutput {
389+ ms.firstOutput = true
390+ log.Debug("received first output")
391+ }
384392 case ports.MsgDetached:
385393 d, derr := ports.UnmarshalDetached(r.frame.Payload)
386394 if derr != nil {
387395 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
388396 }
389397 ms.detached = true
398+ if d.Reason == ports.ReasonDetach {
399+ log.Info("detached cleanly", "reason", d.Reason)
400+ } else {
401+ log.Warn("detached by daemon", "reason", d.Reason)
402+ }
390403 return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
391404 case ports.MsgPong:
392405 // Liveness reply; nothing to do.
@@ -407,7 +420,8 @@ type recvResult struct {
407420
408421 // runRecv reads frames until an error, forwarding each to the main loop.
409422 // It exits on the first error or when the loop context is cancelled.
410-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) {
423+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
424+ defer log.Debug("receive pump exited")
411425 for {
412426 f, err := transport.Recv()
413427 select {
@@ -424,7 +438,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
424438 // runSender is the sole caller of transport.Send: serialising the input and
425439 // resize pumps through one goroutine keeps the transport's single-writer
426440 // contract intact. A send failure cancels the loop and is surfaced once.
427-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) {
441+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) {
442+ defer log.Debug("sender pump exited")
428443 for {
429444 select {
430445 case f := <-in:
@@ -457,7 +472,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
457472 // exits. That is harmless here — Attach has already returned and restored
458473 // the terminal — and matches the standard pattern for stdin pumps; a
459474 // closable stdin duplicate could lift it later if ever needed.
460-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) {
475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {
476+ defer log.Debug("stdin pump exited")
461477 buf := make([]byte, stdinBufSize)
462478 var scanner theme.Scanner
463479 var inputSeq uint64
@@ -493,7 +509,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
493509 ci := &clipboardIntercept{
494510 coalescer: coalescer,
495511 reader: clipboard,
496- log: slog.Default(),
512+ log: log,
497513 sendImage: func(mime string, data []byte) {
498514 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})
499515 },
@@ -537,7 +553,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
537553 // runResize forwards coalesced terminal resize events to the daemon. It
538554 // tolerates an already-closed resize channel (which the terminal adapter
539555 // hands back when restore ran before ResizeEvents was first called).
540-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) {
556+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
557+ defer log.Debug("resize pump exited")
541558 for {
542559 select {
543560 case sz, ok := <-events:
@@ -126,8 +126,10 @@ const (
126 // ClipboardReader configured (clipboard non-nil) — local attaches forward126 // ClipboardReader configured (clipboard non-nil) — local attaches forward
127 // 0x16 untouched so the locally running agent's own clipboard handling127 // 0x16 untouched so the locally running agent's own clipboard handling
128 // applies.128 // applies.
129-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader) (retErr error) {129+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) {
130- log := slog.Default().With("component", "client")130+ if log == nil {
131+ log = slog.Default()
132+ }
131 ms := milestones{}133 ms := milestones{}
132 134
133 defer func() {135 defer func() {
@@ -186,6 +188,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
186 if resumeToken == 0 || ctx.Err() != nil {188 if resumeToken == 0 || ctx.Err() != nil {
187 return err189 return err
188 }190 }
191+ log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
189 drawStatus()192 drawStatus()
190 if !reconnectSleep(ctx, backoff) {193 if !reconnectSleep(ctx, backoff) {
191 return ctx.Err()194 return ctx.Err()
@@ -196,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
196 }199 }
197 ms.dialed = true200 ms.dialed = true
198 201
199- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard)202+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
200 if result.welcomed {203 if result.welcomed {
201 backoff = defaultReconnectBackoff.initial204 backoff = defaultReconnectBackoff.initial
202 }205 }
@@ -212,6 +215,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
212 clearStatus()215 clearStatus()
213 return result.err216 return result.err
214 }217 }
218+ log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
215 drawStatus()219 drawStatus()
216 if !reconnectSleep(ctx, backoff) {220 if !reconnectSleep(ctx, backoff) {
217 clearStatus()221 clearStatus()
@@ -258,7 +262,7 @@ func shouldReconnect(err error) bool {
258 //262 //
259 // It is kept as a compatibility wrapper for callers that still own dialing.263 // It is kept as a compatibility wrapper for callers that still own dialing.
260 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {264 func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
261- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil)265+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
262 }266 }
263 267
264 type singleTransportDialer struct{ transport ports.Transport }268 type singleTransportDialer struct{ transport ports.Transport }
@@ -273,7 +277,7 @@ type attachResult struct {
273 err error277 err error
274 }278 }
275 279
276-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader) attachResult {280+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
277 // 1. Handshake: send Hello with our size and TERM.281 // 1. Handshake: send Hello with our size and TERM.
278 size, err := term.Size()282 size, err := term.Size()
279 if err != nil {283 if err != nil {
@@ -313,6 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
313 }317 }
314 resumeToken = welcome.ResumeToken318 resumeToken = welcome.ResumeToken
315 ms.welcomed = true319 ms.welcomed = true
320+ log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
316 case ports.MsgError:321 case ports.MsgError:
317 em, derr := ports.UnmarshalErrorMsg(reply.Payload)322 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
318 if derr != nil {323 if derr != nil {
@@ -342,13 +347,13 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
342 if remote {347 if remote {
343 clip = clipboard348 clip = clipboard
344 }349 }
345- go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)350+ go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
346- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip)351+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, clip, log)
347- go runResize(loopCtx, term.ResizeEvents(), sendCh)352+ go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
348 353
349 // 5. Output/main loop: the only goroutine that touches the terminal.354 // 5. Output/main loop: the only goroutine that touches the terminal.
350 recvCh := make(chan recvResult, 1)355 recvCh := make(chan recvResult, 1)
351- go runRecv(loopCtx, transport, recvCh)356+ go runRecv(loopCtx, transport, recvCh, log)
352 357
353 for {358 for {
354 select {359 select {
@@ -380,13 +385,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
380 if ferr := term.Flush(); ferr != nil {385 if ferr := term.Flush(); ferr != nil {
381 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}386 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
382 }387 }
383- ms.firstOutput = true388+ if !ms.firstOutput {
389+ ms.firstOutput = true
390+ log.Debug("received first output")
391+ }
384 case ports.MsgDetached:392 case ports.MsgDetached:
385 d, derr := ports.UnmarshalDetached(r.frame.Payload)393 d, derr := ports.UnmarshalDetached(r.frame.Payload)
386 if derr != nil {394 if derr != nil {
387 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}395 return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
388 }396 }
389 ms.detached = true397 ms.detached = true
398+ if d.Reason == ports.ReasonDetach {
399+ log.Info("detached cleanly", "reason", d.Reason)
400+ } else {
401+ log.Warn("detached by daemon", "reason", d.Reason)
402+ }
390 return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}403 return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
391 case ports.MsgPong:404 case ports.MsgPong:
392 // Liveness reply; nothing to do.405 // Liveness reply; nothing to do.
@@ -407,7 +420,8 @@ type recvResult struct {
407 420
408 // runRecv reads frames until an error, forwarding each to the main loop.421 // runRecv reads frames until an error, forwarding each to the main loop.
409 // It exits on the first error or when the loop context is cancelled.422 // It exits on the first error or when the loop context is cancelled.
410-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) {423+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
424+ defer log.Debug("receive pump exited")
411 for {425 for {
412 f, err := transport.Recv()426 f, err := transport.Recv()
413 select {427 select {
@@ -424,7 +438,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
424 // runSender is the sole caller of transport.Send: serialising the input and438 // runSender is the sole caller of transport.Send: serialising the input and
425 // resize pumps through one goroutine keeps the transport's single-writer439 // resize pumps through one goroutine keeps the transport's single-writer
426 // contract intact. A send failure cancels the loop and is surfaced once.440 // contract intact. A send failure cancels the loop and is surfaced once.
427-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) {441+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) {
442+ defer log.Debug("sender pump exited")
428 for {443 for {
429 select {444 select {
430 case f := <-in:445 case f := <-in:
@@ -457,7 +472,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
457 // exits. That is harmless here — Attach has already returned and restored472 // exits. That is harmless here — Attach has already returned and restored
458 // the terminal — and matches the standard pattern for stdin pumps; a473 // the terminal — and matches the standard pattern for stdin pumps; a
459 // closable stdin duplicate could lift it later if ever needed.474 // closable stdin duplicate could lift it later if ever needed.
460-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader) {475+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, clipboard ports.ClipboardReader, log *slog.Logger) {
476+ defer log.Debug("stdin pump exited")
461 buf := make([]byte, stdinBufSize)477 buf := make([]byte, stdinBufSize)
462 var scanner theme.Scanner478 var scanner theme.Scanner
463 var inputSeq uint64479 var inputSeq uint64
@@ -493,7 +509,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
493 ci := &clipboardIntercept{509 ci := &clipboardIntercept{
494 coalescer: coalescer,510 coalescer: coalescer,
495 reader: clipboard,511 reader: clipboard,
496- log: slog.Default(),512+ log: log,
497 sendImage: func(mime string, data []byte) {513 sendImage: func(mime string, data []byte) {
498 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})514 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})
499 },515 },
@@ -537,7 +553,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
537 // runResize forwards coalesced terminal resize events to the daemon. It553 // runResize forwards coalesced terminal resize events to the daemon. It
538 // tolerates an already-closed resize channel (which the terminal adapter554 // tolerates an already-closed resize channel (which the terminal adapter
539 // hands back when restore ran before ResizeEvents was first called).555 // hands back when restore ran before ResizeEvents was first called).
540-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) {556+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
557+ defer log.Debug("resize pump exited")
541 for {558 for {
542 select {559 select {
543 case sz, ok := <-events:560 case sz, ok := <-events:
M internal/usecase/client/client_test.go +90 -5
@@ -107,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
107107
108108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
109109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
110135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
111136 tm := portsmocks.NewMockTerminal(t)
112137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -486,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
486511 }
487512 }
488513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
489574 func TestAttachForwardsResize(t *testing.T) {
490575 var out bytes.Buffer
491576 var restoreCount atomic.Int32
@@ -565,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
565650 d := portsmocks.NewMockDialer(t)
566651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
567652
568- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
569654 require.Error(t, err)
570655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
571656 }
@@ -585,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
585670 d := portsmocks.NewMockDialer(t)
586671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
587672
588- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil)
673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
589674 require.Error(t, err)
590675 var pe *client.ProtocolError
591676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -597,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
597682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
598683 tm := portsmocks.NewMockTerminal(t)
599684
600- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)
685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
601686 require.ErrorIs(t, err, dialErr)
602687 }
603688
@@ -686,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
686771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
687772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
688773
689- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
690775 require.NoError(t, err)
691776 require.Equal(t, int32(3), d.calls.Load())
692777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -713,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
713798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
714799 d := &sequenceDialer{trs: []ports.Transport{tr}}
715800
716- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)
801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
717802 require.Error(t, err)
718803 var de *client.DetachedError
719804 require.True(t, errors.As(err, &de))
@@ -107,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
107 107
108 func (r *oneShotBlockingReader) unblock() { close(r.done) }108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
109 109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
110 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
111 tm := portsmocks.NewMockTerminal(t)136 tm := portsmocks.NewMockTerminal(t)
112 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -486,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
486 }511 }
487 }512 }
488 513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
489 func TestAttachForwardsResize(t *testing.T) {574 func TestAttachForwardsResize(t *testing.T) {
490 var out bytes.Buffer575 var out bytes.Buffer
491 var restoreCount atomic.Int32576 var restoreCount atomic.Int32
@@ -565,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
565 d := portsmocks.NewMockDialer(t)650 d := portsmocks.NewMockDialer(t)
566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
567 652
568- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
569 require.Error(t, err)654 require.Error(t, err)
570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
571 }656 }
@@ -585,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
585 d := portsmocks.NewMockDialer(t)670 d := portsmocks.NewMockDialer(t)
586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
587 672
588- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil)673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
589 require.Error(t, err)674 require.Error(t, err)
590 var pe *client.ProtocolError675 var pe *client.ProtocolError
591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -597,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
598 tm := portsmocks.NewMockTerminal(t)683 tm := portsmocks.NewMockTerminal(t)
599 684
600- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil)685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
601 require.ErrorIs(t, err, dialErr)686 require.ErrorIs(t, err, dialErr)
602 }687 }
603 688
@@ -686,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
688 773
689- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
690 require.NoError(t, err)775 require.NoError(t, err)
691 require.Equal(t, int32(3), d.calls.Load())776 require.Equal(t, int32(3), d.calls.Load())
692 require.Equal(t, int32(1), term.rawCount.Load())777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -713,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
714 d := &sequenceDialer{trs: []ports.Transport{tr}}799 d := &sequenceDialer{trs: []ports.Transport{tr}}
715 800
716- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil)801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
717 require.Error(t, err)802 require.Error(t, err)
718 var de *client.DetachedError803 var de *client.DetachedError
719 require.True(t, errors.As(err, &de))804 require.True(t, errors.As(err, &de))
M internal/usecase/client/clipboard_intercept.go +1 -1
@@ -85,7 +85,7 @@ func (c *clipboardIntercept) Scan(data []byte) {
8585 // might be a split-across-reads prefix of) the bracketed-paste opening
8686 // marker. It returns len(data) when neither is present.
8787 func pasteMarkerBoundary(data []byte) int {
88- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
88+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
8989 return idx
9090 }
9191 if p := trailingOpenPrefixLen(data); p > 0 {
@@ -85,7 +85,7 @@ func (c *clipboardIntercept) Scan(data []byte) {
85 // might be a split-across-reads prefix of) the bracketed-paste opening85 // might be a split-across-reads prefix of) the bracketed-paste opening
86 // marker. It returns len(data) when neither is present.86 // marker. It returns len(data) when neither is present.
87 func pasteMarkerBoundary(data []byte) int {87 func pasteMarkerBoundary(data []byte) int {
88- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {88+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
89 return idx89 return idx
90 }90 }
91 if p := trailingOpenPrefixLen(data); p > 0 {91 if p := trailingOpenPrefixLen(data); p > 0 {
M internal/usecase/client/clipboard_intercept_test.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "io"
7+ "log/slog"
78 "testing"
89 "time"
910
@@ -85,7 +86,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
8586 d := portsmocks.NewMockDialer(t)
8687 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
8788
88- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip)
89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
8990 require.NoError(t, err)
9091 return gotInput, gotImage
9192 }
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "io"6 "io"
7+ "log/slog"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
@@ -85,7 +86,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
85 d := portsmocks.NewMockDialer(t)86 d := portsmocks.NewMockDialer(t)
86 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()87 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
87 88
88- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip)89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
89 require.NoError(t, err)90 require.NoError(t, err)
90 return gotInput, gotImage91 return gotInput, gotImage
91 }92 }
M internal/usecase/client/paste_coalescer.go +17 -17
@@ -8,14 +8,6 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 )
1010
11-// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12-// text with these so the receiving application can tell a paste from typed
13-// input.
14-var (
15- pasteOpenMarker = []byte("\x1b[200~")
16- pasteCloseMarker = []byte("\x1b[201~")
17-)
18-
1911 const (
2012 // pasteFlushDelay bounds how long a trailing strict prefix of the opening
2113 // marker is held before it is emitted as ordinary bytes. It only has to
@@ -91,9 +83,17 @@ func (c *pasteCoalescer) Scan(data []byte) {
9183 func (c *pasteCoalescer) process(data []byte) {
9284 for {
9385 if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
9492 c.buf = append(c.buf, data...)
95- data = nil
96- idx := bytes.Index(c.buf, pasteCloseMarker)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
9797 if idx < 0 {
9898 if len(c.buf) > maxPasteBuffer {
9999 c.emit(c.buf)
@@ -101,7 +101,7 @@ func (c *pasteCoalescer) process(data []byte) {
101101 }
102102 return
103103 }
104- end := idx + len(pasteCloseMarker)
104+ end := idx + len(ports.BracketedPasteCloseMarker)
105105 c.emit(c.buf[:end])
106106 rest := append([]byte(nil), c.buf[end:]...)
107107 c.resetBuffering()
@@ -113,7 +113,7 @@ func (c *pasteCoalescer) process(data []byte) {
113113 return
114114 }
115115
116- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117117 if idx > 0 {
118118 c.emit(data[:idx])
119119 }
@@ -254,12 +254,12 @@ func (c *pasteCoalescer) stopSafetyTimer() {
254254 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
255255 // "\x1b[20"). A full marker match is handled by the caller before this runs.
256256 func trailingOpenPrefixLen(data []byte) int {
257- max := len(pasteOpenMarker) - 1
258- if max > len(data) {
259- max = len(data)
257+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
258+ if matchLimit > len(data) {
259+ matchLimit = len(data)
260260 }
261- for n := max; n >= 1; n-- {
262- if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {
261+ for n := matchLimit; n >= 1; n-- {
262+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
263263 return n
264264 }
265265 }
@@ -8,14 +8,6 @@ import (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 )9 )
10 10
11-// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12-// text with these so the receiving application can tell a paste from typed
13-// input.
14-var (
15- pasteOpenMarker = []byte("\x1b[200~")
16- pasteCloseMarker = []byte("\x1b[201~")
17-)
18-
19 const (11 const (
20 // pasteFlushDelay bounds how long a trailing strict prefix of the opening12 // pasteFlushDelay bounds how long a trailing strict prefix of the opening
21 // marker is held before it is emitted as ordinary bytes. It only has to13 // marker is held before it is emitted as ordinary bytes. It only has to
@@ -91,9 +83,17 @@ func (c *pasteCoalescer) Scan(data []byte) {
91 func (c *pasteCoalescer) process(data []byte) {83 func (c *pasteCoalescer) process(data []byte) {
92 for {84 for {
93 if c.buffering {85 if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
94 c.buf = append(c.buf, data...)92 c.buf = append(c.buf, data...)
95- data = nil93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
96- idx := bytes.Index(c.buf, pasteCloseMarker)94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
97 if idx < 0 {97 if idx < 0 {
98 if len(c.buf) > maxPasteBuffer {98 if len(c.buf) > maxPasteBuffer {
99 c.emit(c.buf)99 c.emit(c.buf)
@@ -101,7 +101,7 @@ func (c *pasteCoalescer) process(data []byte) {
101 }101 }
102 return102 return
103 }103 }
104- end := idx + len(pasteCloseMarker)104+ end := idx + len(ports.BracketedPasteCloseMarker)
105 c.emit(c.buf[:end])105 c.emit(c.buf[:end])
106 rest := append([]byte(nil), c.buf[end:]...)106 rest := append([]byte(nil), c.buf[end:]...)
107 c.resetBuffering()107 c.resetBuffering()
@@ -113,7 +113,7 @@ func (c *pasteCoalescer) process(data []byte) {
113 return113 return
114 }114 }
115 115
116- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117 if idx > 0 {117 if idx > 0 {
118 c.emit(data[:idx])118 c.emit(data[:idx])
119 }119 }
@@ -254,12 +254,12 @@ func (c *pasteCoalescer) stopSafetyTimer() {
254 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or254 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
255 // "\x1b[20"). A full marker match is handled by the caller before this runs.255 // "\x1b[20"). A full marker match is handled by the caller before this runs.
256 func trailingOpenPrefixLen(data []byte) int {256 func trailingOpenPrefixLen(data []byte) int {
257- max := len(pasteOpenMarker) - 1257+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
258- if max > len(data) {258+ if matchLimit > len(data) {
259- max = len(data)259+ matchLimit = len(data)
260 }260 }
261- for n := max; n >= 1; n-- {261+ for n := matchLimit; n >= 1; n-- {
262- if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {262+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
263 return n263 return n
264 }264 }
265 }265 }
M internal/usecase/client/paste_coalescer_test.go +12 -0
@@ -117,6 +117,18 @@ func TestPasteCoalescerByteByByte(t *testing.T) {
117117 "paste fed one byte at a time must still emit as one contiguous paste")
118118 }
119119
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
120132 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121133 pc, clk, col := newTestCoalescer()
122134 defer pc.Close()
@@ -117,6 +117,18 @@ func TestPasteCoalescerByteByByte(t *testing.T) {
117 "paste fed one byte at a time must still emit as one contiguous paste")117 "paste fed one byte at a time must still emit as one contiguous paste")
118 }118 }
119 119
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
120 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {132 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121 pc, clk, col := newTestCoalescer()133 pc, clk, col := newTestCoalescer()
122 defer pc.Close()134 defer pc.Close()
M internal/usecase/copy/mode.go +12 -0
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240240 }
241241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242254 func strconvItoa(n int) string {
243255 if n == 0 {
244256 return "0"
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240 }240 }
241 241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242 func strconvItoa(n int) string {254 func strconvItoa(n int) string {
243 if n == 0 {255 if n == 0 {
244 return "0"256 return "0"
M internal/usecase/copy/mode_test.go +25 -0
@@ -1,6 +1,7 @@
11 package copy
22
33 import (
4+ "bytes"
45 "encoding/base64"
56 "strings"
67 "testing"
@@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366367 }
367368 }
368369
370+func TestOSC52FromBase64(t *testing.T) {
371+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
372+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
373+
374+ tests := []struct {
375+ name string
376+ in string
377+ want []byte
378+ }{
379+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
380+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
381+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
382+ {name: "oversized decoded payload", in: oversized, want: nil},
383+ }
384+ for _, tt := range tests {
385+ t.Run(tt.name, func(t *testing.T) {
386+ got := OSC52FromBase64(tt.in)
387+ if !bytes.Equal(got, tt.want) {
388+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
389+ }
390+ })
391+ }
392+}
393+
369394 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370395 t.Helper()
371396 s := string(chunk)
@@ -1,6 +1,7 @@
1 package copy1 package copy
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/base64"5 "encoding/base64"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366 }367 }
367 }368 }
368 369
370+func TestOSC52FromBase64(t *testing.T) {
371+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
372+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
373+
374+ tests := []struct {
375+ name string
376+ in string
377+ want []byte
378+ }{
379+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
380+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
381+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
382+ {name: "oversized decoded payload", in: oversized, want: nil},
383+ }
384+ for _, tt := range tests {
385+ t.Run(tt.name, func(t *testing.T) {
386+ got := OSC52FromBase64(tt.in)
387+ if !bytes.Equal(got, tt.want) {
388+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
389+ }
390+ })
391+ }
392+}
393+
369 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {394 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370 t.Helper()395 t.Helper()
371 s := string(chunk)396 s := string(chunk)
M internal/usecase/daemon/client.go +11 -3
@@ -232,7 +232,7 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
232232 }
233233
234234 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
235- tr, err := d.boundedSendWith(ac, func(capture func(ports.Transport)) error {
235+ tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
236236 tr, err := ac.sendTransport(f)
237237 capture(tr)
238238 return err
@@ -249,7 +249,7 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
249249 }
250250
251251 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
252- return d.boundedSendWith(ac, func(capture func(ports.Transport)) error {
252+ return d.boundedSendWith(func(capture func(ports.Transport)) error {
253253 ac.sendMu.Lock()
254254 defer ac.sendMu.Unlock()
255255 tr := ac.transport()
@@ -263,7 +263,7 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
263263
264264 var errSendTimedOut = errors.New("send timed out")
265265
266-func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(ports.Transport)) error) (ports.Transport, error) {
266+func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
267267 timer := d.clock.NewTimer(detachNotifyTimeout)
268268 result := make(chan error, 1)
269269 var (
@@ -293,6 +293,7 @@ func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(port
293293 return capturedTransport(), err
294294 default:
295295 }
296+ d.log.Warn("bounded send timed out; force closing client transport")
296297 return capturedTransport(), errSendTimedOut
297298 }
298299 }
@@ -322,6 +323,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
322323 defer close(done)
323324 d.boundedSend(ac, frameDetached(reason))
324325 _ = ac.closeTransport()
326+ d.log.Info("client detached", "reason", reason)
325327 }()
326328 }
327329
@@ -365,8 +367,10 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
365367 sess.mu.Lock()
366368 old := sess.client
367369 sess.client = ac
370+ name := sess.name
368371 tabs := append([]*tab(nil), sess.tabs...)
369372 sess.mu.Unlock()
373+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
370374 for _, tb := range tabs {
371375 tb.mu.Lock()
372376 panes := tb.panesSnapshot()
@@ -417,6 +421,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
417421 }
418422 d.unregisterPreview(old)
419423 old.setSession(nil)
424+ d.log.Info("client displaced")
420425 // Async + bounded: a dead or wedged old client must not stall the new
421426 // client's handshake.
422427 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -517,12 +522,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
517522 if !ephemeral {
518523 d.refreshSessionCwd(sess)
519524 }
525+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
520526 oldTr := failed
521527 if oldTr == nil {
522528 oldTr = ac.transport()
523529 }
524530 if !explicit && d.parkAttachment(sess, ac) {
525531 _ = ac.closeCapturedTransport(oldTr)
532+ d.log.Info("client parked", "session", sess.name)
526533 return
527534 }
528535 d.resetScreenDefaultColors(sess)
@@ -534,6 +541,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
534541 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
535542 }
536543 _ = ac.closeCapturedTransport(oldTr)
544+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
537545 if ephemeral {
538546 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
539547 }
@@ -232,7 +232,7 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
232 }232 }
233 233
234 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {234 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
235- tr, err := d.boundedSendWith(ac, func(capture func(ports.Transport)) error {235+ tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
236 tr, err := ac.sendTransport(f)236 tr, err := ac.sendTransport(f)
237 capture(tr)237 capture(tr)
238 return err238 return err
@@ -249,7 +249,7 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
249 }249 }
250 250
251 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {251 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
252- return d.boundedSendWith(ac, func(capture func(ports.Transport)) error {252+ return d.boundedSendWith(func(capture func(ports.Transport)) error {
253 ac.sendMu.Lock()253 ac.sendMu.Lock()
254 defer ac.sendMu.Unlock()254 defer ac.sendMu.Unlock()
255 tr := ac.transport()255 tr := ac.transport()
@@ -263,7 +263,7 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
263 263
264 var errSendTimedOut = errors.New("send timed out")264 var errSendTimedOut = errors.New("send timed out")
265 265
266-func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(ports.Transport)) error) (ports.Transport, error) {266+func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
267 timer := d.clock.NewTimer(detachNotifyTimeout)267 timer := d.clock.NewTimer(detachNotifyTimeout)
268 result := make(chan error, 1)268 result := make(chan error, 1)
269 var (269 var (
@@ -293,6 +293,7 @@ func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(port
293 return capturedTransport(), err293 return capturedTransport(), err
294 default:294 default:
295 }295 }
296+ d.log.Warn("bounded send timed out; force closing client transport")
296 return capturedTransport(), errSendTimedOut297 return capturedTransport(), errSendTimedOut
297 }298 }
298 }299 }
@@ -322,6 +323,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
322 defer close(done)323 defer close(done)
323 d.boundedSend(ac, frameDetached(reason))324 d.boundedSend(ac, frameDetached(reason))
324 _ = ac.closeTransport()325 _ = ac.closeTransport()
326+ d.log.Info("client detached", "reason", reason)
325 }()327 }()
326 }328 }
327 329
@@ -365,8 +367,10 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
365 sess.mu.Lock()367 sess.mu.Lock()
366 old := sess.client368 old := sess.client
367 sess.client = ac369 sess.client = ac
370+ name := sess.name
368 tabs := append([]*tab(nil), sess.tabs...)371 tabs := append([]*tab(nil), sess.tabs...)
369 sess.mu.Unlock()372 sess.mu.Unlock()
373+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
370 for _, tb := range tabs {374 for _, tb := range tabs {
371 tb.mu.Lock()375 tb.mu.Lock()
372 panes := tb.panesSnapshot()376 panes := tb.panesSnapshot()
@@ -417,6 +421,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
417 }421 }
418 d.unregisterPreview(old)422 d.unregisterPreview(old)
419 old.setSession(nil)423 old.setSession(nil)
424+ d.log.Info("client displaced")
420 // Async + bounded: a dead or wedged old client must not stall the new425 // Async + bounded: a dead or wedged old client must not stall the new
421 // client's handshake.426 // client's handshake.
422 d.notifyDetachedAsync(old, ports.ReasonDetach)427 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -517,12 +522,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
517 if !ephemeral {522 if !ephemeral {
518 d.refreshSessionCwd(sess)523 d.refreshSessionCwd(sess)
519 }524 }
525+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
520 oldTr := failed526 oldTr := failed
521 if oldTr == nil {527 if oldTr == nil {
522 oldTr = ac.transport()528 oldTr = ac.transport()
523 }529 }
524 if !explicit && d.parkAttachment(sess, ac) {530 if !explicit && d.parkAttachment(sess, ac) {
525 _ = ac.closeCapturedTransport(oldTr)531 _ = ac.closeCapturedTransport(oldTr)
532+ d.log.Info("client parked", "session", sess.name)
526 return533 return
527 }534 }
528 d.resetScreenDefaultColors(sess)535 d.resetScreenDefaultColors(sess)
@@ -534,6 +541,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
534 d.boundedSend(ac, frameDetached(ports.ReasonDetach))541 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
535 }542 }
536 _ = ac.closeCapturedTransport(oldTr)543 _ = ac.closeCapturedTransport(oldTr)
544+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
537 if ephemeral {545 if ephemeral {
538 _ = d.killSession(sess, ports.ReasonSessionKilled, false)546 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
539 }547 }
M internal/usecase/daemon/clipboard.go +80 -0
@@ -9,6 +9,7 @@ import (
99 "path/filepath"
1010
1111 "github.com/bnema/vev/internal/ports"
12+ scopy "github.com/bnema/vev/internal/usecase/copy"
1213 )
1314
1415 // maxImagePushSize independently caps an accepted MsgImagePush payload,
@@ -114,3 +115,82 @@ func clipboardExt(mime string) string {
114115 return "bin"
115116 }
116117 }
118+
119+type clipboardForward struct {
120+ ac *attachedClient
121+ seq []byte
122+}
123+
124+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
125+// request (already captured off the pane's screen while ptyReader held pane.mu,
126+// then handed here after that lock is released) to the session's attached
127+// client, if any. Invalid base64 or an oversized decoded payload is dropped
128+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
129+// caller must not hold pane.mu, tab.mu, or session.mu.
130+//
131+// Requests are queued on the session and drained by one worker so clipboard
132+// writes keep arrival order without making the PTY reader wait on client I/O.
133+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
134+ seq := scopy.OSC52FromBase64(b64)
135+ if seq == nil {
136+ return
137+ }
138+
139+ sess.mu.Lock()
140+ if sess.ctx != nil {
141+ select {
142+ case <-sess.ctx.Done():
143+ sess.mu.Unlock()
144+ return
145+ default:
146+ }
147+ }
148+ ac := sess.client
149+ if ac == nil {
150+ sess.mu.Unlock()
151+ return
152+ }
153+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
154+ if !sess.clipboardWorkerRunning {
155+ sess.clipboardWorkerRunning = true
156+ go d.clipboardWorker(sess)
157+ }
158+ sess.mu.Unlock()
159+}
160+
161+func (d *Daemon) clipboardWorker(sess *session) {
162+ for {
163+ item, ok := nextClipboardForward(sess)
164+ if !ok {
165+ return
166+ }
167+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
168+ if err != nil {
169+ d.detachOnSendError(sess, item.ac, failed)
170+ }
171+ }
172+}
173+
174+func nextClipboardForward(sess *session) (clipboardForward, bool) {
175+ sess.mu.Lock()
176+ defer sess.mu.Unlock()
177+ if sess.ctx != nil {
178+ select {
179+ case <-sess.ctx.Done():
180+ sess.clipboardQueue = nil
181+ sess.clipboardWorkerRunning = false
182+ return clipboardForward{}, false
183+ default:
184+ }
185+ }
186+ if len(sess.clipboardQueue) == 0 {
187+ sess.clipboardWorkerRunning = false
188+ return clipboardForward{}, false
189+ }
190+ item := sess.clipboardQueue[0]
191+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
192+ var zero clipboardForward
193+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
194+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
195+ return item, true
196+}
@@ -9,6 +9,7 @@ import (
9 "path/filepath"9 "path/filepath"
10 10
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ scopy "github.com/bnema/vev/internal/usecase/copy"
12 )13 )
13 14
14 // maxImagePushSize independently caps an accepted MsgImagePush payload,15 // maxImagePushSize independently caps an accepted MsgImagePush payload,
@@ -114,3 +115,82 @@ func clipboardExt(mime string) string {
114 return "bin"115 return "bin"
115 }116 }
116 }117 }
118+
119+type clipboardForward struct {
120+ ac *attachedClient
121+ seq []byte
122+}
123+
124+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
125+// request (already captured off the pane's screen while ptyReader held pane.mu,
126+// then handed here after that lock is released) to the session's attached
127+// client, if any. Invalid base64 or an oversized decoded payload is dropped
128+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
129+// caller must not hold pane.mu, tab.mu, or session.mu.
130+//
131+// Requests are queued on the session and drained by one worker so clipboard
132+// writes keep arrival order without making the PTY reader wait on client I/O.
133+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
134+ seq := scopy.OSC52FromBase64(b64)
135+ if seq == nil {
136+ return
137+ }
138+
139+ sess.mu.Lock()
140+ if sess.ctx != nil {
141+ select {
142+ case <-sess.ctx.Done():
143+ sess.mu.Unlock()
144+ return
145+ default:
146+ }
147+ }
148+ ac := sess.client
149+ if ac == nil {
150+ sess.mu.Unlock()
151+ return
152+ }
153+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
154+ if !sess.clipboardWorkerRunning {
155+ sess.clipboardWorkerRunning = true
156+ go d.clipboardWorker(sess)
157+ }
158+ sess.mu.Unlock()
159+}
160+
161+func (d *Daemon) clipboardWorker(sess *session) {
162+ for {
163+ item, ok := nextClipboardForward(sess)
164+ if !ok {
165+ return
166+ }
167+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
168+ if err != nil {
169+ d.detachOnSendError(sess, item.ac, failed)
170+ }
171+ }
172+}
173+
174+func nextClipboardForward(sess *session) (clipboardForward, bool) {
175+ sess.mu.Lock()
176+ defer sess.mu.Unlock()
177+ if sess.ctx != nil {
178+ select {
179+ case <-sess.ctx.Done():
180+ sess.clipboardQueue = nil
181+ sess.clipboardWorkerRunning = false
182+ return clipboardForward{}, false
183+ default:
184+ }
185+ }
186+ if len(sess.clipboardQueue) == 0 {
187+ sess.clipboardWorkerRunning = false
188+ return clipboardForward{}, false
189+ }
190+ item := sess.clipboardQueue[0]
191+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
192+ var zero clipboardForward
193+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
194+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
195+ return item, true
196+}
M internal/usecase/daemon/clipboard_test.go +185 -0
@@ -1,13 +1,22 @@
11 package daemon
22
33 import (
4+ "context"
5+ "encoding/base64"
6+ "io"
47 "os"
58 "path/filepath"
9+ "strings"
610 "testing"
11+ "time"
712
13+ "github.com/stretchr/testify/mock"
814 "github.com/stretchr/testify/require"
915
1016 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
19+ "github.com/bnema/vev/pkg/renderer"
1120 )
1221
1322 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
@@ -149,3 +158,179 @@ func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
149158 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
150159 require.NoFileExists(t, path)
151160 }
161+
162+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
163+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
164+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
165+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
166+ t.Helper()
167+ p := portsmocks.NewMockPTY(t)
168+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
169+ if len(chunks) == 0 {
170+ return 0, io.EOF
171+ }
172+ n := copy(buf, chunks[0])
173+ if n == len(chunks[0]) {
174+ chunks = chunks[1:]
175+ } else {
176+ chunks[0] = chunks[0][n:]
177+ }
178+ return n, nil
179+ })
180+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
181+ p.EXPECT().Close().Return(nil).Maybe()
182+ return p
183+}
184+
185+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
186+ d := newTestDaemon(t, nil, stubClock{})
187+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
188+
189+ tr, sends := newCapturingTransport(t)
190+ sctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ win := newTestTabWithContext(p, sctx, cancel)
193+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
195+ ac.setSession(sess)
196+ d.sessions[sess.id] = sess
197+
198+ d.sessWg.Add(1)
199+ d.ptyReader(sess, win, win.focusedPane())
200+
201+ f := awaitFrame(t, sends, ports.MsgOutput)
202+ out, err := ports.UnmarshalOutput(f.Payload)
203+ require.NoError(t, err)
204+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
205+}
206+
207+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
208+ d := newTestDaemon(t, nil, stubClock{})
209+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
210+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
211+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
212+
213+ tr, sends := newCapturingTransport(t)
214+ sctx, cancel := context.WithCancel(context.Background())
215+ defer cancel()
216+ win := newTestTabWithContext(p, sctx, cancel)
217+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
218+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219+ ac.setSession(sess)
220+ d.sessions[sess.id] = sess
221+
222+ d.sessWg.Add(1)
223+ d.ptyReader(sess, win, win.focusedPane())
224+
225+ select {
226+ case f := <-sends:
227+ require.NotEqual(t, ports.MsgOutput, f.Type)
228+ default:
229+ }
230+}
231+
232+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
233+ d := newTestDaemon(t, nil, stubClock{})
234+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
235+
236+ tr, sends := newCapturingTransport(t)
237+ sctx, cancel := context.WithCancel(context.Background())
238+ defer cancel()
239+ win := newTestTabWithContext(p, sctx, cancel)
240+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
241+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
242+ ac.setSession(sess)
243+ d.sessions[sess.id] = sess
244+
245+ d.sessWg.Add(1)
246+ d.ptyReader(sess, win, win.focusedPane())
247+
248+ select {
249+ case f := <-sends:
250+ require.NotEqual(t, ports.MsgOutput, f.Type)
251+ default:
252+ }
253+}
254+
255+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
256+ d := newTestDaemon(t, nil, stubClock{})
257+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
258+
259+ sctx, cancel := context.WithCancel(context.Background())
260+ defer cancel()
261+ win := newTestTabWithContext(p, sctx, cancel)
262+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
263+ d.sessions[sess.id] = sess
264+
265+ d.sessWg.Add(1)
266+ require.NotPanics(t, func() {
267+ d.ptyReader(sess, win, win.focusedPane())
268+ })
269+}
270+
271+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
272+ d := newTestDaemon(t, nil, stubClock{})
273+ tr := newBlockingClipboardTransport()
274+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
275+ sctx, cancel := context.WithCancel(context.Background())
276+ defer cancel()
277+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
278+ ac.setSession(sess)
279+
280+ first := base64.StdEncoding.EncodeToString([]byte("first"))
281+ second := base64.StdEncoding.EncodeToString([]byte("second"))
282+ d.forwardClipboardAsync(sess, first)
283+ require.Equal(t, "first", <-tr.started)
284+
285+ d.forwardClipboardAsync(sess, second)
286+ select {
287+ case got := <-tr.started:
288+ t.Fatalf("second clipboard send started before first completed: %q", got)
289+ case <-time.After(50 * time.Millisecond):
290+ }
291+
292+ close(tr.releaseFirst)
293+ require.Equal(t, "first", <-tr.sent)
294+ require.Equal(t, "second", <-tr.started)
295+ require.Equal(t, "second", <-tr.sent)
296+}
297+
298+type blockingClipboardTransport struct {
299+ started chan string
300+ sent chan string
301+ releaseFirst chan struct{}
302+}
303+
304+func newBlockingClipboardTransport() *blockingClipboardTransport {
305+ return &blockingClipboardTransport{
306+ started: make(chan string, 2),
307+ sent: make(chan string, 2),
308+ releaseFirst: make(chan struct{}),
309+ }
310+}
311+
312+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
313+ out, err := ports.UnmarshalOutput(f.Payload)
314+ if err != nil {
315+ return err
316+ }
317+ data := string(out.Data)
318+ var label string
319+ switch {
320+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
321+ label = "first"
322+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
323+ label = "second"
324+ default:
325+ label = data
326+ }
327+ tr.started <- label
328+ if label == "first" {
329+ <-tr.releaseFirst
330+ }
331+ tr.sent <- label
332+ return nil
333+}
334+
335+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
336+func (tr *blockingClipboardTransport) Close() error { return nil }
@@ -1,13 +1,22 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+ "encoding/base64"
6+ "io"
4 "os"7 "os"
5 "path/filepath"8 "path/filepath"
9+ "strings"
6 "testing"10 "testing"
11+ "time"
7 12
13+ "github.com/stretchr/testify/mock"
8 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
9 15
10 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
19+ "github.com/bnema/vev/pkg/renderer"
11 )20 )
12 21
13 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {22 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
@@ -149,3 +158,179 @@ func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
149 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))158 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
150 require.NoFileExists(t, path)159 require.NoFileExists(t, path)
151 }160 }
161+
162+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
163+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
164+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
165+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
166+ t.Helper()
167+ p := portsmocks.NewMockPTY(t)
168+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
169+ if len(chunks) == 0 {
170+ return 0, io.EOF
171+ }
172+ n := copy(buf, chunks[0])
173+ if n == len(chunks[0]) {
174+ chunks = chunks[1:]
175+ } else {
176+ chunks[0] = chunks[0][n:]
177+ }
178+ return n, nil
179+ })
180+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
181+ p.EXPECT().Close().Return(nil).Maybe()
182+ return p
183+}
184+
185+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
186+ d := newTestDaemon(t, nil, stubClock{})
187+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
188+
189+ tr, sends := newCapturingTransport(t)
190+ sctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ win := newTestTabWithContext(p, sctx, cancel)
193+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
195+ ac.setSession(sess)
196+ d.sessions[sess.id] = sess
197+
198+ d.sessWg.Add(1)
199+ d.ptyReader(sess, win, win.focusedPane())
200+
201+ f := awaitFrame(t, sends, ports.MsgOutput)
202+ out, err := ports.UnmarshalOutput(f.Payload)
203+ require.NoError(t, err)
204+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
205+}
206+
207+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
208+ d := newTestDaemon(t, nil, stubClock{})
209+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
210+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
211+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
212+
213+ tr, sends := newCapturingTransport(t)
214+ sctx, cancel := context.WithCancel(context.Background())
215+ defer cancel()
216+ win := newTestTabWithContext(p, sctx, cancel)
217+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
218+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219+ ac.setSession(sess)
220+ d.sessions[sess.id] = sess
221+
222+ d.sessWg.Add(1)
223+ d.ptyReader(sess, win, win.focusedPane())
224+
225+ select {
226+ case f := <-sends:
227+ require.NotEqual(t, ports.MsgOutput, f.Type)
228+ default:
229+ }
230+}
231+
232+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
233+ d := newTestDaemon(t, nil, stubClock{})
234+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
235+
236+ tr, sends := newCapturingTransport(t)
237+ sctx, cancel := context.WithCancel(context.Background())
238+ defer cancel()
239+ win := newTestTabWithContext(p, sctx, cancel)
240+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
241+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
242+ ac.setSession(sess)
243+ d.sessions[sess.id] = sess
244+
245+ d.sessWg.Add(1)
246+ d.ptyReader(sess, win, win.focusedPane())
247+
248+ select {
249+ case f := <-sends:
250+ require.NotEqual(t, ports.MsgOutput, f.Type)
251+ default:
252+ }
253+}
254+
255+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
256+ d := newTestDaemon(t, nil, stubClock{})
257+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
258+
259+ sctx, cancel := context.WithCancel(context.Background())
260+ defer cancel()
261+ win := newTestTabWithContext(p, sctx, cancel)
262+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
263+ d.sessions[sess.id] = sess
264+
265+ d.sessWg.Add(1)
266+ require.NotPanics(t, func() {
267+ d.ptyReader(sess, win, win.focusedPane())
268+ })
269+}
270+
271+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
272+ d := newTestDaemon(t, nil, stubClock{})
273+ tr := newBlockingClipboardTransport()
274+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
275+ sctx, cancel := context.WithCancel(context.Background())
276+ defer cancel()
277+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
278+ ac.setSession(sess)
279+
280+ first := base64.StdEncoding.EncodeToString([]byte("first"))
281+ second := base64.StdEncoding.EncodeToString([]byte("second"))
282+ d.forwardClipboardAsync(sess, first)
283+ require.Equal(t, "first", <-tr.started)
284+
285+ d.forwardClipboardAsync(sess, second)
286+ select {
287+ case got := <-tr.started:
288+ t.Fatalf("second clipboard send started before first completed: %q", got)
289+ case <-time.After(50 * time.Millisecond):
290+ }
291+
292+ close(tr.releaseFirst)
293+ require.Equal(t, "first", <-tr.sent)
294+ require.Equal(t, "second", <-tr.started)
295+ require.Equal(t, "second", <-tr.sent)
296+}
297+
298+type blockingClipboardTransport struct {
299+ started chan string
300+ sent chan string
301+ releaseFirst chan struct{}
302+}
303+
304+func newBlockingClipboardTransport() *blockingClipboardTransport {
305+ return &blockingClipboardTransport{
306+ started: make(chan string, 2),
307+ sent: make(chan string, 2),
308+ releaseFirst: make(chan struct{}),
309+ }
310+}
311+
312+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
313+ out, err := ports.UnmarshalOutput(f.Payload)
314+ if err != nil {
315+ return err
316+ }
317+ data := string(out.Data)
318+ var label string
319+ switch {
320+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
321+ label = "first"
322+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
323+ label = "second"
324+ default:
325+ label = data
326+ }
327+ tr.started <- label
328+ if label == "first" {
329+ <-tr.releaseFirst
330+ }
331+ tr.sent <- label
332+ return nil
333+}
334+
335+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
336+func (tr *blockingClipboardTransport) Close() error { return nil }
M internal/usecase/daemon/daemon.go +12 -0
@@ -257,6 +257,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
257257 for {
258258 tr, err := l.Accept()
259259 if err != nil {
260+ if d.serveCtx.Err() != nil {
261+ d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
262+ } else {
263+ d.log.Warn("accept loop exiting", "err", err)
264+ }
260265 break
261266 }
262267 d.connWg.Go(func() {
@@ -300,6 +305,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
300305 snapshot := d.sessionsSnapshotLocked()
301306 empty := len(snapshot) == 0
302307 d.mu.Unlock()
308+ d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
303309 if empty {
304310 d.doneOnce.Do(func() { close(d.done) })
305311 return
@@ -326,6 +332,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
326332
327333 first, err := tr.Recv()
328334 if err != nil {
335+ d.log.Warn("connection closed before hello", "err", err)
329336 _ = tr.Close()
330337 return
331338 }
@@ -337,6 +344,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
337344 case ports.MsgHello:
338345 d.handleHello(tr, first)
339346 default:
347+ d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)
340348 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))
341349 _ = tr.Close()
342350 }
@@ -428,14 +436,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
428436 h, err := ports.UnmarshalHello(f.Payload)
429437 if err != nil {
430438 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
439+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)
431440 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
432441 } else {
442+ d.log.Warn("hello rejected", "err", err)
433443 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
434444 }
435445 _ = tr.Close()
436446 return
437447 }
438448 if h.Version != ports.ProtocolVersion {
449+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)
439450 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
440451 _ = tr.Close()
441452 return
@@ -443,6 +454,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
443454
444455 sess, ac, rerr := d.route(h, tr)
445456 if rerr != nil {
457+ d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
446458 if pe, ok := errors.AsType[*protoErr](rerr); ok {
447459 _ = tr.Send(frameError(pe.code, pe.text))
448460 } else {
@@ -257,6 +257,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
257 for {257 for {
258 tr, err := l.Accept()258 tr, err := l.Accept()
259 if err != nil {259 if err != nil {
260+ if d.serveCtx.Err() != nil {
261+ d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
262+ } else {
263+ d.log.Warn("accept loop exiting", "err", err)
264+ }
260 break265 break
261 }266 }
262 d.connWg.Go(func() {267 d.connWg.Go(func() {
@@ -300,6 +305,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
300 snapshot := d.sessionsSnapshotLocked()305 snapshot := d.sessionsSnapshotLocked()
301 empty := len(snapshot) == 0306 empty := len(snapshot) == 0
302 d.mu.Unlock()307 d.mu.Unlock()
308+ d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
303 if empty {309 if empty {
304 d.doneOnce.Do(func() { close(d.done) })310 d.doneOnce.Do(func() { close(d.done) })
305 return311 return
@@ -326,6 +332,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
326 332
327 first, err := tr.Recv()333 first, err := tr.Recv()
328 if err != nil {334 if err != nil {
335+ d.log.Warn("connection closed before hello", "err", err)
329 _ = tr.Close()336 _ = tr.Close()
330 return337 return
331 }338 }
@@ -337,6 +344,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
337 case ports.MsgHello:344 case ports.MsgHello:
338 d.handleHello(tr, first)345 d.handleHello(tr, first)
339 default:346 default:
347+ d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)
340 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))348 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))
341 _ = tr.Close()349 _ = tr.Close()
342 }350 }
@@ -428,14 +436,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
428 h, err := ports.UnmarshalHello(f.Payload)436 h, err := ports.UnmarshalHello(f.Payload)
429 if err != nil {437 if err != nil {
430 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {438 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
439+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)
431 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))440 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
432 } else {441 } else {
442+ d.log.Warn("hello rejected", "err", err)
433 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))443 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
434 }444 }
435 _ = tr.Close()445 _ = tr.Close()
436 return446 return
437 }447 }
438 if h.Version != ports.ProtocolVersion {448 if h.Version != ports.ProtocolVersion {
449+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)
439 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))450 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
440 _ = tr.Close()451 _ = tr.Close()
441 return452 return
@@ -443,6 +454,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
443 454
444 sess, ac, rerr := d.route(h, tr)455 sess, ac, rerr := d.route(h, tr)
445 if rerr != nil {456 if rerr != nil {
457+ d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
446 if pe, ok := errors.AsType[*protoErr](rerr); ok {458 if pe, ok := errors.AsType[*protoErr](rerr); ok {
447 _ = tr.Send(frameError(pe.code, pe.text))459 _ = tr.Send(frameError(pe.code, pe.text))
448 } else {460 } else {
M internal/usecase/daemon/pane_actions.go +2 -0
@@ -61,6 +61,7 @@ func (d *Daemon) spawnPaneOp(
6161
6262 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))
6363 if err != nil {
64+ d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
6465 tb.mu.Lock()
6566 _ = tb.tree.Close(newID)
6667 tb.tree.Focus = oldFocus
@@ -236,6 +237,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
236237 if p.pty != nil {
237238 _ = p.pty.Close()
238239 }
240+ d.log.Info("pane closed", "session", sess.name, "pane", id)
239241 if repaint {
240242 if ac == nil {
241243 sess.mu.Lock()
@@ -61,6 +61,7 @@ func (d *Daemon) spawnPaneOp(
61 61
62 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))62 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))
63 if err != nil {63 if err != nil {
64+ d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
64 tb.mu.Lock()65 tb.mu.Lock()
65 _ = tb.tree.Close(newID)66 _ = tb.tree.Close(newID)
66 tb.tree.Focus = oldFocus67 tb.tree.Focus = oldFocus
@@ -236,6 +237,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
236 if p.pty != nil {237 if p.pty != nil {
237 _ = p.pty.Close()238 _ = p.pty.Close()
238 }239 }
240+ d.log.Info("pane closed", "session", sess.name, "pane", id)
239 if repaint {241 if repaint {
240 if ac == nil {242 if ac == nil {
241 sess.mu.Lock()243 sess.mu.Lock()
M internal/usecase/daemon/render.go +8 -0
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
5454 }
5555 buf := make([]byte, ptyReadBufSize)
5656 var resp []byte
57+ var clipboards []string
5758 attentionCh := make(chan struct{}, 1)
5859 p.mu.Lock()
5960 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
6061 p.screen.OnBell = func() { signal(attentionCh) }
6162 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
6264 p.mu.Unlock()
6365 for {
6466 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
7476 d.noteAttention(sess, tb)
7577 default:
7678 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboardAsync(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
7785 if len(resp) > 0 {
7886 if _, writeErr := p.pty.Write(resp); writeErr != nil {
7987 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
54 }54 }
55 buf := make([]byte, ptyReadBufSize)55 buf := make([]byte, ptyReadBufSize)
56 var resp []byte56 var resp []byte
57+ var clipboards []string
57 attentionCh := make(chan struct{}, 1)58 attentionCh := make(chan struct{}, 1)
58 p.mu.Lock()59 p.mu.Lock()
59 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }60 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
60 p.screen.OnBell = func() { signal(attentionCh) }61 p.screen.OnBell = func() { signal(attentionCh) }
61 p.screen.OnNotify = func(string, string) { signal(attentionCh) }62 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
62 p.mu.Unlock()64 p.mu.Unlock()
63 for {65 for {
64 n, err := p.pty.Read(buf)66 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
74 d.noteAttention(sess, tb)76 d.noteAttention(sess, tb)
75 default:77 default:
76 }78 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboardAsync(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
77 if len(resp) > 0 {85 if len(resp) > 0 {
78 if _, writeErr := p.pty.Write(resp); writeErr != nil {86 if _, writeErr := p.pty.Write(resp); writeErr != nil {
79 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)87 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
M internal/usecase/daemon/resume.go +8 -0
@@ -43,6 +43,7 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
4343 if ac.resumeToken == 0 {
4444 ac.resumeToken = d.nextResumeTokenLocked()
4545 }
46+ d.log.Info("parking client prepared", "session", sess.name)
4647 return true
4748 }
4849
@@ -65,6 +66,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
6566 ac.parked = true
6667 d.parked[token] = parked
6768 d.mu.Unlock()
69+ d.log.Info("client parked for resume", "session", sess.name, "grace", resumeParkGrace)
6870
6971 go func(token uint64, parked *parkedAttachment) {
7072 select {
@@ -80,6 +82,9 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
8082 d.mu.Lock()
8183 if d.parked[token] == parked {
8284 d.removeParkedLocked(token, parked)
85+ d.mu.Unlock()
86+ d.log.Warn("parked client expired", "session", parked.sess.name)
87+ return
8388 }
8489 d.mu.Unlock()
8590 }
@@ -126,10 +131,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
126131 sess.mu.Unlock()
127132 if !registered || active {
128133 d.removeParkedLocked(h.ResumeToken, parked)
134+ d.log.Warn("resume rejected", "session", sess.name, "registered", registered, "active", active)
129135 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
130136 }
131137 ac := parked.ac
132138 if ac.clientID != h.ClientID {
139+ d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
133140 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
134141 }
135142 delete(d.parked, h.ResumeToken)
@@ -143,5 +150,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
143150 sess.mu.Lock()
144151 sess.client = ac
145152 sess.mu.Unlock()
153+ d.log.Info("client resumed", "session", sess.name)
146154 return sess, ac, true, nil
147155 }
@@ -43,6 +43,7 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
43 if ac.resumeToken == 0 {43 if ac.resumeToken == 0 {
44 ac.resumeToken = d.nextResumeTokenLocked()44 ac.resumeToken = d.nextResumeTokenLocked()
45 }45 }
46+ d.log.Info("parking client prepared", "session", sess.name)
46 return true47 return true
47 }48 }
48 49
@@ -65,6 +66,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
65 ac.parked = true66 ac.parked = true
66 d.parked[token] = parked67 d.parked[token] = parked
67 d.mu.Unlock()68 d.mu.Unlock()
69+ d.log.Info("client parked for resume", "session", sess.name, "grace", resumeParkGrace)
68 70
69 go func(token uint64, parked *parkedAttachment) {71 go func(token uint64, parked *parkedAttachment) {
70 select {72 select {
@@ -80,6 +82,9 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
80 d.mu.Lock()82 d.mu.Lock()
81 if d.parked[token] == parked {83 if d.parked[token] == parked {
82 d.removeParkedLocked(token, parked)84 d.removeParkedLocked(token, parked)
85+ d.mu.Unlock()
86+ d.log.Warn("parked client expired", "session", parked.sess.name)
87+ return
83 }88 }
84 d.mu.Unlock()89 d.mu.Unlock()
85 }90 }
@@ -126,10 +131,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
126 sess.mu.Unlock()131 sess.mu.Unlock()
127 if !registered || active {132 if !registered || active {
128 d.removeParkedLocked(h.ResumeToken, parked)133 d.removeParkedLocked(h.ResumeToken, parked)
134+ d.log.Warn("resume rejected", "session", sess.name, "registered", registered, "active", active)
129 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}135 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
130 }136 }
131 ac := parked.ac137 ac := parked.ac
132 if ac.clientID != h.ClientID {138 if ac.clientID != h.ClientID {
139+ d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
133 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}140 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
134 }141 }
135 delete(d.parked, h.ResumeToken)142 delete(d.parked, h.ResumeToken)
@@ -143,5 +150,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
143 sess.mu.Lock()150 sess.mu.Lock()
144 sess.client = ac151 sess.client = ac
145 sess.mu.Unlock()152 sess.mu.Unlock()
153+ d.log.Info("client resumed", "session", sess.name)
146 return sess, ac, true, nil154 return sess, ac, true, nil
147 }155 }
M internal/usecase/daemon/session.go +23 -6
@@ -48,12 +48,14 @@ type session struct {
4848 ctx context.Context
4949 cancel context.CancelFunc
5050
51- mu sync.Mutex // guards tabs, active, client, and clipFiles
52- tabs []*tab
53- active int
54- client *attachedClient
55- cwd string
56- createdAt int64
51+ mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
52+ tabs []*tab
53+ active int
54+ client *attachedClient
55+ clipboardQueue []clipboardForward
56+ clipboardWorkerRunning bool
57+ cwd string
58+ createdAt int64
5759 // clipFiles records clipboard-image-transfer temp file paths (see
5860 // clipboard.go) written for this session, removed best-effort in
5961 // killSession.
@@ -88,6 +90,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
8890 tbSize := tabSize(sz)
8991 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
9092 if err != nil {
93+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
9194 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
9295 }
9396
@@ -117,6 +120,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
117120 delete(d.stopped, name)
118121 }
119122 d.sessions[id] = sess
123+ d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
124+ d.log.Info("tab created", "session", name, "tab", 0)
120125 d.startTabGoroutines(sess, tb)
121126 return sess, nil
122127 }
@@ -164,6 +169,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
164169 newSess.client = ac
165170 newSess.mu.Unlock()
166171 ac.setSession(newSess)
172+ d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
167173 d.mu.Unlock()
168174
169175 d.firstPaint(newSess, ac, sz)
@@ -179,6 +185,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
179185 sess.mu.Unlock()
180186 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
181187 if err != nil {
188+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
182189 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
183190 }
184191 tb := newTab(pty, tbSize)
@@ -206,7 +213,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
206213 sess.mu.Lock()
207214 sess.tabs = append(sess.tabs, tb)
208215 sess.active = len(sess.tabs) - 1
216+ tabIndex := sess.active
209217 sess.mu.Unlock()
218+ d.log.Info("tab created", "session", name, "tab", tabIndex)
210219 d.startTabGoroutines(sess, tb)
211220 d.mu.Unlock()
212221 return nil
@@ -273,6 +282,9 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
273282 }
274283
275284 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
285+ if p != nil {
286+ d.log.Info("pane created", "session", sess.name, "pane", p.id)
287+ }
276288 d.sessWg.Add(2)
277289 go d.ptyReader(sess, tb, p)
278290 go d.scheduler(sess, tb, p)
@@ -376,7 +388,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
376388 return
377389 }
378390 if len(sess.tabs) == 1 {
391+ name := sess.name
379392 sess.mu.Unlock()
393+ d.log.Info("tab closed", "session", name, "last", true)
380394 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
381395 return
382396 }
@@ -388,7 +402,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
388402 sess.active--
389403 }
390404 ac := sess.client
405+ name := sess.name
391406 sess.mu.Unlock()
407+ d.log.Info("tab closed", "session", name)
392408
393409 d.clearDestroyedTabPreview(tb)
394410 if tb.cancel != nil {
@@ -439,6 +455,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
439455 d.closing = true
440456 }
441457 d.mu.Unlock()
458+ d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
442459
443460 var purgeErr error
444461 if !ephemeral && purge {
@@ -48,12 +48,14 @@ type session struct {
48 ctx context.Context48 ctx context.Context
49 cancel context.CancelFunc49 cancel context.CancelFunc
50 50
51- mu sync.Mutex // guards tabs, active, client, and clipFiles51+ mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
52- tabs []*tab52+ tabs []*tab
53- active int53+ active int
54- client *attachedClient54+ client *attachedClient
55- cwd string55+ clipboardQueue []clipboardForward
56- createdAt int6456+ clipboardWorkerRunning bool
57+ cwd string
58+ createdAt int64
57 // clipFiles records clipboard-image-transfer temp file paths (see59 // clipFiles records clipboard-image-transfer temp file paths (see
58 // clipboard.go) written for this session, removed best-effort in60 // clipboard.go) written for this session, removed best-effort in
59 // killSession.61 // killSession.
@@ -88,6 +90,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
88 tbSize := tabSize(sz)90 tbSize := tabSize(sz)
89 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)91 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
90 if err != nil {92 if err != nil {
93+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
91 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)94 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
92 }95 }
93 96
@@ -117,6 +120,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
117 delete(d.stopped, name)120 delete(d.stopped, name)
118 }121 }
119 d.sessions[id] = sess122 d.sessions[id] = sess
123+ d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
124+ d.log.Info("tab created", "session", name, "tab", 0)
120 d.startTabGoroutines(sess, tb)125 d.startTabGoroutines(sess, tb)
121 return sess, nil126 return sess, nil
122 }127 }
@@ -164,6 +169,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
164 newSess.client = ac169 newSess.client = ac
165 newSess.mu.Unlock()170 newSess.mu.Unlock()
166 ac.setSession(newSess)171 ac.setSession(newSess)
172+ d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
167 d.mu.Unlock()173 d.mu.Unlock()
168 174
169 d.firstPaint(newSess, ac, sz)175 d.firstPaint(newSess, ac, sz)
@@ -179,6 +185,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
179 sess.mu.Unlock()185 sess.mu.Unlock()
180 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)186 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
181 if err != nil {187 if err != nil {
188+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
182 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)189 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
183 }190 }
184 tb := newTab(pty, tbSize)191 tb := newTab(pty, tbSize)
@@ -206,7 +213,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
206 sess.mu.Lock()213 sess.mu.Lock()
207 sess.tabs = append(sess.tabs, tb)214 sess.tabs = append(sess.tabs, tb)
208 sess.active = len(sess.tabs) - 1215 sess.active = len(sess.tabs) - 1
216+ tabIndex := sess.active
209 sess.mu.Unlock()217 sess.mu.Unlock()
218+ d.log.Info("tab created", "session", name, "tab", tabIndex)
210 d.startTabGoroutines(sess, tb)219 d.startTabGoroutines(sess, tb)
211 d.mu.Unlock()220 d.mu.Unlock()
212 return nil221 return nil
@@ -273,6 +282,9 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
273 }282 }
274 283
275 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {284 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
285+ if p != nil {
286+ d.log.Info("pane created", "session", sess.name, "pane", p.id)
287+ }
276 d.sessWg.Add(2)288 d.sessWg.Add(2)
277 go d.ptyReader(sess, tb, p)289 go d.ptyReader(sess, tb, p)
278 go d.scheduler(sess, tb, p)290 go d.scheduler(sess, tb, p)
@@ -376,7 +388,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
376 return388 return
377 }389 }
378 if len(sess.tabs) == 1 {390 if len(sess.tabs) == 1 {
391+ name := sess.name
379 sess.mu.Unlock()392 sess.mu.Unlock()
393+ d.log.Info("tab closed", "session", name, "last", true)
380 _ = d.killSession(sess, ports.ReasonSessionKilled, false)394 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
381 return395 return
382 }396 }
@@ -388,7 +402,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
388 sess.active--402 sess.active--
389 }403 }
390 ac := sess.client404 ac := sess.client
405+ name := sess.name
391 sess.mu.Unlock()406 sess.mu.Unlock()
407+ d.log.Info("tab closed", "session", name)
392 408
393 d.clearDestroyedTabPreview(tb)409 d.clearDestroyedTabPreview(tb)
394 if tb.cancel != nil {410 if tb.cancel != nil {
@@ -439,6 +455,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
439 d.closing = true455 d.closing = true
440 }456 }
441 d.mu.Unlock()457 d.mu.Unlock()
458+ d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
442 459
443 var purgeErr error460 var purgeErr error
444 if !ephemeral && purge {461 if !ephemeral && purge {
M internal/usecase/keys/router.go +3 -10
@@ -17,13 +17,6 @@ const (
1717 ESCDelay = 40 * time.Millisecond
1818 )
1919
20-// Bracketed-paste markers. A paste whose opening and closing markers land in
21-// one frame is forwarded verbatim so its content can never trip a key binding.
22-var (
23- pasteOpenMarker = []byte("\x1b[200~")
24- pasteCloseMarker = []byte("\x1b[201~")
25-)
26-
2720 // Action is an intercepted vev key binding.
2821 type Action int
2922
@@ -112,7 +105,7 @@ func (r *Router) route(data []byte) {
112105 return
113106 }
114107 remaining := data[i+1:]
115- if bytes.HasPrefix(data[i:], pasteOpenMarker) {
108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
116109 // A bracketed paste whose closing marker is in this same frame is
117110 // forwarded verbatim: pasted content bytes (including embedded
118111 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
@@ -120,8 +113,8 @@ func (r *Router) route(data []byte) {
120113 // fall through to today's per-byte routing (the '[' passes through
121114 // as a control prefix); Part A keeps pastes single-frame in
122115 // practice, so no cross-frame paste state is tracked here.
123- if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {
124- end := i + rel + len(pasteCloseMarker)
116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
125118 buf = append(buf, data[i:end]...)
126119 i = end
127120 continue
@@ -17,13 +17,6 @@ const (
17 ESCDelay = 40 * time.Millisecond17 ESCDelay = 40 * time.Millisecond
18 )18 )
19 19
20-// Bracketed-paste markers. A paste whose opening and closing markers land in
21-// one frame is forwarded verbatim so its content can never trip a key binding.
22-var (
23- pasteOpenMarker = []byte("\x1b[200~")
24- pasteCloseMarker = []byte("\x1b[201~")
25-)
26-
27 // Action is an intercepted vev key binding.20 // Action is an intercepted vev key binding.
28 type Action int21 type Action int
29 22
@@ -112,7 +105,7 @@ func (r *Router) route(data []byte) {
112 return105 return
113 }106 }
114 remaining := data[i+1:]107 remaining := data[i+1:]
115- if bytes.HasPrefix(data[i:], pasteOpenMarker) {108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
116 // A bracketed paste whose closing marker is in this same frame is109 // A bracketed paste whose closing marker is in this same frame is
117 // forwarded verbatim: pasted content bytes (including embedded110 // forwarded verbatim: pasted content bytes (including embedded
118 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never111 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
@@ -120,8 +113,8 @@ func (r *Router) route(data []byte) {
120 // fall through to today's per-byte routing (the '[' passes through113 // fall through to today's per-byte routing (the '[' passes through
121 // as a control prefix); Part A keeps pastes single-frame in114 // as a control prefix); Part A keeps pastes single-frame in
122 // practice, so no cross-frame paste state is tracked here.115 // practice, so no cross-frame paste state is tracked here.
123- if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
124- end := i + rel + len(pasteCloseMarker)117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
125 buf = append(buf, data[i:end]...)118 buf = append(buf, data[i:end]...)
126 i = end119 i = end
127 continue120 continue
M internal/usecase/keys/router_test.go +1 -1
@@ -506,7 +506,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
506506 h := &captureHandler{}
507507 r := NewRouter(clk, h)
508508
509- paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)
509+ paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510510 r.Route(paste)
511511
512512 require.Empty(t, h.actions, "paste content must not fire any binding")
@@ -506,7 +506,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
506 h := &captureHandler{}506 h := &captureHandler{}
507 r := NewRouter(clk, h)507 r := NewRouter(clk, h)
508 508
509- paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)509+ paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510 r.Route(paste)510 r.Route(paste)
511 511
512 require.Empty(t, h.actions, "paste content must not fire any binding")512 require.Empty(t, h.actions, "paste content must not fire any binding")
M pkg/vt/screen.go +40 -7
@@ -25,7 +25,11 @@ type screenState struct {
2525 savedCursor cursorState
2626 }
2727
28-const maxEscapeBufferLen = 4096
28+// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
29+// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
30+// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
31+// so keep this value in sync if that payload cap changes.
32+const maxEscapeBufferLen = 128 * 1024
2933
3034 type Screen struct {
3135 Frame renderer.Frame
@@ -45,8 +49,14 @@ type Screen struct {
4549 OnBell func()
4650 // OnNotify is called synchronously from Write for explicit terminal
4751 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.
52+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
4953 OnNotify func(title, body string)
54+ // OnClipboard is called synchronously from Write for a complete OSC 52
55+ // clipboard set request from the child. The OSC 52 selection field is
56+ // accepted but ignored; the callback receives only the raw base64 payload.
57+ // Clipboard queries (data == "?") and malformed payloads are ignored and
58+ // never invoke it. Nil disables it.
59+ OnClipboard func(b64 string)
5060
5161 defaultFG renderer.RGB
5262 defaultBG renderer.RGB
@@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625635 }
626636
627637 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard
629-// and every other OSC are still discarded.
638+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
639+// set requests (OSC 52) are acted on; titles and every other OSC are still
640+// discarded.
630641 func (s *Screen) handleOSC(payload []byte) {
642+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
643+ s.handleOSC52(string(payload[len("52;"):]))
644+ return
645+ }
631646 if s.OnNotify == nil {
632647 return
633648 }
@@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) {
654669 }
655670 }
656671
672+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673+// clipboard payload (selection may be empty; split on the first ";"). A
674+// clipboard query (data == "?") is always ignored — vev never answers
675+// clipboard queries. A payload with no second ";" is malformed and ignored.
676+func (s *Screen) handleOSC52(rest string) {
677+ idx := strings.IndexByte(rest, ';')
678+ if idx < 0 {
679+ return
680+ }
681+ data := rest[idx+1:]
682+ if data == "?" {
683+ return
684+ }
685+ if s.OnClipboard != nil {
686+ s.OnClipboard(data)
687+ }
688+}
689+
657690 func consumeSTString(data []byte) (consumed int, partial bool) {
658691 for i := 2; i < len(data); i++ {
659692 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
10221055 next := renderer.NewFrame(newW, newH)
10231056 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
10241057 if evict != nil {
1025- for y := 0; y < shift; y++ {
1058+ for y := range shift {
10261059 evict(old.Row(y))
10271060 }
10281061 }
1029- for dy := 0; dy < newH; dy++ {
1062+ for dy := range newH {
10301063 sy := dy + shift
10311064 if sy >= old.Height {
10321065 break
@@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) {
11261159 s.clampCursor()
11271160 switch mode {
11281161 case 1:
1129- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {
1162+ for y := range min(s.Row+1, s.Frame.Height) {
11301163 end := s.Frame.Width
11311164 if y == s.Row {
11321165 end = min(s.Col+1, s.Frame.Width)
@@ -25,7 +25,11 @@ type screenState struct {
25 savedCursor cursorState25 savedCursor cursorState
26 }26 }
27 27
28-const maxEscapeBufferLen = 409628+// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
29+// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
30+// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
31+// so keep this value in sync if that payload cap changes.
32+const maxEscapeBufferLen = 128 * 1024
29 33
30 type Screen struct {34 type Screen struct {
31 Frame renderer.Frame35 Frame renderer.Frame
@@ -45,8 +49,14 @@ type Screen struct {
45 OnBell func()49 OnBell func()
46 // OnNotify is called synchronously from Write for explicit terminal50 // OnNotify is called synchronously from Write for explicit terminal
47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).51 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.52+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
49 OnNotify func(title, body string)53 OnNotify func(title, body string)
54+ // OnClipboard is called synchronously from Write for a complete OSC 52
55+ // clipboard set request from the child. The OSC 52 selection field is
56+ // accepted but ignored; the callback receives only the raw base64 payload.
57+ // Clipboard queries (data == "?") and malformed payloads are ignored and
58+ // never invoke it. Nil disables it.
59+ OnClipboard func(b64 string)
50 60
51 defaultFG renderer.RGB61 defaultFG renderer.RGB
52 defaultBG renderer.RGB62 defaultBG renderer.RGB
@@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625 }635 }
626 636
627 // handleOSC inspects a complete OSC payload (between "ESC ]" and its637 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard638+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
629-// and every other OSC are still discarded.639+// set requests (OSC 52) are acted on; titles and every other OSC are still
640+// discarded.
630 func (s *Screen) handleOSC(payload []byte) {641 func (s *Screen) handleOSC(payload []byte) {
642+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
643+ s.handleOSC52(string(payload[len("52;"):]))
644+ return
645+ }
631 if s.OnNotify == nil {646 if s.OnNotify == nil {
632 return647 return
633 }648 }
@@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) {
654 }669 }
655 }670 }
656 671
672+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673+// clipboard payload (selection may be empty; split on the first ";"). A
674+// clipboard query (data == "?") is always ignored — vev never answers
675+// clipboard queries. A payload with no second ";" is malformed and ignored.
676+func (s *Screen) handleOSC52(rest string) {
677+ idx := strings.IndexByte(rest, ';')
678+ if idx < 0 {
679+ return
680+ }
681+ data := rest[idx+1:]
682+ if data == "?" {
683+ return
684+ }
685+ if s.OnClipboard != nil {
686+ s.OnClipboard(data)
687+ }
688+}
689+
657 func consumeSTString(data []byte) (consumed int, partial bool) {690 func consumeSTString(data []byte) (consumed int, partial bool) {
658 for i := 2; i < len(data); i++ {691 for i := 2; i < len(data); i++ {
659 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {692 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
1022 next := renderer.NewFrame(newW, newH)1055 next := renderer.NewFrame(newW, newH)
1023 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))1056 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
1024 if evict != nil {1057 if evict != nil {
1025- for y := 0; y < shift; y++ {1058+ for y := range shift {
1026 evict(old.Row(y))1059 evict(old.Row(y))
1027 }1060 }
1028 }1061 }
1029- for dy := 0; dy < newH; dy++ {1062+ for dy := range newH {
1030 sy := dy + shift1063 sy := dy + shift
1031 if sy >= old.Height {1064 if sy >= old.Height {
1032 break1065 break
@@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) {
1126 s.clampCursor()1159 s.clampCursor()
1127 switch mode {1160 switch mode {
1128 case 1:1161 case 1:
1129- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {1162+ for y := range min(s.Row+1, s.Frame.Height) {
1130 end := s.Frame.Width1163 end := s.Frame.Width
1131 if y == s.Row {1164 if y == s.Row {
1132 end = min(s.Col+1, s.Frame.Width)1165 end = min(s.Col+1, s.Frame.Width)
M pkg/vt/screen_test.go +64 -0
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157157 require.Equal(t, 1, calls)
158158 }
159159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160224 // ---------------------------------------------------------------------------
161225 // Printable ASCII / UTF-8
162226 // ---------------------------------------------------------------------------
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157 require.Equal(t, 1, calls)157 require.Equal(t, 1, calls)
158 }158 }
159 159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160 // ---------------------------------------------------------------------------224 // ---------------------------------------------------------------------------
161 // Printable ASCII / UTF-8225 // Printable ASCII / UTF-8
162 // ---------------------------------------------------------------------------226 // ---------------------------------------------------------------------------

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