fix(client,keys): keep bracketed paste markers intact across input frames
Over a remote attach a multiline bracketed paste reached the pane app line-by-line. The client frames stdin in fixed 4096-byte reads and the daemon keys router retains a trailing ESC for ESCDelay (40ms) so split Alt-key sequences can still be intercepted. When an input-frame boundary landed between a paste marker's ESC and its '[' and the next frame arrived later than the retention window (ssh jitter / terminal chunking), the router forwarded a lone ESC to the pane as its own write. TUIs read a lone-ESC as the Esc key; a broken opening marker meant the app never entered paste mode, so every '\n' submitted a line. The daemon cannot fix the opening-marker case alone: while holding a bare trailing ESC it cannot know '[200~' is coming. Part A (primary, client): add a paste coalescer to the stdin pump so a whole bracketed paste is framed as one MsgInput. It follows the theme.Scanner stdin-scanning precedent, sits between the theme scanner's byte output and the MsgInput framing, and is driven by an injected ports.Clock/ports.Timer (no wall-clock). Outside a paste it passes bytes through; a trailing strict prefix of the opening marker is held briefly (bridging a local kernel read split) and recombined when the rest arrives, or flushed verbatim if it was a real Esc. Once the opening marker completes it buffers the whole paste until the closing marker, with 2 MiB / 2s safety valves that degrade to today's behavior and never drop bytes. The clock is threaded through client.Run / Attach from the app layer. Part B (defense in depth, keys): when the router sees a complete bracketed paste within one frame, forward the whole span (both markers inclusive) verbatim so pasted content — including embedded Alt-binding or CSI lookalikes — can never fire an Action mid-paste. A marker split across frames keeps today's per-byte behavior; Part A makes single-frame pastes the norm.
- commit
- f87757e0279d58740fd1701f8a03c36b4754f4b7
- author
- brice · · 2026-07-04 14:22 UTC
- parent
- 51529aa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +600 -40
M internal/app/run.go +3 -3
| @@ -285,7 +285,7 @@ type runAttachDeps struct { | |||
| 285 | 285 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | |
| 286 | 286 | localDialer func() ports.Dialer | |
| 287 | 287 | remoteDialer func(target, session string) ports.Dialer | |
| 288 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error | |
| 288 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error | |
| 289 | 289 | createDetached func(context.Context, string) error | |
| 290 | 290 | } | |
| 291 | 291 | ||
| @@ -306,7 +306,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 306 | 306 | if remoteDialer == nil { | |
| 307 | 307 | remoteDialer = defaultRemoteDialer | |
| 308 | 308 | } | |
| 309 | - | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name) | |
| 309 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name) | |
| 310 | 310 | } | |
| 311 | 311 | ||
| 312 | 312 | attachLocal := deps.attachLocal | |
| @@ -316,7 +316,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 316 | 316 | localDialer = defaultLocalDialer | |
| 317 | 317 | } | |
| 318 | 318 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | |
| 319 | - | return runClient(ctx, localDialer(), term.New(), intent, name) | |
| 319 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name) | |
| 320 | 320 | } | |
| 321 | 321 | } | |
| 322 | 322 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| @@ -285,7 +285,7 @@ type runAttachDeps struct { | |||||
| 285 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | 285 | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | ||
| 286 | localDialer func() ports.Dialer | 286 | localDialer func() ports.Dialer | ||
| 287 | remoteDialer func(target, session string) ports.Dialer | 287 | remoteDialer func(target, session string) ports.Dialer | ||
| 288 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error | 288 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error |
| 289 | createDetached func(context.Context, string) error | 289 | createDetached func(context.Context, string) error | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| @@ -306,7 +306,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 306 | if remoteDialer == nil { | 306 | if remoteDialer == nil { | ||
| 307 | remoteDialer = defaultRemoteDialer | 307 | remoteDialer = defaultRemoteDialer | ||
| 308 | } | 308 | } | ||
| 309 | - | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name) | 309 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name) |
| 310 | } | 310 | } | ||
| 311 | 311 | ||||
| 312 | attachLocal := deps.attachLocal | 312 | attachLocal := deps.attachLocal | ||
| @@ -316,7 +316,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 316 | localDialer = defaultLocalDialer | 316 | localDialer = defaultLocalDialer | ||
| 317 | } | 317 | } | ||
| 318 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | 318 | attachLocal = func(ctx context.Context, intent uint8, name string) error { | ||
| 319 | - | return runClient(ctx, localDialer(), term.New(), intent, name) | 319 | + | return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name) |
| 320 | } | 320 | } | ||
| 321 | } | 321 | } | ||
| 322 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | 322 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | ||
M internal/app/run_test.go +2 -2
| @@ -400,7 +400,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||
| 400 | 400 | gotTarget, gotSession = target, session | |
| 401 | 401 | return namedDialer{name: "remote"} | |
| 402 | 402 | }, | |
| 403 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |
| 403 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | |
| 404 | 404 | gotDialer = d.(namedDialer).name | |
| 405 | 405 | if intent != ports.IntentAttach || name != "work" { | |
| 406 | 406 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| @@ -420,7 +420,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 420 | 420 | var gotDialer string | |
| 421 | 421 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | |
| 422 | 422 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 423 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |
| 423 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { | |
| 424 | 424 | gotDialer = d.(namedDialer).name | |
| 425 | 425 | if intent != ports.IntentEphemeral || name != "" { | |
| 426 | 426 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | |
| @@ -400,7 +400,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||||
| 400 | gotTarget, gotSession = target, session | 400 | gotTarget, gotSession = target, session | ||
| 401 | return namedDialer{name: "remote"} | 401 | return namedDialer{name: "remote"} | ||
| 402 | }, | 402 | }, | ||
| 403 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | 403 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { |
| 404 | gotDialer = d.(namedDialer).name | 404 | gotDialer = d.(namedDialer).name | ||
| 405 | if intent != ports.IntentAttach || name != "work" { | 405 | if intent != ports.IntentAttach || name != "work" { | ||
| 406 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | 406 | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | ||
| @@ -420,7 +420,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 420 | var gotDialer string | 420 | var gotDialer string | ||
| 421 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | 421 | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | ||
| 422 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | 422 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | ||
| 423 | - | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | 423 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error { |
| 424 | gotDialer = d.(namedDialer).name | 424 | gotDialer = d.(namedDialer).name | ||
| 425 | if intent != ports.IntentEphemeral || name != "" { | 425 | if intent != ports.IntentEphemeral || name != "" { | ||
| 426 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | 426 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | ||
M internal/usecase/client/client.go +27 -21
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "log/slog" | |
| 17 | 17 | "os" | |
| 18 | 18 | "strings" | |
| 19 | + | "sync/atomic" | |
| 19 | 20 | "time" | |
| 20 | 21 | ||
| 21 | 22 | "github.com/bnema/vev/internal/domain" | |
| @@ -118,7 +119,7 @@ const ( | |||
| 118 | 119 | // Run connects via dialer and runs the attach client. It owns the terminal | |
| 119 | 120 | // lifecycle above attach attempts so raw mode remains active while a live | |
| 120 | 121 | // client process redials a lost link. | |
| 121 | - | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) { | |
| 122 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) { | |
| 122 | 123 | log := slog.Default().With("component", "client") | |
| 123 | 124 | ms := milestones{} | |
| 124 | 125 | ||
| @@ -188,7 +189,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||
| 188 | 189 | } | |
| 189 | 190 | ms.dialed = true | |
| 190 | 191 | ||
| 191 | - | result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |
| 192 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |
| 192 | 193 | if result.welcomed { | |
| 193 | 194 | backoff = defaultReconnectBackoff.initial | |
| 194 | 195 | } | |
| @@ -249,8 +250,8 @@ func shouldReconnect(err error) bool { | |||
| 249 | 250 | // disappears, or the context is cancelled. | |
| 250 | 251 | // | |
| 251 | 252 | // It is kept as a compatibility wrapper for callers that still own dialing. | |
| 252 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error { | |
| 253 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name) | |
| 253 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | |
| 254 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name) | |
| 254 | 255 | } | |
| 255 | 256 | ||
| 256 | 257 | type singleTransportDialer struct{ transport ports.Transport } | |
| @@ -265,7 +266,7 @@ type attachResult struct { | |||
| 265 | 266 | err error | |
| 266 | 267 | } | |
| 267 | 268 | ||
| 268 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | |
| 269 | + | 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()) attachResult { | |
| 269 | 270 | // 1. Handshake: send Hello with our size and TERM. | |
| 270 | 271 | size, err := term.Size() | |
| 271 | 272 | if err != nil { | |
| @@ -331,7 +332,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 331 | 332 | sendErrCh := make(chan error, 1) | |
| 332 | 333 | ||
| 333 | 334 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |
| 334 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor) | |
| 335 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor) | |
| 335 | 336 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |
| 336 | 337 | ||
| 337 | 338 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -445,23 +446,35 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 445 | 446 | // exits. That is harmless here — Attach has already returned and restored | |
| 446 | 447 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 447 | 448 | // closable stdin duplicate could lift it later if ever needed. | |
| 448 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | |
| 449 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) { | |
| 449 | 450 | buf := make([]byte, stdinBufSize) | |
| 450 | 451 | var scanner theme.Scanner | |
| 451 | 452 | var inputSeq uint64 | |
| 452 | 453 | current := ports.Theme{TrueColor: trueColor} | |
| 453 | - | send := func(frame ports.Frame) bool { | |
| 454 | + | var sendOK atomic.Bool | |
| 455 | + | sendOK.Store(true) | |
| 456 | + | send := func(frame ports.Frame) { | |
| 454 | 457 | select { | |
| 455 | 458 | case out <- frame: | |
| 456 | - | return true | |
| 457 | 459 | case <-ctx.Done(): | |
| 458 | - | return false | |
| 460 | + | sendOK.Store(false) | |
| 459 | 461 | } | |
| 460 | 462 | } | |
| 463 | + | // The coalescer reframes a bracketed paste split across reads into one | |
| 464 | + | // MsgInput frame, so a marker boundary can never leave a lone ESC on the | |
| 465 | + | // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract. | |
| 466 | + | coalescer := newPasteCoalescer(clk, func(data []byte) { | |
| 467 | + | if len(data) == 0 { | |
| 468 | + | return | |
| 469 | + | } | |
| 470 | + | copyData := append([]byte(nil), data...) | |
| 471 | + | inputSeq++ | |
| 472 | + | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 473 | + | }) | |
| 474 | + | defer coalescer.Close() | |
| 461 | 475 | for { | |
| 462 | 476 | n, rerr := in.Read(buf) | |
| 463 | 477 | if n > 0 { | |
| 464 | - | ok := true | |
| 465 | 478 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 466 | 479 | switch kind { | |
| 467 | 480 | case 10: | |
| @@ -473,16 +486,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 473 | 486 | default: | |
| 474 | 487 | return | |
| 475 | 488 | } | |
| 476 | - | ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 477 | - | }, func(data []byte) { | |
| 478 | - | if len(data) == 0 || !ok { | |
| 479 | - | return | |
| 480 | - | } | |
| 481 | - | copyData := append([]byte(nil), data...) | |
| 482 | - | inputSeq++ | |
| 483 | - | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 484 | - | }) | |
| 485 | - | if !ok { | |
| 489 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 490 | + | }, coalescer.Scan) | |
| 491 | + | if !sendOK.Load() { | |
| 486 | 492 | return | |
| 487 | 493 | } | |
| 488 | 494 | } | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "log/slog" | 16 | "log/slog" | ||
| 17 | "os" | 17 | "os" | ||
| 18 | "strings" | 18 | "strings" | ||
| 19 | + | "sync/atomic" | |||
| 19 | "time" | 20 | "time" | ||
| 20 | 21 | ||||
| 21 | "github.com/bnema/vev/internal/domain" | 22 | "github.com/bnema/vev/internal/domain" | ||
| @@ -118,7 +119,7 @@ const ( | |||||
| 118 | // Run connects via dialer and runs the attach client. It owns the terminal | 119 | // Run connects via dialer and runs the attach client. It owns the terminal | ||
| 119 | // lifecycle above attach attempts so raw mode remains active while a live | 120 | // lifecycle above attach attempts so raw mode remains active while a live | ||
| 120 | // client process redials a lost link. | 121 | // client process redials a lost link. | ||
| 121 | - | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) { | 122 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) { |
| 122 | log := slog.Default().With("component", "client") | 123 | log := slog.Default().With("component", "client") | ||
| 123 | ms := milestones{} | 124 | ms := milestones{} | ||
| 124 | 125 | ||||
| @@ -188,7 +189,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||||
| 188 | } | 189 | } | ||
| 189 | ms.dialed = true | 190 | ms.dialed = true | ||
| 190 | 191 | ||||
| 191 | - | result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | 192 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) |
| 192 | if result.welcomed { | 193 | if result.welcomed { | ||
| 193 | backoff = defaultReconnectBackoff.initial | 194 | backoff = defaultReconnectBackoff.initial | ||
| 194 | } | 195 | } | ||
| @@ -249,8 +250,8 @@ func shouldReconnect(err error) bool { | |||||
| 249 | // disappears, or the context is cancelled. | 250 | // disappears, or the context is cancelled. | ||
| 250 | // | 251 | // | ||
| 251 | // It is kept as a compatibility wrapper for callers that still own dialing. | 252 | // It is kept as a compatibility wrapper for callers that still own dialing. | ||
| 252 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error { | 253 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { |
| 253 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name) | 254 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name) |
| 254 | } | 255 | } | ||
| 255 | 256 | ||||
| 256 | type singleTransportDialer struct{ transport ports.Transport } | 257 | type singleTransportDialer struct{ transport ports.Transport } | ||
| @@ -265,7 +266,7 @@ type attachResult struct { | |||||
| 265 | err error | 266 | err error | ||
| 266 | } | 267 | } | ||
| 267 | 268 | ||||
| 268 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | 269 | + | 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()) attachResult { |
| 269 | // 1. Handshake: send Hello with our size and TERM. | 270 | // 1. Handshake: send Hello with our size and TERM. | ||
| 270 | size, err := term.Size() | 271 | size, err := term.Size() | ||
| 271 | if err != nil { | 272 | if err != nil { | ||
| @@ -331,7 +332,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 331 | sendErrCh := make(chan error, 1) | 332 | sendErrCh := make(chan error, 1) | ||
| 332 | 333 | ||||
| 333 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | 334 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | ||
| 334 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor) | 335 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor) |
| 335 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | 336 | go runResize(loopCtx, term.ResizeEvents(), sendCh) | ||
| 336 | 337 | ||||
| 337 | // 5. Output/main loop: the only goroutine that touches the terminal. | 338 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -445,23 +446,35 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 445 | // exits. That is harmless here — Attach has already returned and restored | 446 | // exits. That is harmless here — Attach has already returned and restored | ||
| 446 | // the terminal — and matches the standard pattern for stdin pumps; a | 447 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 447 | // closable stdin duplicate could lift it later if ever needed. | 448 | // closable stdin duplicate could lift it later if ever needed. | ||
| 448 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | 449 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) { |
| 449 | buf := make([]byte, stdinBufSize) | 450 | buf := make([]byte, stdinBufSize) | ||
| 450 | var scanner theme.Scanner | 451 | var scanner theme.Scanner | ||
| 451 | var inputSeq uint64 | 452 | var inputSeq uint64 | ||
| 452 | current := ports.Theme{TrueColor: trueColor} | 453 | current := ports.Theme{TrueColor: trueColor} | ||
| 453 | - | send := func(frame ports.Frame) bool { | 454 | + | var sendOK atomic.Bool |
| 455 | + | sendOK.Store(true) | |||
| 456 | + | send := func(frame ports.Frame) { | |||
| 454 | select { | 457 | select { | ||
| 455 | case out <- frame: | 458 | case out <- frame: | ||
| 456 | - | return true | |||
| 457 | case <-ctx.Done(): | 459 | case <-ctx.Done(): | ||
| 458 | - | return false | 460 | + | sendOK.Store(false) |
| 459 | } | 461 | } | ||
| 460 | } | 462 | } | ||
| 463 | + | // The coalescer reframes a bracketed paste split across reads into one | |||
| 464 | + | // MsgInput frame, so a marker boundary can never leave a lone ESC on the | |||
| 465 | + | // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract. | |||
| 466 | + | coalescer := newPasteCoalescer(clk, func(data []byte) { | |||
| 467 | + | if len(data) == 0 { | |||
| 468 | + | return | |||
| 469 | + | } | |||
| 470 | + | copyData := append([]byte(nil), data...) | |||
| 471 | + | inputSeq++ | |||
| 472 | + | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |||
| 473 | + | }) | |||
| 474 | + | defer coalescer.Close() | |||
| 461 | for { | 475 | for { | ||
| 462 | n, rerr := in.Read(buf) | 476 | n, rerr := in.Read(buf) | ||
| 463 | if n > 0 { | 477 | if n > 0 { | ||
| 464 | - | ok := true | |||
| 465 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | 478 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | ||
| 466 | switch kind { | 479 | switch kind { | ||
| 467 | case 10: | 480 | case 10: | ||
| @@ -473,16 +486,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 473 | default: | 486 | default: | ||
| 474 | return | 487 | return | ||
| 475 | } | 488 | } | ||
| 476 | - | ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 489 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) |
| 477 | - | }, func(data []byte) { | 490 | + | }, coalescer.Scan) |
| 478 | - | if len(data) == 0 || !ok { | 491 | + | if !sendOK.Load() { |
| 479 | - | return | |||
| 480 | - | } | |||
| 481 | - | copyData := append([]byte(nil), data...) | |||
| 482 | - | inputSeq++ | |||
| 483 | - | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |||
| 484 | - | }) | |||
| 485 | - | if !ok { | |||
| 486 | return | 492 | return | ||
| 487 | } | 493 | } | ||
| 488 | } | 494 | } | ||
M internal/usecase/client/client_test.go +29 -14
| @@ -25,6 +25,21 @@ func init() { | |||
| 25 | 25 | slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | // realClock is a stdlib-backed ports.Clock for tests that exercise the stdin | |
| 29 | + | // pump end to end; the paste coalescer's flush timer runs on wall time here. | |
| 30 | + | type realClock struct{} | |
| 31 | + | ||
| 32 | + | func (realClock) Now() time.Time { return time.Now() } | |
| 33 | + | func (realClock) NewTimer(d time.Duration) ports.Timer { | |
| 34 | + | return &realTimer{t: time.NewTimer(d)} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type realTimer struct{ t *time.Timer } | |
| 38 | + | ||
| 39 | + | func (r *realTimer) C() <-chan time.Time { return r.t.C } | |
| 40 | + | func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) } | |
| 41 | + | func (r *realTimer) Stop() bool { return r.t.Stop() } | |
| 42 | + | ||
| 28 | 43 | // recvItem is one scripted Recv result. | |
| 29 | 44 | type recvItem struct { | |
| 30 | 45 | f ports.Frame | |
| @@ -128,7 +143,7 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 128 | 143 | defer unblock() | |
| 129 | 144 | tr.EXPECT().Close().Return(nil).Once() | |
| 130 | 145 | ||
| 131 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 146 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 132 | 147 | require.NoError(t, err) | |
| 133 | 148 | require.Equal(t, "hello world", out.String()) | |
| 134 | 149 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |
| @@ -147,7 +162,7 @@ func TestAttachVersionMismatch(t *testing.T) { | |||
| 147 | 162 | ).Once() | |
| 148 | 163 | tr.EXPECT().Close().Return(nil).Once() | |
| 149 | 164 | ||
| 150 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main") | |
| 165 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main") | |
| 151 | 166 | require.Error(t, err) | |
| 152 | 167 | var pe *client.ProtocolError | |
| 153 | 168 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -171,7 +186,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 171 | 186 | defer unblock() | |
| 172 | 187 | tr.EXPECT().Close().Return(nil).Once() | |
| 173 | 188 | ||
| 174 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 189 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 175 | 190 | require.Error(t, err) | |
| 176 | 191 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error") | |
| 177 | 192 | } | |
| @@ -192,7 +207,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 192 | 207 | defer unblock() | |
| 193 | 208 | tr.EXPECT().Close().Return(nil).Once() | |
| 194 | 209 | ||
| 195 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 210 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 196 | 211 | require.Error(t, err) | |
| 197 | 212 | require.ErrorIs(t, err, io.EOF) | |
| 198 | 213 | require.Equal(t, int32(1), restoreCount.Load()) | |
| @@ -264,7 +279,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 264 | 279 | }).Maybe() | |
| 265 | 280 | tr.EXPECT().Close().Return(nil).Once() | |
| 266 | 281 | ||
| 267 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 282 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 268 | 283 | require.NoError(t, err) | |
| 269 | 284 | require.Equal(t, int32(1), restoreCount.Load()) | |
| 270 | 285 | ||
| @@ -342,7 +357,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |||
| 342 | 357 | }).Maybe() | |
| 343 | 358 | tr.EXPECT().Close().Return(nil).Once() | |
| 344 | 359 | ||
| 345 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 360 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 346 | 361 | require.NoError(t, err) | |
| 347 | 362 | select { | |
| 348 | 363 | case th := <-gotTheme: | |
| @@ -402,7 +417,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||
| 402 | 417 | }).Maybe() | |
| 403 | 418 | tr.EXPECT().Close().Return(nil).Once() | |
| 404 | 419 | ||
| 405 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 420 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 406 | 421 | require.NoError(t, err) | |
| 407 | 422 | select { | |
| 408 | 423 | case got := <-gotInput: | |
| @@ -461,7 +476,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 461 | 476 | }).Maybe() | |
| 462 | 477 | tr.EXPECT().Close().Return(nil).Once() | |
| 463 | 478 | ||
| 464 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 479 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 465 | 480 | require.NoError(t, err) | |
| 466 | 481 | select { | |
| 467 | 482 | case got := <-gotInput: | |
| @@ -521,7 +536,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 521 | 536 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | |
| 522 | 537 | }() | |
| 523 | 538 | ||
| 524 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | |
| 539 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 525 | 540 | require.NoError(t, err) | |
| 526 | 541 | select { | |
| 527 | 542 | case r := <-gotResize: | |
| @@ -550,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 550 | 565 | d := portsmocks.NewMockDialer(t) | |
| 551 | 566 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 552 | 567 | ||
| 553 | - | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 568 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | |
| 554 | 569 | require.Error(t, err) | |
| 555 | 570 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | |
| 556 | 571 | } | |
| @@ -570,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 570 | 585 | d := portsmocks.NewMockDialer(t) | |
| 571 | 586 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 572 | 587 | ||
| 573 | - | err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main") | |
| 588 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main") | |
| 574 | 589 | require.Error(t, err) | |
| 575 | 590 | var pe *client.ProtocolError | |
| 576 | 591 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -582,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 582 | 597 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | |
| 583 | 598 | tm := portsmocks.NewMockTerminal(t) | |
| 584 | 599 | ||
| 585 | - | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 600 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") | |
| 586 | 601 | require.ErrorIs(t, err, dialErr) | |
| 587 | 602 | } | |
| 588 | 603 | ||
| @@ -671,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 671 | 686 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 672 | 687 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | |
| 673 | 688 | ||
| 674 | - | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |
| 689 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | |
| 675 | 690 | require.NoError(t, err) | |
| 676 | 691 | require.Equal(t, int32(3), d.calls.Load()) | |
| 677 | 692 | require.Equal(t, int32(1), term.rawCount.Load()) | |
| @@ -698,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 698 | 713 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 699 | 714 | d := &sequenceDialer{trs: []ports.Transport{tr}} | |
| 700 | 715 | ||
| 701 | - | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |
| 716 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") | |
| 702 | 717 | require.Error(t, err) | |
| 703 | 718 | var de *client.DetachedError | |
| 704 | 719 | require.True(t, errors.As(err, &de)) | |
| @@ -25,6 +25,21 @@ func init() { | |||||
| 25 | slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) | 25 | slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | + | // realClock is a stdlib-backed ports.Clock for tests that exercise the stdin | |||
| 29 | + | // pump end to end; the paste coalescer's flush timer runs on wall time here. | |||
| 30 | + | type realClock struct{} | |||
| 31 | + | ||||
| 32 | + | func (realClock) Now() time.Time { return time.Now() } | |||
| 33 | + | func (realClock) NewTimer(d time.Duration) ports.Timer { | |||
| 34 | + | return &realTimer{t: time.NewTimer(d)} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type realTimer struct{ t *time.Timer } | |||
| 38 | + | ||||
| 39 | + | func (r *realTimer) C() <-chan time.Time { return r.t.C } | |||
| 40 | + | func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) } | |||
| 41 | + | func (r *realTimer) Stop() bool { return r.t.Stop() } | |||
| 42 | + | ||||
| 28 | // recvItem is one scripted Recv result. | 43 | // recvItem is one scripted Recv result. | ||
| 29 | type recvItem struct { | 44 | type recvItem struct { | ||
| 30 | f ports.Frame | 45 | f ports.Frame | ||
| @@ -128,7 +143,7 @@ func TestAttachHappyPath(t *testing.T) { | |||||
| 128 | defer unblock() | 143 | defer unblock() | ||
| 129 | tr.EXPECT().Close().Return(nil).Once() | 144 | tr.EXPECT().Close().Return(nil).Once() | ||
| 130 | 145 | ||||
| 131 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 146 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 132 | require.NoError(t, err) | 147 | require.NoError(t, err) | ||
| 133 | require.Equal(t, "hello world", out.String()) | 148 | require.Equal(t, "hello world", out.String()) | ||
| 134 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | 149 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | ||
| @@ -147,7 +162,7 @@ func TestAttachVersionMismatch(t *testing.T) { | |||||
| 147 | ).Once() | 162 | ).Once() | ||
| 148 | tr.EXPECT().Close().Return(nil).Once() | 163 | tr.EXPECT().Close().Return(nil).Once() | ||
| 149 | 164 | ||||
| 150 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main") | 165 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main") |
| 151 | require.Error(t, err) | 166 | require.Error(t, err) | ||
| 152 | var pe *client.ProtocolError | 167 | var pe *client.ProtocolError | ||
| 153 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | 168 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | ||
| @@ -171,7 +186,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||||
| 171 | defer unblock() | 186 | defer unblock() | ||
| 172 | tr.EXPECT().Close().Return(nil).Once() | 187 | tr.EXPECT().Close().Return(nil).Once() | ||
| 173 | 188 | ||||
| 174 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 189 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 175 | require.Error(t, err) | 190 | require.Error(t, err) | ||
| 176 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error") | 191 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error") | ||
| 177 | } | 192 | } | ||
| @@ -192,7 +207,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||||
| 192 | defer unblock() | 207 | defer unblock() | ||
| 193 | tr.EXPECT().Close().Return(nil).Once() | 208 | tr.EXPECT().Close().Return(nil).Once() | ||
| 194 | 209 | ||||
| 195 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 210 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 196 | require.Error(t, err) | 211 | require.Error(t, err) | ||
| 197 | require.ErrorIs(t, err, io.EOF) | 212 | require.ErrorIs(t, err, io.EOF) | ||
| 198 | require.Equal(t, int32(1), restoreCount.Load()) | 213 | require.Equal(t, int32(1), restoreCount.Load()) | ||
| @@ -264,7 +279,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||||
| 264 | }).Maybe() | 279 | }).Maybe() | ||
| 265 | tr.EXPECT().Close().Return(nil).Once() | 280 | tr.EXPECT().Close().Return(nil).Once() | ||
| 266 | 281 | ||||
| 267 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 282 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 268 | require.NoError(t, err) | 283 | require.NoError(t, err) | ||
| 269 | require.Equal(t, int32(1), restoreCount.Load()) | 284 | require.Equal(t, int32(1), restoreCount.Load()) | ||
| 270 | 285 | ||||
| @@ -342,7 +357,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |||||
| 342 | }).Maybe() | 357 | }).Maybe() | ||
| 343 | tr.EXPECT().Close().Return(nil).Once() | 358 | tr.EXPECT().Close().Return(nil).Once() | ||
| 344 | 359 | ||||
| 345 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 360 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 346 | require.NoError(t, err) | 361 | require.NoError(t, err) | ||
| 347 | select { | 362 | select { | ||
| 348 | case th := <-gotTheme: | 363 | case th := <-gotTheme: | ||
| @@ -402,7 +417,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||||
| 402 | }).Maybe() | 417 | }).Maybe() | ||
| 403 | tr.EXPECT().Close().Return(nil).Once() | 418 | tr.EXPECT().Close().Return(nil).Once() | ||
| 404 | 419 | ||||
| 405 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 420 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 406 | require.NoError(t, err) | 421 | require.NoError(t, err) | ||
| 407 | select { | 422 | select { | ||
| 408 | case got := <-gotInput: | 423 | case got := <-gotInput: | ||
| @@ -461,7 +476,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||||
| 461 | }).Maybe() | 476 | }).Maybe() | ||
| 462 | tr.EXPECT().Close().Return(nil).Once() | 477 | tr.EXPECT().Close().Return(nil).Once() | ||
| 463 | 478 | ||||
| 464 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 479 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 465 | require.NoError(t, err) | 480 | require.NoError(t, err) | ||
| 466 | select { | 481 | select { | ||
| 467 | case got := <-gotInput: | 482 | case got := <-gotInput: | ||
| @@ -521,7 +536,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 521 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | 536 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | ||
| 522 | }() | 537 | }() | ||
| 523 | 538 | ||||
| 524 | - | err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "") | 539 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 525 | require.NoError(t, err) | 540 | require.NoError(t, err) | ||
| 526 | select { | 541 | select { | ||
| 527 | case r := <-gotResize: | 542 | case r := <-gotResize: | ||
| @@ -550,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||||
| 550 | d := portsmocks.NewMockDialer(t) | 565 | d := portsmocks.NewMockDialer(t) | ||
| 551 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 566 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 552 | 567 | ||||
| 553 | - | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | 568 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") |
| 554 | require.Error(t, err) | 569 | require.Error(t, err) | ||
| 555 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | 570 | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | ||
| 556 | } | 571 | } | ||
| @@ -570,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||||
| 570 | d := portsmocks.NewMockDialer(t) | 585 | d := portsmocks.NewMockDialer(t) | ||
| 571 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | 586 | d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | ||
| 572 | 587 | ||||
| 573 | - | err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main") | 588 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main") |
| 574 | require.Error(t, err) | 589 | require.Error(t, err) | ||
| 575 | var pe *client.ProtocolError | 590 | var pe *client.ProtocolError | ||
| 576 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | 591 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | ||
| @@ -582,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||||
| 582 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | 597 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | ||
| 583 | tm := portsmocks.NewMockTerminal(t) | 598 | tm := portsmocks.NewMockTerminal(t) | ||
| 584 | 599 | ||||
| 585 | - | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | 600 | + | err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "") |
| 586 | require.ErrorIs(t, err, dialErr) | 601 | require.ErrorIs(t, err, dialErr) | ||
| 587 | } | 602 | } | ||
| 588 | 603 | ||||
| @@ -671,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||||
| 671 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | 686 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | ||
| 672 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | 687 | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | ||
| 673 | 688 | ||||
| 674 | - | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | 689 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") |
| 675 | require.NoError(t, err) | 690 | require.NoError(t, err) | ||
| 676 | require.Equal(t, int32(3), d.calls.Load()) | 691 | require.Equal(t, int32(3), d.calls.Load()) | ||
| 677 | require.Equal(t, int32(1), term.rawCount.Load()) | 692 | require.Equal(t, int32(1), term.rawCount.Load()) | ||
| @@ -698,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||||
| 698 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | 713 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | ||
| 699 | d := &sequenceDialer{trs: []ports.Transport{tr}} | 714 | d := &sequenceDialer{trs: []ports.Transport{tr}} | ||
| 700 | 715 | ||||
| 701 | - | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | 716 | + | err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main") |
| 702 | require.Error(t, err) | 717 | require.Error(t, err) | ||
| 703 | var de *client.DetachedError | 718 | var de *client.DetachedError | ||
| 704 | require.True(t, errors.As(err, &de)) | 719 | require.True(t, errors.As(err, &de)) | ||
A internal/usecase/client/paste_coalescer.go +253 -0
| @@ -0,0 +1,253 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 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 ( | |
| 20 | + | // 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 | |
| 22 | + | // bridge a local kernel read split: the rest of the marker is already in | |
| 23 | + | // the tty buffer and arrives on the next Read microseconds later. It is NOT | |
| 24 | + | // meant to bridge a network-scale gap — once the opening marker completes, | |
| 25 | + | // buffering mode holds the whole paste (up to the 2s safety timer), so a | |
| 26 | + | // close-marker split at any realistic latency is covered by whole-paste | |
| 27 | + | // framing rather than this timer. Kept well under the daemon router's | |
| 28 | + | // ESCDelay so a real lone Esc keypress still reaches the pane promptly. | |
| 29 | + | pasteFlushDelay = 20 * time.Millisecond | |
| 30 | + | // pasteSafetyDelay caps how long the coalescer waits for a closing marker | |
| 31 | + | // before flushing a partial paste, so a paste with a lost closing marker | |
| 32 | + | // degrades to today's per-frame behavior instead of stalling input. | |
| 33 | + | pasteSafetyDelay = 2 * time.Second | |
| 34 | + | // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed | |
| 35 | + | // verbatim and normal scanning resumes; input is never dropped. | |
| 36 | + | maxPasteBuffer = 2 << 20 // 2 MiB | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // pasteCoalescer reframes a bracketed paste that arrives split across input | |
| 40 | + | // reads into a single emit call. Over a remote attach, stdin is read in fixed | |
| 41 | + | // chunks and forwarded frame by frame; an input-frame boundary can land inside | |
| 42 | + | // a paste marker (e.g. between the marker's ESC and its '['). When the daemon's | |
| 43 | + | // keys router then sees a lone trailing ESC it retains it for ESCDelay and, if | |
| 44 | + | // the next frame is late, forwards it as a standalone Esc keypress — breaking | |
| 45 | + | // the opening marker so the pane's TUI never enters paste mode. Coalescing the | |
| 46 | + | // whole paste into one emit keeps both markers contiguous on the wire. | |
| 47 | + | // | |
| 48 | + | // It is safe for concurrent use: Scan runs on the stdin pump goroutine while a | |
| 49 | + | // flush/safety timer may fire on its own goroutine; both take the mutex, so | |
| 50 | + | // emit calls are serialised. | |
| 51 | + | type pasteCoalescer struct { | |
| 52 | + | clock ports.Clock | |
| 53 | + | emit func([]byte) | |
| 54 | + | ||
| 55 | + | mu sync.Mutex | |
| 56 | + | buffering bool // inside a paste: accumulating until the closing marker | |
| 57 | + | buf []byte // paste bytes accumulated in buffering mode | |
| 58 | + | pending []byte // held trailing strict prefix of the opening marker | |
| 59 | + | ||
| 60 | + | flushTimer ports.Timer | |
| 61 | + | flushDone chan struct{} | |
| 62 | + | safetyTimer ports.Timer | |
| 63 | + | safetyDone chan struct{} | |
| 64 | + | closed bool | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer { | |
| 68 | + | return &pasteCoalescer{clock: clock, emit: emit} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Scan consumes one run of ordinary input bytes, emitting either pass-through | |
| 72 | + | // bytes immediately or a whole bracketed paste as a single emit call once its | |
| 73 | + | // closing marker is seen. | |
| 74 | + | func (c *pasteCoalescer) Scan(data []byte) { | |
| 75 | + | if len(data) == 0 { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | c.mu.Lock() | |
| 79 | + | defer c.mu.Unlock() | |
| 80 | + | if len(c.pending) > 0 { | |
| 81 | + | // New bytes arrived before the flush timer fired: the held prefix was | |
| 82 | + | // the start of this data, not a lone Esc. Recombine and rescan. | |
| 83 | + | c.stopFlushTimer() | |
| 84 | + | data = append(c.pending, data...) | |
| 85 | + | c.pending = nil | |
| 86 | + | } | |
| 87 | + | c.process(data) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // process runs the scan/buffer state machine with the mutex held. | |
| 91 | + | func (c *pasteCoalescer) process(data []byte) { | |
| 92 | + | for { | |
| 93 | + | if c.buffering { | |
| 94 | + | c.buf = append(c.buf, data...) | |
| 95 | + | data = nil | |
| 96 | + | idx := bytes.Index(c.buf, pasteCloseMarker) | |
| 97 | + | if idx < 0 { | |
| 98 | + | if len(c.buf) > maxPasteBuffer { | |
| 99 | + | c.emit(c.buf) | |
| 100 | + | c.resetBuffering() | |
| 101 | + | } | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | end := idx + len(pasteCloseMarker) | |
| 105 | + | c.emit(c.buf[:end]) | |
| 106 | + | rest := append([]byte(nil), c.buf[end:]...) | |
| 107 | + | c.resetBuffering() | |
| 108 | + | data = rest | |
| 109 | + | // Fall through to scan any bytes trailing the paste. | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if len(data) == 0 { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 { | |
| 117 | + | if idx > 0 { | |
| 118 | + | c.emit(data[:idx]) | |
| 119 | + | } | |
| 120 | + | c.buffering = true | |
| 121 | + | c.startSafetyTimer() | |
| 122 | + | data = data[idx:] // marker onward; re-enter the buffering branch | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | if p := trailingOpenPrefixLen(data); p > 0 { | |
| 127 | + | if len(data) > p { | |
| 128 | + | c.emit(data[:len(data)-p]) | |
| 129 | + | } | |
| 130 | + | c.pending = append([]byte(nil), data[len(data)-p:]...) | |
| 131 | + | c.startFlushTimer() | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | c.emit(data) | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // resetBuffering leaves paste mode and stops the safety timer. | |
| 141 | + | func (c *pasteCoalescer) resetBuffering() { | |
| 142 | + | c.buffering = false | |
| 143 | + | c.buf = nil | |
| 144 | + | c.stopSafetyTimer() | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Close stops any pending timers and their goroutines. Held bytes are dropped: | |
| 148 | + | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | |
| 149 | + | // left to deliver them. | |
| 150 | + | func (c *pasteCoalescer) Close() { | |
| 151 | + | c.mu.Lock() | |
| 152 | + | defer c.mu.Unlock() | |
| 153 | + | c.closed = true | |
| 154 | + | c.stopFlushTimer() | |
| 155 | + | c.stopSafetyTimer() | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (c *pasteCoalescer) startFlushTimer() { | |
| 159 | + | c.stopFlushTimer() | |
| 160 | + | if c.closed { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | timer := c.clock.NewTimer(pasteFlushDelay) | |
| 164 | + | done := make(chan struct{}) | |
| 165 | + | c.flushTimer = timer | |
| 166 | + | c.flushDone = done | |
| 167 | + | go func() { | |
| 168 | + | select { | |
| 169 | + | case <-timer.C(): | |
| 170 | + | case <-done: | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | c.mu.Lock() | |
| 174 | + | defer c.mu.Unlock() | |
| 175 | + | if c.flushTimer != timer || len(c.pending) == 0 { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | c.flushTimer = nil | |
| 179 | + | c.flushDone = nil | |
| 180 | + | held := c.pending | |
| 181 | + | c.pending = nil | |
| 182 | + | c.emit(held) | |
| 183 | + | }() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (c *pasteCoalescer) stopFlushTimer() { | |
| 187 | + | if c.flushTimer != nil { | |
| 188 | + | c.flushTimer.Stop() | |
| 189 | + | c.flushTimer = nil | |
| 190 | + | } | |
| 191 | + | if c.flushDone != nil { | |
| 192 | + | close(c.flushDone) | |
| 193 | + | c.flushDone = nil | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (c *pasteCoalescer) startSafetyTimer() { | |
| 198 | + | c.stopSafetyTimer() | |
| 199 | + | if c.closed { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | timer := c.clock.NewTimer(pasteSafetyDelay) | |
| 203 | + | done := make(chan struct{}) | |
| 204 | + | c.safetyTimer = timer | |
| 205 | + | c.safetyDone = done | |
| 206 | + | go func() { | |
| 207 | + | select { | |
| 208 | + | case <-timer.C(): | |
| 209 | + | case <-done: | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | c.mu.Lock() | |
| 213 | + | defer c.mu.Unlock() | |
| 214 | + | if c.safetyTimer != timer || !c.buffering { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | buffered := c.buf | |
| 218 | + | c.buffering = false | |
| 219 | + | c.buf = nil | |
| 220 | + | c.safetyTimer = nil | |
| 221 | + | c.safetyDone = nil | |
| 222 | + | if len(buffered) > 0 { | |
| 223 | + | c.emit(buffered) | |
| 224 | + | } | |
| 225 | + | }() | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (c *pasteCoalescer) stopSafetyTimer() { | |
| 229 | + | if c.safetyTimer != nil { | |
| 230 | + | c.safetyTimer.Stop() | |
| 231 | + | c.safetyTimer = nil | |
| 232 | + | } | |
| 233 | + | if c.safetyDone != nil { | |
| 234 | + | close(c.safetyDone) | |
| 235 | + | c.safetyDone = nil | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // trailingOpenPrefixLen returns the length of the longest suffix of data that | |
| 240 | + | // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or | |
| 241 | + | // "\x1b[20"). A full marker match is handled by the caller before this runs. | |
| 242 | + | func trailingOpenPrefixLen(data []byte) int { | |
| 243 | + | max := len(pasteOpenMarker) - 1 | |
| 244 | + | if max > len(data) { | |
| 245 | + | max = len(data) | |
| 246 | + | } | |
| 247 | + | for n := max; n >= 1; n-- { | |
| 248 | + | if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) { | |
| 249 | + | return n | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return 0 | |
| 253 | + | } | |
| @@ -0,0 +1,253 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 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 ( | |||
| 20 | + | // 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 | |||
| 22 | + | // bridge a local kernel read split: the rest of the marker is already in | |||
| 23 | + | // the tty buffer and arrives on the next Read microseconds later. It is NOT | |||
| 24 | + | // meant to bridge a network-scale gap — once the opening marker completes, | |||
| 25 | + | // buffering mode holds the whole paste (up to the 2s safety timer), so a | |||
| 26 | + | // close-marker split at any realistic latency is covered by whole-paste | |||
| 27 | + | // framing rather than this timer. Kept well under the daemon router's | |||
| 28 | + | // ESCDelay so a real lone Esc keypress still reaches the pane promptly. | |||
| 29 | + | pasteFlushDelay = 20 * time.Millisecond | |||
| 30 | + | // pasteSafetyDelay caps how long the coalescer waits for a closing marker | |||
| 31 | + | // before flushing a partial paste, so a paste with a lost closing marker | |||
| 32 | + | // degrades to today's per-frame behavior instead of stalling input. | |||
| 33 | + | pasteSafetyDelay = 2 * time.Second | |||
| 34 | + | // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed | |||
| 35 | + | // verbatim and normal scanning resumes; input is never dropped. | |||
| 36 | + | maxPasteBuffer = 2 << 20 // 2 MiB | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // pasteCoalescer reframes a bracketed paste that arrives split across input | |||
| 40 | + | // reads into a single emit call. Over a remote attach, stdin is read in fixed | |||
| 41 | + | // chunks and forwarded frame by frame; an input-frame boundary can land inside | |||
| 42 | + | // a paste marker (e.g. between the marker's ESC and its '['). When the daemon's | |||
| 43 | + | // keys router then sees a lone trailing ESC it retains it for ESCDelay and, if | |||
| 44 | + | // the next frame is late, forwards it as a standalone Esc keypress — breaking | |||
| 45 | + | // the opening marker so the pane's TUI never enters paste mode. Coalescing the | |||
| 46 | + | // whole paste into one emit keeps both markers contiguous on the wire. | |||
| 47 | + | // | |||
| 48 | + | // It is safe for concurrent use: Scan runs on the stdin pump goroutine while a | |||
| 49 | + | // flush/safety timer may fire on its own goroutine; both take the mutex, so | |||
| 50 | + | // emit calls are serialised. | |||
| 51 | + | type pasteCoalescer struct { | |||
| 52 | + | clock ports.Clock | |||
| 53 | + | emit func([]byte) | |||
| 54 | + | ||||
| 55 | + | mu sync.Mutex | |||
| 56 | + | buffering bool // inside a paste: accumulating until the closing marker | |||
| 57 | + | buf []byte // paste bytes accumulated in buffering mode | |||
| 58 | + | pending []byte // held trailing strict prefix of the opening marker | |||
| 59 | + | ||||
| 60 | + | flushTimer ports.Timer | |||
| 61 | + | flushDone chan struct{} | |||
| 62 | + | safetyTimer ports.Timer | |||
| 63 | + | safetyDone chan struct{} | |||
| 64 | + | closed bool | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer { | |||
| 68 | + | return &pasteCoalescer{clock: clock, emit: emit} | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Scan consumes one run of ordinary input bytes, emitting either pass-through | |||
| 72 | + | // bytes immediately or a whole bracketed paste as a single emit call once its | |||
| 73 | + | // closing marker is seen. | |||
| 74 | + | func (c *pasteCoalescer) Scan(data []byte) { | |||
| 75 | + | if len(data) == 0 { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | c.mu.Lock() | |||
| 79 | + | defer c.mu.Unlock() | |||
| 80 | + | if len(c.pending) > 0 { | |||
| 81 | + | // New bytes arrived before the flush timer fired: the held prefix was | |||
| 82 | + | // the start of this data, not a lone Esc. Recombine and rescan. | |||
| 83 | + | c.stopFlushTimer() | |||
| 84 | + | data = append(c.pending, data...) | |||
| 85 | + | c.pending = nil | |||
| 86 | + | } | |||
| 87 | + | c.process(data) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // process runs the scan/buffer state machine with the mutex held. | |||
| 91 | + | func (c *pasteCoalescer) process(data []byte) { | |||
| 92 | + | for { | |||
| 93 | + | if c.buffering { | |||
| 94 | + | c.buf = append(c.buf, data...) | |||
| 95 | + | data = nil | |||
| 96 | + | idx := bytes.Index(c.buf, pasteCloseMarker) | |||
| 97 | + | if idx < 0 { | |||
| 98 | + | if len(c.buf) > maxPasteBuffer { | |||
| 99 | + | c.emit(c.buf) | |||
| 100 | + | c.resetBuffering() | |||
| 101 | + | } | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | end := idx + len(pasteCloseMarker) | |||
| 105 | + | c.emit(c.buf[:end]) | |||
| 106 | + | rest := append([]byte(nil), c.buf[end:]...) | |||
| 107 | + | c.resetBuffering() | |||
| 108 | + | data = rest | |||
| 109 | + | // Fall through to scan any bytes trailing the paste. | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | if len(data) == 0 { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 { | |||
| 117 | + | if idx > 0 { | |||
| 118 | + | c.emit(data[:idx]) | |||
| 119 | + | } | |||
| 120 | + | c.buffering = true | |||
| 121 | + | c.startSafetyTimer() | |||
| 122 | + | data = data[idx:] // marker onward; re-enter the buffering branch | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | if p := trailingOpenPrefixLen(data); p > 0 { | |||
| 127 | + | if len(data) > p { | |||
| 128 | + | c.emit(data[:len(data)-p]) | |||
| 129 | + | } | |||
| 130 | + | c.pending = append([]byte(nil), data[len(data)-p:]...) | |||
| 131 | + | c.startFlushTimer() | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | c.emit(data) | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // resetBuffering leaves paste mode and stops the safety timer. | |||
| 141 | + | func (c *pasteCoalescer) resetBuffering() { | |||
| 142 | + | c.buffering = false | |||
| 143 | + | c.buf = nil | |||
| 144 | + | c.stopSafetyTimer() | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Close stops any pending timers and their goroutines. Held bytes are dropped: | |||
| 148 | + | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | |||
| 149 | + | // left to deliver them. | |||
| 150 | + | func (c *pasteCoalescer) Close() { | |||
| 151 | + | c.mu.Lock() | |||
| 152 | + | defer c.mu.Unlock() | |||
| 153 | + | c.closed = true | |||
| 154 | + | c.stopFlushTimer() | |||
| 155 | + | c.stopSafetyTimer() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (c *pasteCoalescer) startFlushTimer() { | |||
| 159 | + | c.stopFlushTimer() | |||
| 160 | + | if c.closed { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | timer := c.clock.NewTimer(pasteFlushDelay) | |||
| 164 | + | done := make(chan struct{}) | |||
| 165 | + | c.flushTimer = timer | |||
| 166 | + | c.flushDone = done | |||
| 167 | + | go func() { | |||
| 168 | + | select { | |||
| 169 | + | case <-timer.C(): | |||
| 170 | + | case <-done: | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | c.mu.Lock() | |||
| 174 | + | defer c.mu.Unlock() | |||
| 175 | + | if c.flushTimer != timer || len(c.pending) == 0 { | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | c.flushTimer = nil | |||
| 179 | + | c.flushDone = nil | |||
| 180 | + | held := c.pending | |||
| 181 | + | c.pending = nil | |||
| 182 | + | c.emit(held) | |||
| 183 | + | }() | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (c *pasteCoalescer) stopFlushTimer() { | |||
| 187 | + | if c.flushTimer != nil { | |||
| 188 | + | c.flushTimer.Stop() | |||
| 189 | + | c.flushTimer = nil | |||
| 190 | + | } | |||
| 191 | + | if c.flushDone != nil { | |||
| 192 | + | close(c.flushDone) | |||
| 193 | + | c.flushDone = nil | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (c *pasteCoalescer) startSafetyTimer() { | |||
| 198 | + | c.stopSafetyTimer() | |||
| 199 | + | if c.closed { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | timer := c.clock.NewTimer(pasteSafetyDelay) | |||
| 203 | + | done := make(chan struct{}) | |||
| 204 | + | c.safetyTimer = timer | |||
| 205 | + | c.safetyDone = done | |||
| 206 | + | go func() { | |||
| 207 | + | select { | |||
| 208 | + | case <-timer.C(): | |||
| 209 | + | case <-done: | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | c.mu.Lock() | |||
| 213 | + | defer c.mu.Unlock() | |||
| 214 | + | if c.safetyTimer != timer || !c.buffering { | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | buffered := c.buf | |||
| 218 | + | c.buffering = false | |||
| 219 | + | c.buf = nil | |||
| 220 | + | c.safetyTimer = nil | |||
| 221 | + | c.safetyDone = nil | |||
| 222 | + | if len(buffered) > 0 { | |||
| 223 | + | c.emit(buffered) | |||
| 224 | + | } | |||
| 225 | + | }() | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (c *pasteCoalescer) stopSafetyTimer() { | |||
| 229 | + | if c.safetyTimer != nil { | |||
| 230 | + | c.safetyTimer.Stop() | |||
| 231 | + | c.safetyTimer = nil | |||
| 232 | + | } | |||
| 233 | + | if c.safetyDone != nil { | |||
| 234 | + | close(c.safetyDone) | |||
| 235 | + | c.safetyDone = nil | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // trailingOpenPrefixLen returns the length of the longest suffix of data that | |||
| 240 | + | // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or | |||
| 241 | + | // "\x1b[20"). A full marker match is handled by the caller before this runs. | |||
| 242 | + | func trailingOpenPrefixLen(data []byte) int { | |||
| 243 | + | max := len(pasteOpenMarker) - 1 | |||
| 244 | + | if max > len(data) { | |||
| 245 | + | max = len(data) | |||
| 246 | + | } | |||
| 247 | + | for n := max; n >= 1; n-- { | |||
| 248 | + | if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) { | |||
| 249 | + | return n | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | return 0 | |||
| 253 | + | } | |||
A internal/usecase/client/paste_coalescer_test.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // pcFakeClock hands out fake timers the test fires by hand. The coalescer only | |
| 16 | + | // ever has one timer live at a time (a flush timer in normal mode or a safety | |
| 17 | + | // timer while buffering), so fireLast drives whichever is pending. | |
| 18 | + | type pcFakeClock struct { | |
| 19 | + | mu sync.Mutex | |
| 20 | + | timers []*pcFakeTimer | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (c *pcFakeClock) Now() time.Time { return time.Time{} } | |
| 24 | + | ||
| 25 | + | func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer { | |
| 26 | + | c.mu.Lock() | |
| 27 | + | defer c.mu.Unlock() | |
| 28 | + | t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d} | |
| 29 | + | c.timers = append(c.timers, t) | |
| 30 | + | return t | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (c *pcFakeClock) fireLast() { | |
| 34 | + | c.mu.Lock() | |
| 35 | + | defer c.mu.Unlock() | |
| 36 | + | for i := len(c.timers) - 1; i >= 0; i-- { | |
| 37 | + | if !c.timers[i].stopped && !c.timers[i].fired { | |
| 38 | + | c.timers[i].fired = true | |
| 39 | + | c.timers[i].ch <- time.Time{} | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type pcFakeTimer struct { | |
| 46 | + | ch chan time.Time | |
| 47 | + | d time.Duration | |
| 48 | + | stopped bool | |
| 49 | + | fired bool | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (t *pcFakeTimer) C() <-chan time.Time { return t.ch } | |
| 53 | + | func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped } | |
| 54 | + | func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s } | |
| 55 | + | ||
| 56 | + | // pcCollector records emit calls under a mutex so a timer goroutine's emit is | |
| 57 | + | // safe to observe from the test goroutine. | |
| 58 | + | type pcCollector struct { | |
| 59 | + | mu sync.Mutex | |
| 60 | + | emits [][]byte | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (c *pcCollector) emit(data []byte) { | |
| 64 | + | c.mu.Lock() | |
| 65 | + | defer c.mu.Unlock() | |
| 66 | + | c.emits = append(c.emits, append([]byte(nil), data...)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (c *pcCollector) snapshot() [][]byte { | |
| 70 | + | c.mu.Lock() | |
| 71 | + | defer c.mu.Unlock() | |
| 72 | + | out := make([][]byte, len(c.emits)) | |
| 73 | + | copy(out, c.emits) | |
| 74 | + | return out | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (c *pcCollector) waitEmits(t *testing.T, n int) { | |
| 78 | + | t.Helper() | |
| 79 | + | require.Eventually(t, func() bool { | |
| 80 | + | c.mu.Lock() | |
| 81 | + | defer c.mu.Unlock() | |
| 82 | + | return len(c.emits) >= n | |
| 83 | + | }, time.Second, time.Millisecond) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) { | |
| 87 | + | clk := &pcFakeClock{} | |
| 88 | + | col := &pcCollector{} | |
| 89 | + | return newPasteCoalescer(clk, col.emit), clk, col | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // samplePaste is a multiline bracketed paste whose content deliberately embeds | |
| 93 | + | // sequences that would otherwise be interpreted (a lone ESC+letter, an Alt | |
| 94 | + | // arrow CSI, a marker-looking prefix) to prove content is carried verbatim. | |
| 95 | + | var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~") | |
| 96 | + | ||
| 97 | + | func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) { | |
| 98 | + | for s := 0; s <= len(samplePaste); s++ { | |
| 99 | + | t.Run("split_"+strconv.Itoa(s), func(t *testing.T) { | |
| 100 | + | pc, _, col := newTestCoalescer() | |
| 101 | + | defer pc.Close() | |
| 102 | + | pc.Scan(samplePaste[:s]) | |
| 103 | + | pc.Scan(samplePaste[s:]) | |
| 104 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |
| 105 | + | "paste split at offset %d must emit exactly once, byte-identical", s) | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestPasteCoalescerByteByByte(t *testing.T) { | |
| 111 | + | pc, _, col := newTestCoalescer() | |
| 112 | + | defer pc.Close() | |
| 113 | + | for i := 0; i < len(samplePaste); i++ { | |
| 114 | + | pc.Scan(samplePaste[i : i+1]) | |
| 115 | + | } | |
| 116 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |
| 117 | + | "paste fed one byte at a time must still emit as one contiguous paste") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) { | |
| 121 | + | pc, clk, col := newTestCoalescer() | |
| 122 | + | defer pc.Close() | |
| 123 | + | ||
| 124 | + | pc.Scan([]byte{0x1b}) | |
| 125 | + | require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately") | |
| 126 | + | ||
| 127 | + | clk.fireLast() | |
| 128 | + | col.waitEmits(t, 1) | |
| 129 | + | require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) { | |
| 133 | + | pc, _, col := newTestCoalescer() | |
| 134 | + | defer pc.Close() | |
| 135 | + | ||
| 136 | + | pc.Scan([]byte{0x1b}) | |
| 137 | + | require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker") | |
| 138 | + | ||
| 139 | + | pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~" | |
| 140 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |
| 141 | + | "ESC recombined with the following marker must emit a single paste") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestPasteCoalescerPlainTextPassthrough(t *testing.T) { | |
| 145 | + | pc, _, col := newTestCoalescer() | |
| 146 | + | defer pc.Close() | |
| 147 | + | ||
| 148 | + | pc.Scan([]byte("hello world")) | |
| 149 | + | pc.Scan([]byte("second read")) | |
| 150 | + | require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(), | |
| 151 | + | "plain text must pass through unchanged at the same call granularity") | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) { | |
| 155 | + | pc, _, col := newTestCoalescer() | |
| 156 | + | defer pc.Close() | |
| 157 | + | ||
| 158 | + | paste := []byte("\x1b[200~pasted\x1b[201~") | |
| 159 | + | read := append(append([]byte("AB"), paste...), []byte("CD")...) | |
| 160 | + | pc.Scan(read) | |
| 161 | + | require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(), | |
| 162 | + | "text + paste + text in one read must emit three chunks in order") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) { | |
| 166 | + | pc, _, col := newTestCoalescer() | |
| 167 | + | defer pc.Close() | |
| 168 | + | ||
| 169 | + | big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...) | |
| 170 | + | pc.Scan(big) | |
| 171 | + | require.Equal(t, [][]byte{big}, col.snapshot(), | |
| 172 | + | "an oversized unterminated paste must flush verbatim without dropping bytes") | |
| 173 | + | ||
| 174 | + | // The coalescer must be back in normal mode and keep working. | |
| 175 | + | pc.Scan([]byte("after")) | |
| 176 | + | require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot()) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) { | |
| 180 | + | pc, clk, col := newTestCoalescer() | |
| 181 | + | defer pc.Close() | |
| 182 | + | ||
| 183 | + | partial := []byte("\x1b[200~unterminated paste") | |
| 184 | + | pc.Scan(partial) | |
| 185 | + | require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted") | |
| 186 | + | ||
| 187 | + | clk.fireLast() // safety timer | |
| 188 | + | col.waitEmits(t, 1) | |
| 189 | + | require.Equal(t, [][]byte{partial}, col.snapshot(), | |
| 190 | + | "the safety timeout must flush buffered paste bytes verbatim") | |
| 191 | + | ||
| 192 | + | pc.Scan([]byte("normal")) | |
| 193 | + | require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(), | |
| 194 | + | "after the safety flush the coalescer must resume normal scanning") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestTrailingOpenPrefixLen(t *testing.T) { | |
| 198 | + | cases := []struct { | |
| 199 | + | in string | |
| 200 | + | want int | |
| 201 | + | }{ | |
| 202 | + | {"", 0}, | |
| 203 | + | {"abc", 0}, | |
| 204 | + | {"abc\x1b", 1}, | |
| 205 | + | {"abc\x1b[", 2}, | |
| 206 | + | {"\x1b[2", 3}, | |
| 207 | + | {"\x1b[20", 4}, | |
| 208 | + | {"\x1b[200", 5}, | |
| 209 | + | {"\x1b[200~", 0}, // full marker is not a strict prefix | |
| 210 | + | {"[200", 0}, // no ESC: not a marker start | |
| 211 | + | } | |
| 212 | + | for _, tc := range cases { | |
| 213 | + | require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in) | |
| 214 | + | } | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // pcFakeClock hands out fake timers the test fires by hand. The coalescer only | |||
| 16 | + | // ever has one timer live at a time (a flush timer in normal mode or a safety | |||
| 17 | + | // timer while buffering), so fireLast drives whichever is pending. | |||
| 18 | + | type pcFakeClock struct { | |||
| 19 | + | mu sync.Mutex | |||
| 20 | + | timers []*pcFakeTimer | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (c *pcFakeClock) Now() time.Time { return time.Time{} } | |||
| 24 | + | ||||
| 25 | + | func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 26 | + | c.mu.Lock() | |||
| 27 | + | defer c.mu.Unlock() | |||
| 28 | + | t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d} | |||
| 29 | + | c.timers = append(c.timers, t) | |||
| 30 | + | return t | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (c *pcFakeClock) fireLast() { | |||
| 34 | + | c.mu.Lock() | |||
| 35 | + | defer c.mu.Unlock() | |||
| 36 | + | for i := len(c.timers) - 1; i >= 0; i-- { | |||
| 37 | + | if !c.timers[i].stopped && !c.timers[i].fired { | |||
| 38 | + | c.timers[i].fired = true | |||
| 39 | + | c.timers[i].ch <- time.Time{} | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | type pcFakeTimer struct { | |||
| 46 | + | ch chan time.Time | |||
| 47 | + | d time.Duration | |||
| 48 | + | stopped bool | |||
| 49 | + | fired bool | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (t *pcFakeTimer) C() <-chan time.Time { return t.ch } | |||
| 53 | + | func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped } | |||
| 54 | + | func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s } | |||
| 55 | + | ||||
| 56 | + | // pcCollector records emit calls under a mutex so a timer goroutine's emit is | |||
| 57 | + | // safe to observe from the test goroutine. | |||
| 58 | + | type pcCollector struct { | |||
| 59 | + | mu sync.Mutex | |||
| 60 | + | emits [][]byte | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (c *pcCollector) emit(data []byte) { | |||
| 64 | + | c.mu.Lock() | |||
| 65 | + | defer c.mu.Unlock() | |||
| 66 | + | c.emits = append(c.emits, append([]byte(nil), data...)) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (c *pcCollector) snapshot() [][]byte { | |||
| 70 | + | c.mu.Lock() | |||
| 71 | + | defer c.mu.Unlock() | |||
| 72 | + | out := make([][]byte, len(c.emits)) | |||
| 73 | + | copy(out, c.emits) | |||
| 74 | + | return out | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (c *pcCollector) waitEmits(t *testing.T, n int) { | |||
| 78 | + | t.Helper() | |||
| 79 | + | require.Eventually(t, func() bool { | |||
| 80 | + | c.mu.Lock() | |||
| 81 | + | defer c.mu.Unlock() | |||
| 82 | + | return len(c.emits) >= n | |||
| 83 | + | }, time.Second, time.Millisecond) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) { | |||
| 87 | + | clk := &pcFakeClock{} | |||
| 88 | + | col := &pcCollector{} | |||
| 89 | + | return newPasteCoalescer(clk, col.emit), clk, col | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // samplePaste is a multiline bracketed paste whose content deliberately embeds | |||
| 93 | + | // sequences that would otherwise be interpreted (a lone ESC+letter, an Alt | |||
| 94 | + | // arrow CSI, a marker-looking prefix) to prove content is carried verbatim. | |||
| 95 | + | var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~") | |||
| 96 | + | ||||
| 97 | + | func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) { | |||
| 98 | + | for s := 0; s <= len(samplePaste); s++ { | |||
| 99 | + | t.Run("split_"+strconv.Itoa(s), func(t *testing.T) { | |||
| 100 | + | pc, _, col := newTestCoalescer() | |||
| 101 | + | defer pc.Close() | |||
| 102 | + | pc.Scan(samplePaste[:s]) | |||
| 103 | + | pc.Scan(samplePaste[s:]) | |||
| 104 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |||
| 105 | + | "paste split at offset %d must emit exactly once, byte-identical", s) | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestPasteCoalescerByteByByte(t *testing.T) { | |||
| 111 | + | pc, _, col := newTestCoalescer() | |||
| 112 | + | defer pc.Close() | |||
| 113 | + | for i := 0; i < len(samplePaste); i++ { | |||
| 114 | + | pc.Scan(samplePaste[i : i+1]) | |||
| 115 | + | } | |||
| 116 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |||
| 117 | + | "paste fed one byte at a time must still emit as one contiguous paste") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) { | |||
| 121 | + | pc, clk, col := newTestCoalescer() | |||
| 122 | + | defer pc.Close() | |||
| 123 | + | ||||
| 124 | + | pc.Scan([]byte{0x1b}) | |||
| 125 | + | require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately") | |||
| 126 | + | ||||
| 127 | + | clk.fireLast() | |||
| 128 | + | col.waitEmits(t, 1) | |||
| 129 | + | require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) { | |||
| 133 | + | pc, _, col := newTestCoalescer() | |||
| 134 | + | defer pc.Close() | |||
| 135 | + | ||||
| 136 | + | pc.Scan([]byte{0x1b}) | |||
| 137 | + | require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker") | |||
| 138 | + | ||||
| 139 | + | pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~" | |||
| 140 | + | require.Equal(t, [][]byte{samplePaste}, col.snapshot(), | |||
| 141 | + | "ESC recombined with the following marker must emit a single paste") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestPasteCoalescerPlainTextPassthrough(t *testing.T) { | |||
| 145 | + | pc, _, col := newTestCoalescer() | |||
| 146 | + | defer pc.Close() | |||
| 147 | + | ||||
| 148 | + | pc.Scan([]byte("hello world")) | |||
| 149 | + | pc.Scan([]byte("second read")) | |||
| 150 | + | require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(), | |||
| 151 | + | "plain text must pass through unchanged at the same call granularity") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) { | |||
| 155 | + | pc, _, col := newTestCoalescer() | |||
| 156 | + | defer pc.Close() | |||
| 157 | + | ||||
| 158 | + | paste := []byte("\x1b[200~pasted\x1b[201~") | |||
| 159 | + | read := append(append([]byte("AB"), paste...), []byte("CD")...) | |||
| 160 | + | pc.Scan(read) | |||
| 161 | + | require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(), | |||
| 162 | + | "text + paste + text in one read must emit three chunks in order") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) { | |||
| 166 | + | pc, _, col := newTestCoalescer() | |||
| 167 | + | defer pc.Close() | |||
| 168 | + | ||||
| 169 | + | big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...) | |||
| 170 | + | pc.Scan(big) | |||
| 171 | + | require.Equal(t, [][]byte{big}, col.snapshot(), | |||
| 172 | + | "an oversized unterminated paste must flush verbatim without dropping bytes") | |||
| 173 | + | ||||
| 174 | + | // The coalescer must be back in normal mode and keep working. | |||
| 175 | + | pc.Scan([]byte("after")) | |||
| 176 | + | require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot()) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) { | |||
| 180 | + | pc, clk, col := newTestCoalescer() | |||
| 181 | + | defer pc.Close() | |||
| 182 | + | ||||
| 183 | + | partial := []byte("\x1b[200~unterminated paste") | |||
| 184 | + | pc.Scan(partial) | |||
| 185 | + | require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted") | |||
| 186 | + | ||||
| 187 | + | clk.fireLast() // safety timer | |||
| 188 | + | col.waitEmits(t, 1) | |||
| 189 | + | require.Equal(t, [][]byte{partial}, col.snapshot(), | |||
| 190 | + | "the safety timeout must flush buffered paste bytes verbatim") | |||
| 191 | + | ||||
| 192 | + | pc.Scan([]byte("normal")) | |||
| 193 | + | require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(), | |||
| 194 | + | "after the safety flush the coalescer must resume normal scanning") | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestTrailingOpenPrefixLen(t *testing.T) { | |||
| 198 | + | cases := []struct { | |||
| 199 | + | in string | |||
| 200 | + | want int | |||
| 201 | + | }{ | |||
| 202 | + | {"", 0}, | |||
| 203 | + | {"abc", 0}, | |||
| 204 | + | {"abc\x1b", 1}, | |||
| 205 | + | {"abc\x1b[", 2}, | |||
| 206 | + | {"\x1b[2", 3}, | |||
| 207 | + | {"\x1b[20", 4}, | |||
| 208 | + | {"\x1b[200", 5}, | |||
| 209 | + | {"\x1b[200~", 0}, // full marker is not a strict prefix | |||
| 210 | + | {"[200", 0}, // no ESC: not a marker start | |||
| 211 | + | } | |||
| 212 | + | for _, tc := range cases { | |||
| 213 | + | require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in) | |||
| 214 | + | } | |||
| 215 | + | } | |||
M internal/usecase/keys/router.go +23 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package keys | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -16,6 +17,13 @@ const ( | |||
| 16 | 17 | ESCDelay = 40 * time.Millisecond | |
| 17 | 18 | ) | |
| 18 | 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 | + | ||
| 19 | 27 | // Action is an intercepted vev key binding. | |
| 20 | 28 | type Action int | |
| 21 | 29 | ||
| @@ -104,6 +112,21 @@ func (r *Router) route(data []byte) { | |||
| 104 | 112 | return | |
| 105 | 113 | } | |
| 106 | 114 | remaining := data[i+1:] | |
| 115 | + | if bytes.HasPrefix(data[i:], pasteOpenMarker) { | |
| 116 | + | // A bracketed paste whose closing marker is in this same frame is | |
| 117 | + | // forwarded verbatim: pasted content bytes (including embedded | |
| 118 | + | // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never | |
| 119 | + | // fire an Action mid-paste. Without a closing marker in-frame we | |
| 120 | + | // fall through to today's per-byte routing (the '[' passes through | |
| 121 | + | // as a control prefix); Part A keeps pastes single-frame in | |
| 122 | + | // 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) | |
| 125 | + | buf = append(buf, data[i:end]...) | |
| 126 | + | i = end | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | } | |
| 107 | 130 | if action, size, ok, partial := altArrowCSI(remaining); ok { | |
| 108 | 131 | flush() | |
| 109 | 132 | r.h.Action(action) | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package keys | 3 | package keys | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "bytes" | |||
| 6 | "slices" | 7 | "slices" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -16,6 +17,13 @@ const ( | |||||
| 16 | ESCDelay = 40 * time.Millisecond | 17 | ESCDelay = 40 * time.Millisecond | ||
| 17 | ) | 18 | ) | ||
| 18 | 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 | + | ||||
| 19 | // Action is an intercepted vev key binding. | 27 | // Action is an intercepted vev key binding. | ||
| 20 | type Action int | 28 | type Action int | ||
| 21 | 29 | ||||
| @@ -104,6 +112,21 @@ func (r *Router) route(data []byte) { | |||||
| 104 | return | 112 | return | ||
| 105 | } | 113 | } | ||
| 106 | remaining := data[i+1:] | 114 | remaining := data[i+1:] | ||
| 115 | + | if bytes.HasPrefix(data[i:], pasteOpenMarker) { | |||
| 116 | + | // A bracketed paste whose closing marker is in this same frame is | |||
| 117 | + | // forwarded verbatim: pasted content bytes (including embedded | |||
| 118 | + | // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never | |||
| 119 | + | // fire an Action mid-paste. Without a closing marker in-frame we | |||
| 120 | + | // fall through to today's per-byte routing (the '[' passes through | |||
| 121 | + | // as a control prefix); Part A keeps pastes single-frame in | |||
| 122 | + | // 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) | |||
| 125 | + | buf = append(buf, data[i:end]...) | |||
| 126 | + | i = end | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 107 | if action, size, ok, partial := altArrowCSI(remaining); ok { | 130 | if action, size, ok, partial := altArrowCSI(remaining); ok { | ||
| 108 | flush() | 131 | flush() | ||
| 109 | r.h.Action(action) | 132 | r.h.Action(action) | ||
M internal/usecase/keys/router_test.go +48 -0
| @@ -496,3 +496,51 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |||
| 496 | 496 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | |
| 497 | 497 | require.Empty(t, h.actions) | |
| 498 | 498 | } | |
| 499 | + | ||
| 500 | + | // pasteBindingLookalikes are content bytes that, seen outside a paste, each map | |
| 501 | + | // to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI. | |
| 502 | + | var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A") | |
| 503 | + | ||
| 504 | + | func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |
| 505 | + | clk := &fakeClock{} | |
| 506 | + | h := &captureHandler{} | |
| 507 | + | r := NewRouter(clk, h) | |
| 508 | + | ||
| 509 | + | paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...) | |
| 510 | + | r.Route(paste) | |
| 511 | + | ||
| 512 | + | require.Empty(t, h.actions, "paste content must not fire any binding") | |
| 513 | + | require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical") | |
| 514 | + | require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC") | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | |
| 518 | + | clk := &fakeClock{} | |
| 519 | + | h := &captureHandler{} | |
| 520 | + | r := NewRouter(clk, h) | |
| 521 | + | ||
| 522 | + | r.Route(pasteBindingLookalikes) | |
| 523 | + | ||
| 524 | + | require.Equal(t, []Action{ | |
| 525 | + | ActionFocusPaneDown, // Alt-j | |
| 526 | + | ActionOpenPalette, // Alt-space | |
| 527 | + | ActionSwitchTab1, // Alt-1 | |
| 528 | + | ActionFocusPaneUp, // Alt-arrow up | |
| 529 | + | }, h.actions) | |
| 530 | + | require.Empty(t, h.forwards) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) { | |
| 534 | + | clk := &fakeClock{} | |
| 535 | + | h := &captureHandler{} | |
| 536 | + | r := NewRouter(clk, h) | |
| 537 | + | ||
| 538 | + | // Opening marker + content in one frame, closing marker in the next: with | |
| 539 | + | // no closing marker in-frame the router keeps its historical per-byte | |
| 540 | + | // routing, forwarding each frame's bytes as they arrive. | |
| 541 | + | r.Route([]byte("\x1b[200~hello")) | |
| 542 | + | r.Route([]byte("\x1b[201~")) | |
| 543 | + | ||
| 544 | + | require.Empty(t, h.actions) | |
| 545 | + | require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards) | |
| 546 | + | } | |
| @@ -496,3 +496,51 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |||||
| 496 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | 496 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | ||
| 497 | require.Empty(t, h.actions) | 497 | require.Empty(t, h.actions) | ||
| 498 | } | 498 | } | ||
| 499 | + | ||||
| 500 | + | // pasteBindingLookalikes are content bytes that, seen outside a paste, each map | |||
| 501 | + | // to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI. | |||
| 502 | + | var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A") | |||
| 503 | + | ||||
| 504 | + | func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |||
| 505 | + | clk := &fakeClock{} | |||
| 506 | + | h := &captureHandler{} | |||
| 507 | + | r := NewRouter(clk, h) | |||
| 508 | + | ||||
| 509 | + | paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...) | |||
| 510 | + | r.Route(paste) | |||
| 511 | + | ||||
| 512 | + | require.Empty(t, h.actions, "paste content must not fire any binding") | |||
| 513 | + | require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical") | |||
| 514 | + | require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC") | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | |||
| 518 | + | clk := &fakeClock{} | |||
| 519 | + | h := &captureHandler{} | |||
| 520 | + | r := NewRouter(clk, h) | |||
| 521 | + | ||||
| 522 | + | r.Route(pasteBindingLookalikes) | |||
| 523 | + | ||||
| 524 | + | require.Equal(t, []Action{ | |||
| 525 | + | ActionFocusPaneDown, // Alt-j | |||
| 526 | + | ActionOpenPalette, // Alt-space | |||
| 527 | + | ActionSwitchTab1, // Alt-1 | |||
| 528 | + | ActionFocusPaneUp, // Alt-arrow up | |||
| 529 | + | }, h.actions) | |||
| 530 | + | require.Empty(t, h.forwards) | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) { | |||
| 534 | + | clk := &fakeClock{} | |||
| 535 | + | h := &captureHandler{} | |||
| 536 | + | r := NewRouter(clk, h) | |||
| 537 | + | ||||
| 538 | + | // Opening marker + content in one frame, closing marker in the next: with | |||
| 539 | + | // no closing marker in-frame the router keeps its historical per-byte | |||
| 540 | + | // routing, forwarding each frame's bytes as they arrive. | |||
| 541 | + | r.Route([]byte("\x1b[200~hello")) | |||
| 542 | + | r.Route([]byte("\x1b[201~")) | |||
| 543 | + | ||||
| 544 | + | require.Empty(t, h.actions) | |||
| 545 | + | require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards) | |||
| 546 | + | } | |||