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
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 | |||
| 19 | 19 | ||
| 20 | 20 | - Format with **goimports**, not gofmt — lint enforces it. | |
| 21 | 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 | 23 | - Feature branches live in git worktrees under `.worktrees/<branch-name>`. | |
| 23 | 24 | ||
| 24 | 25 | ## 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 | ## Architecture | 25 | ## Architecture | ||
M README.md +4 -17
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | # 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 | 8 | ## Install | |
| 6 | 9 | ||
| @@ -81,19 +84,3 @@ Visual mode freezes a view over history while the program keeps running undernea | |||
| 81 | 84 | | v or Space | toggle line selection | | |
| 82 | 85 | | y or Enter | copy selection and exit | | |
| 83 | 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`. | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | # vev | 1 | # 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 | ## Install | 8 | ## 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "log/slog" | |
| 5 | 6 | "net" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "time" | |
| @@ -35,12 +36,17 @@ type RemoteDialer struct { | |||
| 35 | 36 | Target string | |
| 36 | 37 | Session string | |
| 37 | 38 | ProbeTimeout time.Duration | |
| 39 | + | Log *slog.Logger | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | func NewRemoteDialer(target, session string) RemoteDialer { | |
| 41 | 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 | 50 | func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 45 | 51 | // The UDP bootstrap currently requires a long-lived remote proxy process. Do | |
| 46 | 52 | // 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 | 56 | return nil, ctx.Err() | |
| 51 | 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 | 62 | 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 string | 36 | Target string | ||
| 36 | Session string | 37 | Session string | ||
| 37 | ProbeTimeout time.Duration | 38 | 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. Do | 51 | // 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 detach | 52 | // 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) { | |||
| 288 | 288 | return | |
| 289 | 289 | } | |
| 290 | 290 | if reliable { | |
| 291 | - | if t.enqueueReliable(seq, f) { | |
| 291 | + | ack, queued := t.enqueueReliable(seq, f) | |
| 292 | + | if ack { | |
| 292 | 293 | t.sendAck(seq) | |
| 293 | 294 | } | |
| 295 | + | if queued { | |
| 296 | + | t.signalDelivery() | |
| 297 | + | } | |
| 294 | 298 | return | |
| 295 | 299 | } | |
| 296 | 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 | 305 | t.deliverMu.Lock() | |
| 302 | 306 | defer t.deliverMu.Unlock() | |
| 303 | 307 | if seq < t.nextRecvSeq { | |
| 304 | - | return true | |
| 308 | + | return true, false | |
| 305 | 309 | } | |
| 306 | 310 | if _, exists := t.recvBuf[seq]; exists { | |
| 307 | - | return true | |
| 311 | + | return true, false | |
| 308 | 312 | } | |
| 309 | 313 | if len(t.recvBuf) >= maxRecvBuffer { | |
| 310 | 314 | if seq != t.nextRecvSeq { | |
| 311 | - | return false | |
| 315 | + | return false, false | |
| 312 | 316 | } | |
| 313 | 317 | for bufferedSeq := range t.recvBuf { | |
| 314 | 318 | delete(t.recvBuf, bufferedSeq) | |
| @@ -316,8 +320,13 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool { | |||
| 316 | 320 | } | |
| 317 | 321 | } | |
| 318 | 322 | t.recvBuf[seq] = f | |
| 323 | + | return true, true | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (t *Transport) signalDelivery() { | |
| 327 | + | t.deliverMu.Lock() | |
| 319 | 328 | t.deliverCond.Signal() | |
| 320 | - | return true | |
| 329 | + | t.deliverMu.Unlock() | |
| 321 | 330 | } | |
| 322 | 331 | ||
| 323 | 332 | func (t *Transport) deliveryLoop() { | |
| @@ -288,27 +288,31 @@ func (t *Transport) handleRecord(p []byte) { | |||||
| 288 | return | 288 | 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 | return | 298 | 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 true | 308 | + | return true, false |
| 305 | } | 309 | } | ||
| 306 | if _, exists := t.recvBuf[seq]; exists { | 310 | if _, exists := t.recvBuf[seq]; exists { | ||
| 307 | - | return true | 311 | + | 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 false | 315 | + | 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] = f | 322 | 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 true | 329 | + | 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) { | |||
| 59 | 59 | } | |
| 60 | 60 | peer := p.peers[addr.String()] | |
| 61 | 61 | peer.mu.Lock() | |
| 62 | - | peerClosed := peer.closed | |
| 63 | - | peer.mu.Unlock() | |
| 64 | - | if peerClosed { | |
| 62 | + | defer peer.mu.Unlock() | |
| 63 | + | if peer.closed { | |
| 65 | 64 | return 0, errors.New("peer closed") | |
| 66 | 65 | } | |
| 67 | 66 | 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.closed | 62 | + | 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 ( | |||
| 11 | 11 | "errors" | |
| 12 | 12 | "fmt" | |
| 13 | 13 | "io" | |
| 14 | + | "log/slog" | |
| 14 | 15 | "os/exec" | |
| 15 | 16 | "strings" | |
| 16 | 17 | "sync" | |
| @@ -95,7 +96,7 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 95 | 96 | ||
| 96 | 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 | 100 | return func() error { | |
| 100 | 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 | 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 | 125 | if stderrText != "" { | |
| 118 | 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 | 167 | // 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) { | |
| 162 | 172 | if err := ctx.Err(); err != nil { | |
| 163 | 173 | return nil, err | |
| 164 | 174 | } | |
| 175 | + | var log *slog.Logger | |
| 176 | + | if len(logger) > 0 { | |
| 177 | + | log = logger[0] | |
| 178 | + | } | |
| 165 | 179 | spec := BuildCommand(target, session) | |
| 166 | 180 | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 167 | 181 | stdin, err := cmd.StdinPipe() | |
| @@ -175,8 +189,11 @@ func DialContext(ctx context.Context, target, session string) (ports.Transport, | |||
| 175 | 189 | var stderr bytes.Buffer | |
| 176 | 190 | cmd.Stderr = &stderr | |
| 177 | 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 | 195 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 179 | 196 | } | |
| 180 | 197 | ||
| 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 | |
| 182 | 199 | } | |
| @@ -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 a | 167 | // 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, err | 173 | 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.Buffer | 189 | var stderr bytes.Buffer | ||
| 176 | cmd.Stderr = &stderr | 190 | 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)), nil | 198 | + | 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "log/slog" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "strings" | |
| 10 | 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 | 158 | func TestProcessCloser(t *testing.T) { | |
| 129 | 159 | tests := []struct { | |
| 130 | 160 | name string | |
| @@ -161,7 +191,7 @@ func TestProcessCloser(t *testing.T) { | |||
| 161 | 191 | } | |
| 162 | 192 | ||
| 163 | 193 | started := time.Now() | |
| 164 | - | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)() | |
| 194 | + | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")() | |
| 165 | 195 | elapsed := time.Since(started) | |
| 166 | 196 | ||
| 167 | 197 | 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 string | 160 | 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 ( | |||
| 29 | 29 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 30 | 30 | "github.com/bnema/vev/internal/adapters/term" | |
| 31 | 31 | "github.com/bnema/vev/internal/domain" | |
| 32 | + | "github.com/bnema/vev/internal/logging" | |
| 32 | 33 | "github.com/bnema/vev/internal/persist" | |
| 33 | 34 | "github.com/bnema/vev/internal/platform" | |
| 34 | 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 | 227 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 217 | 228 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 218 | 229 | // constructs the daemon, and serves until the last session exits or a | |
| 219 | 230 | // termination signal arrives (graceful shutdown notifies attached clients). | |
| 220 | 231 | func runDaemon() error { | |
| 221 | - | logFile, err := setupLogging() | |
| 232 | + | log, logCloser, err := configureLogging(logging.Daemon, true) | |
| 222 | 233 | if err != nil { | |
| 223 | 234 | return err | |
| 224 | 235 | } | |
| 225 | - | defer func() { _ = logFile.Close() }() | |
| 236 | + | defer func() { _ = logCloser.Close() }() | |
| 226 | 237 | ||
| 227 | 238 | ln, err := ipc.Listen(ipc.SocketDir()) | |
| 228 | 239 | if err != nil { | |
| 240 | + | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |
| 229 | 241 | return fmt.Errorf("vev: daemon listen: %w", err) | |
| 230 | 242 | } | |
| 231 | 243 | defer func() { _ = ln.Close() }() | |
| @@ -236,25 +248,27 @@ func runDaemon() error { | |||
| 236 | 248 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |
| 237 | 249 | go func() { | |
| 238 | 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 | 258 | 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) | |
| 249 | 262 | } else { | |
| 263 | + | log.Info("session persistence enabled", "path", storePath) | |
| 250 | 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 | 267 | if err := d.Serve(ctx, ln); err != nil { | |
| 254 | - | slog.Error("daemon exited", "err", err) | |
| 268 | + | log.Error("daemon exited", "err", err) | |
| 255 | 269 | return err | |
| 256 | 270 | } | |
| 257 | - | slog.Info("daemon exited cleanly") | |
| 271 | + | log.Info("daemon exited cleanly") | |
| 258 | 272 | return nil | |
| 259 | 273 | } | |
| 260 | 274 | ||
| @@ -262,13 +276,13 @@ func runDaemon() error { | |||
| 262 | 276 | // attach loop. Logging goes to the shared file: the client must never write | |
| 263 | 277 | // to the console while the terminal is raw. | |
| 264 | 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 | 280 | if err != nil { | |
| 267 | 281 | 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 | 286 | localDialer: defaultLocalDialer, | |
| 273 | 287 | remoteDialer: defaultRemoteDialer, | |
| 274 | 288 | runClient: client.Run, | |
| @@ -279,15 +293,15 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 279 | 293 | ||
| 280 | 294 | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |
| 281 | 295 | ||
| 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) | |
| 284 | 298 | } | |
| 285 | 299 | ||
| 286 | 300 | 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 | |
| 288 | 302 | 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 | |
| 291 | 305 | createDetached func(context.Context, string) error | |
| 292 | 306 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 293 | 307 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| @@ -296,7 +310,7 @@ type runAttachDeps struct { | |||
| 296 | 310 | 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 | 314 | if activeSession != "" { | |
| 301 | 315 | if remoteTarget == "" && intent == ports.IntentNew { | |
| 302 | 316 | return deps.createDetached(ctx, name) | |
| @@ -313,7 +327,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 313 | 327 | if remoteDialer == nil { | |
| 314 | 328 | 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 | 336 | attachLocal := deps.attachLocal | |
| @@ -322,13 +339,18 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 322 | 339 | if localDialer == nil { | |
| 323 | 340 | localDialer = defaultLocalDialer | |
| 324 | 341 | } | |
| 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) | |
| 327 | 347 | } | |
| 328 | 348 | } | |
| 329 | 349 | 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 | + | }, | |
| 332 | 354 | killDaemon: requestDaemonStop, | |
| 333 | 355 | settleAfterKill: waitForDaemonStop, | |
| 334 | 356 | }) | |
| @@ -496,6 +518,13 @@ func requestDaemonStop(ctx context.Context) error { | |||
| 496 | 518 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |
| 497 | 519 | // framed protocol between process stdio and the daemon socket. | |
| 498 | 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 | 528 | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 500 | 529 | if err != nil { | |
| 501 | 530 | return err | |
| @@ -503,13 +532,20 @@ func runStdio(ctx context.Context) error { | |||
| 503 | 532 | defer func() { _ = transport.Close() }() | |
| 504 | 533 | ||
| 505 | 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 | 538 | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | |
| 510 | 539 | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | |
| 511 | 540 | // random session key to stdout so callers can fall back to _stdio if setup fails. | |
| 512 | 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 | 549 | select { | |
| 514 | 550 | case <-ctx.Done(): | |
| 515 | 551 | return ctx.Err() | |
| @@ -549,7 +585,7 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||
| 549 | 585 | if session != "" { | |
| 550 | 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 | 591 | type preHelloNameTransport struct { | |
| @@ -573,7 +609,7 @@ func (t preHelloNameTransport) Recv() (ports.Frame, error) { | |||
| 573 | 609 | 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 | 613 | errCh := make(chan error, 2) | |
| 578 | 614 | copyFrames := func(dst, src ports.Transport) { | |
| 579 | 615 | for { | |
| @@ -593,11 +629,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |||
| 593 | 629 | ||
| 594 | 630 | select { | |
| 595 | 631 | case <-ctx.Done(): | |
| 632 | + | if log != nil { | |
| 633 | + | log.Info("stdio proxy stopped by context", "err", ctx.Err()) | |
| 634 | + | } | |
| 596 | 635 | return ctx.Err() | |
| 597 | 636 | case err := <-errCh: | |
| 598 | 637 | if errors.Is(err, io.EOF) { | |
| 638 | + | if log != nil { | |
| 639 | + | log.Info("stdio proxy reached clean EOF") | |
| 640 | + | } | |
| 599 | 641 | return nil | |
| 600 | 642 | } | |
| 643 | + | if log != nil { | |
| 644 | + | log.Error("stdio proxy copy failed", "err", err) | |
| 645 | + | } | |
| 601 | 646 | return err | |
| 602 | 647 | } | |
| 603 | 648 | } | |
| @@ -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 a | 229 | // 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 err | 234 | 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 err | 269 | return err | ||
| 256 | } | 270 | } | ||
| 257 | - | slog.Info("daemon exited cleanly") | 271 | + | log.Info("daemon exited cleanly") |
| 258 | return nil | 272 | 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 write | 276 | // 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 err | 281 | 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 tests | 301 | + | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests |
| 288 | localDialer func() ports.Dialer | 302 | localDialer func() ports.Dialer | ||
| 289 | - | remoteDialer func(target, session string) ports.Dialer | 303 | + | 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) error | 304 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error |
| 291 | createDetached func(context.Context, string) error | 305 | createDetached func(context.Context, string) error | ||
| 292 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | 306 | // 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.ClipboardReader | 310 | 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 = defaultRemoteDialer | 328 | 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.attachLocal | 336 | 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 = defaultLocalDialer | 340 | 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 the | 518 | // 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 err | 530 | 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 to | 538 | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | ||
| 510 | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | 539 | // 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, nil | 609 | 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 nil | 641 | return nil | ||
| 600 | } | 642 | } | ||
| 643 | + | if log != nil { | |||
| 644 | + | log.Error("stdio proxy copy failed", "err", err) | |||
| 645 | + | } | |||
| 601 | return err | 646 | return err | ||
| 602 | } | 647 | } | ||
| 603 | } | 648 | } | ||
M internal/app/run_test.go +10 -9
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "log/slog" | |
| 8 | 9 | "os" | |
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "strings" | |
| @@ -346,8 +347,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||
| 346 | 347 | ||
| 347 | 348 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 348 | 349 | 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 { | |
| 351 | 352 | called = true | |
| 352 | 353 | return nil | |
| 353 | 354 | }, | |
| @@ -369,8 +370,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |||
| 369 | 370 | ||
| 370 | 371 | func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |
| 371 | 372 | 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 { | |
| 374 | 375 | t.Fatal("nested new should not attach to the session") | |
| 375 | 376 | return nil | |
| 376 | 377 | }, | |
| @@ -403,13 +404,13 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||
| 403 | 404 | var gotRemote bool | |
| 404 | 405 | var gotClipboard ports.ClipboardReader | |
| 405 | 406 | 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 { | |
| 408 | 409 | gotTarget, gotSession = target, session | |
| 409 | 410 | return namedDialer{name: "remote"} | |
| 410 | 411 | }, | |
| 411 | 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 | 414 | gotDialer = d.(namedDialer).name | |
| 414 | 415 | gotRemote = remote | |
| 415 | 416 | gotClipboard = clipboard | |
| @@ -437,10 +438,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 437 | 438 | var gotDialer string | |
| 438 | 439 | gotRemote := true | |
| 439 | 440 | gotClipboard := ports.ClipboardReader(&fakeClipboardReader{}) | |
| 440 | - | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | |
| 441 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{ | |
| 441 | 442 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 442 | 443 | 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 | 445 | gotDialer = d.(namedDialer).name | |
| 445 | 446 | gotRemote = remote | |
| 446 | 447 | 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 := false | 349 | 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 = true | 352 | called = true | ||
| 352 | return nil | 353 | 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 string | 372 | 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 nil | 376 | return nil | ||
| 376 | }, | 377 | }, | ||
| @@ -403,13 +404,13 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||||
| 403 | var gotRemote bool | 404 | var gotRemote bool | ||
| 404 | var gotClipboard ports.ClipboardReader | 405 | 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, session | 409 | 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).name | 414 | gotDialer = d.(namedDialer).name | ||
| 414 | gotRemote = remote | 415 | gotRemote = remote | ||
| 415 | gotClipboard = clipboard | 416 | gotClipboard = clipboard | ||
| @@ -437,10 +438,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 437 | var gotDialer string | 438 | var gotDialer string | ||
| 438 | gotRemote := true | 439 | 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 attach | 443 | 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).name | 445 | gotDialer = d.(namedDialer).name | ||
| 445 | gotRemote = remote | 446 | gotRemote = remote | ||
| 446 | gotClipboard = clipboard | 447 | gotClipboard = clipboard | ||
M internal/app/spawn.go +10 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "path/filepath" | |
| @@ -57,6 +58,7 @@ var defaultBackoff = backoffConfig{ | |||
| 57 | 58 | // backoff until the socket is live or the budget expires. | |
| 58 | 59 | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | |
| 59 | 60 | if t, err := dial(dir); err == nil { | |
| 61 | + | slog.Debug("daemon already reachable", "socket_dir", dir) | |
| 60 | 62 | return t, nil | |
| 61 | 63 | } | |
| 62 | 64 | ||
| @@ -69,9 +71,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun | |||
| 69 | 71 | // dialable (or spawn fails) so late-arriving clients keep waiting | |
| 70 | 72 | // rather than spawning a second daemon. | |
| 71 | 73 | defer release() | |
| 74 | + | slog.Info("spawning daemon", "socket_dir", dir) | |
| 72 | 75 | if err := spawn(); err != nil { | |
| 76 | + | slog.Error("daemon spawn failed", "err", err) | |
| 73 | 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 | 83 | return retryDial(ctx, dir, dial, cfg) | |
| @@ -95,6 +101,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |||
| 95 | 101 | ||
| 96 | 102 | // Lock exists: take it over only if it looks abandoned. | |
| 97 | 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 | 106 | _ = os.Remove(path) | |
| 99 | 107 | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |
| 100 | 108 | return func() { _ = os.Remove(path) }, true, nil | |
| @@ -115,6 +123,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig | |||
| 115 | 123 | return t, nil | |
| 116 | 124 | } | |
| 117 | 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 | 127 | return nil, ErrDaemonUnreachable | |
| 119 | 128 | } | |
| 120 | 129 | select { | |
| @@ -153,6 +162,7 @@ func realSpawn() error { | |||
| 153 | 162 | if err := cmd.Start(); err != nil { | |
| 154 | 163 | return err | |
| 155 | 164 | } | |
| 165 | + | slog.Info("daemon process started", "pid", cmd.Process.Pid) | |
| 156 | 166 | // Detach: we neither wait for nor signal the daemon after launch. | |
| 157 | 167 | return cmd.Process.Release() | |
| 158 | 168 | } | |
| @@ -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, nil | 62 | 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 waiting | 71 | // 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, nil | 108 | 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, nil | 123 | 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, ErrDaemonUnreachable | 127 | 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 err | 163 | 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 ( | |||
| 126 | 126 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | |
| 127 | 127 | // 0x16 untouched so the locally running agent's own clipboard handling | |
| 128 | 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) { | |
| 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 | + | } | |
| 131 | 133 | ms := milestones{} | |
| 132 | 134 | ||
| 133 | 135 | defer func() { | |
| @@ -186,6 +188,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 186 | 188 | if resumeToken == 0 || ctx.Err() != nil { | |
| 187 | 189 | return err | |
| 188 | 190 | } | |
| 191 | + | log.Warn("reconnect dial failed", "err", err, "backoff", backoff) | |
| 189 | 192 | drawStatus() | |
| 190 | 193 | if !reconnectSleep(ctx, backoff) { | |
| 191 | 194 | return ctx.Err() | |
| @@ -196,7 +199,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 196 | 199 | } | |
| 197 | 200 | 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 | 203 | if result.welcomed { | |
| 201 | 204 | backoff = defaultReconnectBackoff.initial | |
| 202 | 205 | } | |
| @@ -212,6 +215,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 212 | 215 | clearStatus() | |
| 213 | 216 | return result.err | |
| 214 | 217 | } | |
| 218 | + | log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff) | |
| 215 | 219 | drawStatus() | |
| 216 | 220 | if !reconnectSleep(ctx, backoff) { | |
| 217 | 221 | clearStatus() | |
| @@ -258,7 +262,7 @@ func shouldReconnect(err error) bool { | |||
| 258 | 262 | // | |
| 259 | 263 | // It is kept as a compatibility wrapper for callers that still own dialing. | |
| 260 | 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 | 268 | type singleTransportDialer struct{ transport ports.Transport } | |
| @@ -273,7 +277,7 @@ type attachResult struct { | |||
| 273 | 277 | 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 | 281 | // 1. Handshake: send Hello with our size and TERM. | |
| 278 | 282 | size, err := term.Size() | |
| 279 | 283 | if err != nil { | |
| @@ -313,6 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 313 | 317 | } | |
| 314 | 318 | resumeToken = welcome.ResumeToken | |
| 315 | 319 | ms.welcomed = true | |
| 320 | + | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | |
| 316 | 321 | case ports.MsgError: | |
| 317 | 322 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | |
| 318 | 323 | if derr != nil { | |
| @@ -342,13 +347,13 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 342 | 347 | if remote { | |
| 343 | 348 | clip = clipboard | |
| 344 | 349 | } | |
| 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) | |
| 348 | 353 | ||
| 349 | 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| 350 | 355 | recvCh := make(chan recvResult, 1) | |
| 351 | - | go runRecv(loopCtx, transport, recvCh) | |
| 356 | + | go runRecv(loopCtx, transport, recvCh, log) | |
| 352 | 357 | ||
| 353 | 358 | for { | |
| 354 | 359 | select { | |
| @@ -380,13 +385,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 380 | 385 | if ferr := term.Flush(); ferr != nil { | |
| 381 | 386 | return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)} | |
| 382 | 387 | } | |
| 383 | - | ms.firstOutput = true | |
| 388 | + | if !ms.firstOutput { | |
| 389 | + | ms.firstOutput = true | |
| 390 | + | log.Debug("received first output") | |
| 391 | + | } | |
| 384 | 392 | case ports.MsgDetached: | |
| 385 | 393 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | |
| 386 | 394 | if derr != nil { | |
| 387 | 395 | return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)} | |
| 388 | 396 | } | |
| 389 | 397 | 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 | 403 | return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)} | |
| 391 | 404 | case ports.MsgPong: | |
| 392 | 405 | // Liveness reply; nothing to do. | |
| @@ -407,7 +420,8 @@ type recvResult struct { | |||
| 407 | 420 | ||
| 408 | 421 | // runRecv reads frames until an error, forwarding each to the main loop. | |
| 409 | 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 | 425 | for { | |
| 412 | 426 | f, err := transport.Recv() | |
| 413 | 427 | select { | |
| @@ -424,7 +438,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||
| 424 | 438 | // runSender is the sole caller of transport.Send: serialising the input and | |
| 425 | 439 | // resize pumps through one goroutine keeps the transport's single-writer | |
| 426 | 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 | 443 | for { | |
| 429 | 444 | select { | |
| 430 | 445 | case f := <-in: | |
| @@ -457,7 +472,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 457 | 472 | // exits. That is harmless here — Attach has already returned and restored | |
| 458 | 473 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 459 | 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 | 477 | buf := make([]byte, stdinBufSize) | |
| 462 | 478 | var scanner theme.Scanner | |
| 463 | 479 | var inputSeq uint64 | |
| @@ -493,7 +509,7 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 493 | 509 | ci := &clipboardIntercept{ | |
| 494 | 510 | coalescer: coalescer, | |
| 495 | 511 | reader: clipboard, | |
| 496 | - | log: slog.Default(), | |
| 512 | + | log: log, | |
| 497 | 513 | sendImage: func(mime string, data []byte) { | |
| 498 | 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 | 553 | // runResize forwards coalesced terminal resize events to the daemon. It | |
| 538 | 554 | // tolerates an already-closed resize channel (which the terminal adapter | |
| 539 | 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 | 558 | for { | |
| 542 | 559 | select { | |
| 543 | 560 | case sz, ok := <-events: | |
| @@ -126,8 +126,10 @@ const ( | |||||
| 126 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | 126 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | ||
| 127 | // 0x16 untouched so the locally running agent's own clipboard handling | 127 | // 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 err | 189 | 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 = true | 200 | 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.initial | 204 | 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.err | 216 | 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 error | 277 | 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.ResumeToken | 318 | resumeToken = welcome.ResumeToken | ||
| 315 | ms.welcomed = true | 319 | 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 = clipboard | 348 | 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 = true | 388 | + | 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 = true | 397 | 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 and | 438 | // runSender is the sole caller of transport.Send: serialising the input and | ||
| 425 | // resize pumps through one goroutine keeps the transport's single-writer | 439 | // 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 restored | 472 | // exits. That is harmless here — Attach has already returned and restored | ||
| 458 | // the terminal — and matches the standard pattern for stdin pumps; a | 473 | // 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.Scanner | 478 | var scanner theme.Scanner | ||
| 463 | var inputSeq uint64 | 479 | 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. It | 553 | // runResize forwards coalesced terminal resize events to the daemon. It | ||
| 538 | // tolerates an already-closed resize channel (which the terminal adapter | 554 | // 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) { | |||
| 107 | 107 | ||
| 108 | 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 | 135 | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | |
| 111 | 136 | tm := portsmocks.NewMockTerminal(t) | |
| 112 | 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 | 574 | func TestAttachForwardsResize(t *testing.T) { | |
| 490 | 575 | var out bytes.Buffer | |
| 491 | 576 | var restoreCount atomic.Int32 | |
| @@ -565,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 565 | 650 | d := portsmocks.NewMockDialer(t) | |
| 566 | 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 | 654 | require.Error(t, err) | |
| 570 | 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 | 670 | d := portsmocks.NewMockDialer(t) | |
| 586 | 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 | 674 | require.Error(t, err) | |
| 590 | 675 | var pe *client.ProtocolError | |
| 591 | 676 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -597,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 597 | 682 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | |
| 598 | 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 | 686 | require.ErrorIs(t, err, dialErr) | |
| 602 | 687 | } | |
| 603 | 688 | ||
| @@ -686,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 686 | 771 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 687 | 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 | 775 | require.NoError(t, err) | |
| 691 | 776 | require.Equal(t, int32(3), d.calls.Load()) | |
| 692 | 777 | require.Equal(t, int32(1), term.rawCount.Load()) | |
| @@ -713,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 713 | 798 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 714 | 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 | 802 | require.Error(t, err) | |
| 718 | 803 | var de *client.DetachedError | |
| 719 | 804 | 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.Buffer | 575 | var out bytes.Buffer | ||
| 491 | var restoreCount atomic.Int32 | 576 | 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.ProtocolError | 675 | 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.DetachedError | 803 | 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) { | |||
| 85 | 85 | // might be a split-across-reads prefix of) the bracketed-paste opening | |
| 86 | 86 | // marker. It returns len(data) when neither is present. | |
| 87 | 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 | 89 | return idx | |
| 90 | 90 | } | |
| 91 | 91 | 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 opening | 85 | // 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 idx | 89 | 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "io" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -85,7 +86,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 85 | 86 | d := portsmocks.NewMockDialer(t) | |
| 86 | 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 | 90 | require.NoError(t, err) | |
| 90 | 91 | return gotInput, gotImage | |
| 91 | 92 | } | |
| @@ -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, gotImage | 91 | return gotInput, gotImage | ||
| 91 | } | 92 | } | ||
M internal/usecase/client/paste_coalescer.go +17 -17
| @@ -8,14 +8,6 @@ import ( | |||
| 8 | 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 | 11 | const ( | |
| 20 | 12 | // pasteFlushDelay bounds how long a trailing strict prefix of the opening | |
| 21 | 13 | // 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 | 83 | func (c *pasteCoalescer) process(data []byte) { | |
| 92 | 84 | for { | |
| 93 | 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 | 92 | 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 | + | } | |
| 97 | 97 | if idx < 0 { | |
| 98 | 98 | if len(c.buf) > maxPasteBuffer { | |
| 99 | 99 | c.emit(c.buf) | |
| @@ -101,7 +101,7 @@ func (c *pasteCoalescer) process(data []byte) { | |||
| 101 | 101 | } | |
| 102 | 102 | return | |
| 103 | 103 | } | |
| 104 | - | end := idx + len(pasteCloseMarker) | |
| 104 | + | end := idx + len(ports.BracketedPasteCloseMarker) | |
| 105 | 105 | c.emit(c.buf[:end]) | |
| 106 | 106 | rest := append([]byte(nil), c.buf[end:]...) | |
| 107 | 107 | c.resetBuffering() | |
| @@ -113,7 +113,7 @@ func (c *pasteCoalescer) process(data []byte) { | |||
| 113 | 113 | 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 | 117 | if idx > 0 { | |
| 118 | 118 | c.emit(data[:idx]) | |
| 119 | 119 | } | |
| @@ -254,12 +254,12 @@ func (c *pasteCoalescer) stopSafetyTimer() { | |||
| 254 | 254 | // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or | |
| 255 | 255 | // "\x1b[20"). A full marker match is handled by the caller before this runs. | |
| 256 | 256 | 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) | |
| 260 | 260 | } | |
| 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]) { | |
| 263 | 263 | return n | |
| 264 | 264 | } | |
| 265 | 265 | } | |
| @@ -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 opening | 12 | // 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 to | 13 | // 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 = nil | 93 | + | 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 | return | 102 | 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 | return | 113 | 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" or | 254 | // 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) - 1 | 257 | + | 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 n | 263 | 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) { | |||
| 117 | 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 | 132 | func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) { | |
| 121 | 133 | pc, clk, col := newTestCoalescer() | |
| 122 | 134 | 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 { | |||
| 239 | 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 | 254 | func strconvItoa(n int) string { | |
| 243 | 255 | if n == 0 { | |
| 244 | 256 | 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 @@ | |||
| 1 | 1 | package copy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/base64" | |
| 5 | 6 | "strings" | |
| 6 | 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 | 394 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 370 | 395 | t.Helper() | |
| 371 | 396 | s := string(chunk) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package copy | 1 | 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) { | |||
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 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 | 236 | tr, err := ac.sendTransport(f) | |
| 237 | 237 | capture(tr) | |
| 238 | 238 | return err | |
| @@ -249,7 +249,7 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | 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 | 253 | ac.sendMu.Lock() | |
| 254 | 254 | defer ac.sendMu.Unlock() | |
| 255 | 255 | tr := ac.transport() | |
| @@ -263,7 +263,7 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 263 | 263 | ||
| 264 | 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 | 267 | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 268 | 268 | result := make(chan error, 1) | |
| 269 | 269 | var ( | |
| @@ -293,6 +293,7 @@ func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(port | |||
| 293 | 293 | return capturedTransport(), err | |
| 294 | 294 | default: | |
| 295 | 295 | } | |
| 296 | + | d.log.Warn("bounded send timed out; force closing client transport") | |
| 296 | 297 | return capturedTransport(), errSendTimedOut | |
| 297 | 298 | } | |
| 298 | 299 | } | |
| @@ -322,6 +323,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||
| 322 | 323 | defer close(done) | |
| 323 | 324 | d.boundedSend(ac, frameDetached(reason)) | |
| 324 | 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 | 367 | sess.mu.Lock() | |
| 366 | 368 | old := sess.client | |
| 367 | 369 | sess.client = ac | |
| 370 | + | name := sess.name | |
| 368 | 371 | tabs := append([]*tab(nil), sess.tabs...) | |
| 369 | 372 | sess.mu.Unlock() | |
| 373 | + | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 370 | 374 | for _, tb := range tabs { | |
| 371 | 375 | tb.mu.Lock() | |
| 372 | 376 | panes := tb.panesSnapshot() | |
| @@ -417,6 +421,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 417 | 421 | } | |
| 418 | 422 | d.unregisterPreview(old) | |
| 419 | 423 | old.setSession(nil) | |
| 424 | + | d.log.Info("client displaced") | |
| 420 | 425 | // Async + bounded: a dead or wedged old client must not stall the new | |
| 421 | 426 | // client's handshake. | |
| 422 | 427 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| @@ -517,12 +522,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 517 | 522 | if !ephemeral { | |
| 518 | 523 | d.refreshSessionCwd(sess) | |
| 519 | 524 | } | |
| 525 | + | d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral) | |
| 520 | 526 | oldTr := failed | |
| 521 | 527 | if oldTr == nil { | |
| 522 | 528 | oldTr = ac.transport() | |
| 523 | 529 | } | |
| 524 | 530 | if !explicit && d.parkAttachment(sess, ac) { | |
| 525 | 531 | _ = ac.closeCapturedTransport(oldTr) | |
| 532 | + | d.log.Info("client parked", "session", sess.name) | |
| 526 | 533 | return | |
| 527 | 534 | } | |
| 528 | 535 | d.resetScreenDefaultColors(sess) | |
| @@ -534,6 +541,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 534 | 541 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 535 | 542 | } | |
| 536 | 543 | _ = ac.closeCapturedTransport(oldTr) | |
| 544 | + | d.log.Info("client detached", "session", sess.name, "explicit", explicit) | |
| 537 | 545 | if ephemeral { | |
| 538 | 546 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 539 | 547 | } | |
| @@ -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 err | 238 | 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(), err | 293 | return capturedTransport(), err | ||
| 294 | default: | 294 | default: | ||
| 295 | } | 295 | } | ||
| 296 | + | d.log.Warn("bounded send timed out; force closing client transport") | |||
| 296 | return capturedTransport(), errSendTimedOut | 297 | 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.client | 368 | old := sess.client | ||
| 367 | sess.client = ac | 369 | 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 new | 425 | // 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 := failed | 526 | 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 | return | 533 | 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 ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // maxImagePushSize independently caps an accepted MsgImagePush payload, | |
| @@ -114,3 +115,82 @@ func clipboardExt(mime string) string { | |||
| 114 | 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 | + | } | |
| @@ -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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "io" | |
| 4 | 7 | "os" | |
| 5 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 6 | 10 | "testing" | |
| 11 | + | "time" | |
| 7 | 12 | ||
| 13 | + | "github.com/stretchr/testify/mock" | |
| 8 | 14 | "github.com/stretchr/testify/require" | |
| 9 | 15 | ||
| 10 | 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 | 22 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |
| @@ -149,3 +158,179 @@ func TestKillSessionRemovesClipboardTempFiles(t *testing.T) { | |||
| 149 | 158 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 150 | 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 } | |
| @@ -1,13 +1,22 @@ | |||||
| 1 | package daemon | 1 | 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 { | |||
| 257 | 257 | for { | |
| 258 | 258 | tr, err := l.Accept() | |
| 259 | 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 | 265 | break | |
| 261 | 266 | } | |
| 262 | 267 | d.connWg.Go(func() { | |
| @@ -300,6 +305,7 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||
| 300 | 305 | snapshot := d.sessionsSnapshotLocked() | |
| 301 | 306 | empty := len(snapshot) == 0 | |
| 302 | 307 | d.mu.Unlock() | |
| 308 | + | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | |
| 303 | 309 | if empty { | |
| 304 | 310 | d.doneOnce.Do(func() { close(d.done) }) | |
| 305 | 311 | return | |
| @@ -326,6 +332,7 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||
| 326 | 332 | ||
| 327 | 333 | first, err := tr.Recv() | |
| 328 | 334 | if err != nil { | |
| 335 | + | d.log.Warn("connection closed before hello", "err", err) | |
| 329 | 336 | _ = tr.Close() | |
| 330 | 337 | return | |
| 331 | 338 | } | |
| @@ -337,6 +344,7 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||
| 337 | 344 | case ports.MsgHello: | |
| 338 | 345 | d.handleHello(tr, first) | |
| 339 | 346 | default: | |
| 347 | + | d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type) | |
| 340 | 348 | _ = tr.Send(frameError(ports.ErrInternal, "expected hello")) | |
| 341 | 349 | _ = tr.Close() | |
| 342 | 350 | } | |
| @@ -428,14 +436,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 428 | 436 | h, err := ports.UnmarshalHello(f.Payload) | |
| 429 | 437 | if err != nil { | |
| 430 | 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 | 440 | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |
| 432 | 441 | } else { | |
| 442 | + | d.log.Warn("hello rejected", "err", err) | |
| 433 | 443 | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | |
| 434 | 444 | } | |
| 435 | 445 | _ = tr.Close() | |
| 436 | 446 | return | |
| 437 | 447 | } | |
| 438 | 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 | 450 | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |
| 440 | 451 | _ = tr.Close() | |
| 441 | 452 | return | |
| @@ -443,6 +454,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 443 | 454 | ||
| 444 | 455 | sess, ac, rerr := d.route(h, tr) | |
| 445 | 456 | if rerr != nil { | |
| 457 | + | d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name) | |
| 446 | 458 | if pe, ok := errors.AsType[*protoErr](rerr); ok { | |
| 447 | 459 | _ = tr.Send(frameError(pe.code, pe.text)) | |
| 448 | 460 | } 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 | break | 265 | 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) == 0 | 306 | 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 | return | 311 | 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 | return | 337 | 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 | return | 446 | 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 | return | 452 | 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( | |||
| 61 | 61 | ||
| 62 | 62 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |
| 63 | 63 | if err != nil { | |
| 64 | + | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 64 | 65 | tb.mu.Lock() | |
| 65 | 66 | _ = tb.tree.Close(newID) | |
| 66 | 67 | tb.tree.Focus = oldFocus | |
| @@ -236,6 +237,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 236 | 237 | if p.pty != nil { | |
| 237 | 238 | _ = p.pty.Close() | |
| 238 | 239 | } | |
| 240 | + | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 239 | 241 | if repaint { | |
| 240 | 242 | if ac == nil { | |
| 241 | 243 | 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 = oldFocus | 67 | 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) { | |||
| 54 | 54 | } | |
| 55 | 55 | buf := make([]byte, ptyReadBufSize) | |
| 56 | 56 | var resp []byte | |
| 57 | + | var clipboards []string | |
| 57 | 58 | attentionCh := make(chan struct{}, 1) | |
| 58 | 59 | p.mu.Lock() | |
| 59 | 60 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 60 | 61 | p.screen.OnBell = func() { signal(attentionCh) } | |
| 61 | 62 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 63 | + | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |
| 62 | 64 | p.mu.Unlock() | |
| 63 | 65 | for { | |
| 64 | 66 | n, err := p.pty.Read(buf) | |
| @@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 74 | 76 | d.noteAttention(sess, tb) | |
| 75 | 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 | 85 | if len(resp) > 0 { | |
| 78 | 86 | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |
| 79 | 87 | 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 []byte | 56 | 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 { | |||
| 43 | 43 | if ac.resumeToken == 0 { | |
| 44 | 44 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 45 | 45 | } | |
| 46 | + | d.log.Info("parking client prepared", "session", sess.name) | |
| 46 | 47 | return true | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -65,6 +66,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 65 | 66 | ac.parked = true | |
| 66 | 67 | d.parked[token] = parked | |
| 67 | 68 | d.mu.Unlock() | |
| 69 | + | d.log.Info("client parked for resume", "session", sess.name, "grace", resumeParkGrace) | |
| 68 | 70 | ||
| 69 | 71 | go func(token uint64, parked *parkedAttachment) { | |
| 70 | 72 | select { | |
| @@ -80,6 +82,9 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||
| 80 | 82 | d.mu.Lock() | |
| 81 | 83 | if d.parked[token] == parked { | |
| 82 | 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 | 89 | d.mu.Unlock() | |
| 85 | 90 | } | |
| @@ -126,10 +131,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 126 | 131 | sess.mu.Unlock() | |
| 127 | 132 | if !registered || active { | |
| 128 | 133 | d.removeParkedLocked(h.ResumeToken, parked) | |
| 134 | + | d.log.Warn("resume rejected", "session", sess.name, "registered", registered, "active", active) | |
| 129 | 135 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 130 | 136 | } | |
| 131 | 137 | ac := parked.ac | |
| 132 | 138 | if ac.clientID != h.ClientID { | |
| 139 | + | d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch") | |
| 133 | 140 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 134 | 141 | } | |
| 135 | 142 | delete(d.parked, h.ResumeToken) | |
| @@ -143,5 +150,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 143 | 150 | sess.mu.Lock() | |
| 144 | 151 | sess.client = ac | |
| 145 | 152 | sess.mu.Unlock() | |
| 153 | + | d.log.Info("client resumed", "session", sess.name) | |
| 146 | 154 | return sess, ac, true, nil | |
| 147 | 155 | } | |
| @@ -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 true | 47 | return true | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -65,6 +66,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||||
| 65 | ac.parked = true | 66 | ac.parked = true | ||
| 66 | d.parked[token] = parked | 67 | 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.ac | 137 | 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 = ac | 151 | 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, nil | 154 | return sess, ac, true, nil | ||
| 147 | } | 155 | } | ||
M internal/usecase/daemon/session.go +23 -6
| @@ -48,12 +48,14 @@ type session struct { | |||
| 48 | 48 | ctx context.Context | |
| 49 | 49 | cancel context.CancelFunc | |
| 50 | 50 | ||
| 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 | |
| 57 | 59 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 58 | 60 | // clipboard.go) written for this session, removed best-effort in | |
| 59 | 61 | // killSession. | |
| @@ -88,6 +90,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 88 | 90 | tbSize := tabSize(sz) | |
| 89 | 91 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 90 | 92 | if err != nil { | |
| 93 | + | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | |
| 91 | 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 | 120 | delete(d.stopped, name) | |
| 118 | 121 | } | |
| 119 | 122 | 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 | 125 | d.startTabGoroutines(sess, tb) | |
| 121 | 126 | return sess, nil | |
| 122 | 127 | } | |
| @@ -164,6 +169,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 164 | 169 | newSess.client = ac | |
| 165 | 170 | newSess.mu.Unlock() | |
| 166 | 171 | ac.setSession(newSess) | |
| 172 | + | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 167 | 173 | d.mu.Unlock() | |
| 168 | 174 | ||
| 169 | 175 | d.firstPaint(newSess, ac, sz) | |
| @@ -179,6 +185,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 179 | 185 | sess.mu.Unlock() | |
| 180 | 186 | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 181 | 187 | if err != nil { | |
| 188 | + | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 182 | 189 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| 183 | 190 | } | |
| 184 | 191 | tb := newTab(pty, tbSize) | |
| @@ -206,7 +213,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 206 | 213 | sess.mu.Lock() | |
| 207 | 214 | sess.tabs = append(sess.tabs, tb) | |
| 208 | 215 | sess.active = len(sess.tabs) - 1 | |
| 216 | + | tabIndex := sess.active | |
| 209 | 217 | sess.mu.Unlock() | |
| 218 | + | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| 210 | 219 | d.startTabGoroutines(sess, tb) | |
| 211 | 220 | d.mu.Unlock() | |
| 212 | 221 | return nil | |
| @@ -273,6 +282,9 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |||
| 273 | 282 | } | |
| 274 | 283 | ||
| 275 | 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 | 288 | d.sessWg.Add(2) | |
| 277 | 289 | go d.ptyReader(sess, tb, p) | |
| 278 | 290 | go d.scheduler(sess, tb, p) | |
| @@ -376,7 +388,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 376 | 388 | return | |
| 377 | 389 | } | |
| 378 | 390 | if len(sess.tabs) == 1 { | |
| 391 | + | name := sess.name | |
| 379 | 392 | sess.mu.Unlock() | |
| 393 | + | d.log.Info("tab closed", "session", name, "last", true) | |
| 380 | 394 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 381 | 395 | return | |
| 382 | 396 | } | |
| @@ -388,7 +402,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 388 | 402 | sess.active-- | |
| 389 | 403 | } | |
| 390 | 404 | ac := sess.client | |
| 405 | + | name := sess.name | |
| 391 | 406 | sess.mu.Unlock() | |
| 407 | + | d.log.Info("tab closed", "session", name) | |
| 392 | 408 | ||
| 393 | 409 | d.clearDestroyedTabPreview(tb) | |
| 394 | 410 | if tb.cancel != nil { | |
| @@ -439,6 +455,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 439 | 455 | d.closing = true | |
| 440 | 456 | } | |
| 441 | 457 | d.mu.Unlock() | |
| 458 | + | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 442 | 459 | ||
| 443 | 460 | var purgeErr error | |
| 444 | 461 | if !ephemeral && purge { | |
| @@ -48,12 +48,14 @@ type session struct { | |||||
| 48 | ctx context.Context | 48 | ctx context.Context | ||
| 49 | cancel context.CancelFunc | 49 | cancel context.CancelFunc | ||
| 50 | 50 | ||||
| 51 | - | mu sync.Mutex // guards tabs, active, client, and clipFiles | 51 | + | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state |
| 52 | - | tabs []*tab | 52 | + | tabs []*tab |
| 53 | - | active int | 53 | + | active int |
| 54 | - | client *attachedClient | 54 | + | client *attachedClient |
| 55 | - | cwd string | 55 | + | clipboardQueue []clipboardForward |
| 56 | - | createdAt int64 | 56 | + | clipboardWorkerRunning bool |
| 57 | + | cwd string | |||
| 58 | + | createdAt int64 | |||
| 57 | // clipFiles records clipboard-image-transfer temp file paths (see | 59 | // clipFiles records clipboard-image-transfer temp file paths (see | ||
| 58 | // clipboard.go) written for this session, removed best-effort in | 60 | // 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] = sess | 122 | 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, nil | 126 | return sess, nil | ||
| 122 | } | 127 | } | ||
| @@ -164,6 +169,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 164 | newSess.client = ac | 169 | 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) - 1 | 215 | 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 nil | 221 | 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 | return | 388 | 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 | return | 395 | 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.client | 404 | 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 = true | 455 | 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 error | 460 | var purgeErr error | ||
| 444 | if !ephemeral && purge { | 461 | if !ephemeral && purge { | ||
M internal/usecase/keys/router.go +3 -10
| @@ -17,13 +17,6 @@ const ( | |||
| 17 | 17 | 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 | 20 | // Action is an intercepted vev key binding. | |
| 28 | 21 | type Action int | |
| 29 | 22 | ||
| @@ -112,7 +105,7 @@ func (r *Router) route(data []byte) { | |||
| 112 | 105 | return | |
| 113 | 106 | } | |
| 114 | 107 | remaining := data[i+1:] | |
| 115 | - | if bytes.HasPrefix(data[i:], pasteOpenMarker) { | |
| 108 | + | if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) { | |
| 116 | 109 | // A bracketed paste whose closing marker is in this same frame is | |
| 117 | 110 | // forwarded verbatim: pasted content bytes (including embedded | |
| 118 | 111 | // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never | |
| @@ -120,8 +113,8 @@ func (r *Router) route(data []byte) { | |||
| 120 | 113 | // fall through to today's per-byte routing (the '[' passes through | |
| 121 | 114 | // as a control prefix); Part A keeps pastes single-frame in | |
| 122 | 115 | // 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) | |
| 125 | 118 | buf = append(buf, data[i:end]...) | |
| 126 | 119 | i = end | |
| 127 | 120 | continue | |
| @@ -17,13 +17,6 @@ const ( | |||||
| 17 | ESCDelay = 40 * time.Millisecond | 17 | 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 int | 21 | type Action int | ||
| 29 | 22 | ||||
| @@ -112,7 +105,7 @@ func (r *Router) route(data []byte) { | |||||
| 112 | return | 105 | 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 is | 109 | // A bracketed paste whose closing marker is in this same frame is | ||
| 117 | // forwarded verbatim: pasted content bytes (including embedded | 110 | // forwarded verbatim: pasted content bytes (including embedded | ||
| 118 | // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never | 111 | // 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 through | 113 | // fall through to today's per-byte routing (the '[' passes through | ||
| 121 | // as a control prefix); Part A keeps pastes single-frame in | 114 | // 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 = end | 119 | i = end | ||
| 127 | continue | 120 | continue | ||
M internal/usecase/keys/router_test.go +1 -1
| @@ -506,7 +506,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |||
| 506 | 506 | h := &captureHandler{} | |
| 507 | 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 | 510 | r.Route(paste) | |
| 511 | 511 | ||
| 512 | 512 | 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 { | |||
| 25 | 25 | savedCursor cursorState | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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 | |
| 29 | 33 | ||
| 30 | 34 | type Screen struct { | |
| 31 | 35 | Frame renderer.Frame | |
| @@ -45,8 +49,14 @@ type Screen struct { | |||
| 45 | 49 | OnBell func() | |
| 46 | 50 | // OnNotify is called synchronously from Write for explicit terminal | |
| 47 | 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 | 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 | 61 | defaultFG renderer.RGB | |
| 52 | 62 | defaultBG renderer.RGB | |
| @@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 625 | 635 | } | |
| 626 | 636 | ||
| 627 | 637 | // 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. | |
| 630 | 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 | 646 | if s.OnNotify == nil { | |
| 632 | 647 | 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 | 690 | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 658 | 691 | for i := 2; i < len(data); i++ { | |
| 659 | 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 | 1055 | next := renderer.NewFrame(newW, newH) | |
| 1023 | 1056 | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 1024 | 1057 | if evict != nil { | |
| 1025 | - | for y := 0; y < shift; y++ { | |
| 1058 | + | for y := range shift { | |
| 1026 | 1059 | evict(old.Row(y)) | |
| 1027 | 1060 | } | |
| 1028 | 1061 | } | |
| 1029 | - | for dy := 0; dy < newH; dy++ { | |
| 1062 | + | for dy := range newH { | |
| 1030 | 1063 | sy := dy + shift | |
| 1031 | 1064 | if sy >= old.Height { | |
| 1032 | 1065 | break | |
| @@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 1126 | 1159 | s.clampCursor() | |
| 1127 | 1160 | switch mode { | |
| 1128 | 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 | 1163 | end := s.Frame.Width | |
| 1131 | 1164 | if y == s.Row { | |
| 1132 | 1165 | end = min(s.Col+1, s.Frame.Width) | |
| @@ -25,7 +25,11 @@ type screenState struct { | |||||
| 25 | savedCursor cursorState | 25 | savedCursor cursorState | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 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 | |||
| 29 | 33 | ||||
| 30 | type Screen struct { | 34 | type Screen struct { | ||
| 31 | Frame renderer.Frame | 35 | 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 terminal | 50 | // 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.RGB | 61 | defaultFG renderer.RGB | ||
| 52 | defaultBG renderer.RGB | 62 | 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 its | 637 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | ||
| 628 | - | // terminator). Only notification sequences are acted on; titles, clipboard | 638 | + | // 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 | return | 647 | 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 + shift | 1063 | sy := dy + shift | ||
| 1031 | if sy >= old.Height { | 1064 | if sy >= old.Height { | ||
| 1032 | break | 1065 | 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.Width | 1163 | 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) { | |||
| 157 | 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 | 225 | // Printable ASCII / UTF-8 | |
| 162 | 226 | // --------------------------------------------------------------------------- | |
| @@ -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-8 | 225 | // Printable ASCII / UTF-8 | ||
| 162 | // --------------------------------------------------------------------------- | 226 | // --------------------------------------------------------------------------- | ||