fix(vt): address branch review defects
- commit
- 0e2903b537ad3c8ec1bc1c47224f0db805710740
- author
- brice · · 2026-07-02 18:06 UTC
- parent
- e7f30ce
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +726 -206
A README.md +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | # vev | |
| 2 | + | ||
| 3 | + | A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link). | |
| 4 | + | ||
| 5 | + | ## Install | |
| 6 | + | ||
| 7 | + | ```sh | |
| 8 | + | go install github.com/bnema/vev@latest | |
| 9 | + | ``` | |
| 10 | + | ||
| 11 | + | Or from a checkout: `go build -o vev .` | |
| 12 | + | ||
| 13 | + | ## Usage | |
| 14 | + | ||
| 15 | + | ``` | |
| 16 | + | vev attach to (or create) an ephemeral session | |
| 17 | + | vev new <name> create and attach to a named session | |
| 18 | + | vev attach <name> attach to an existing named session (alias: a) | |
| 19 | + | vev attach user@host[:session] attach to a remote vev daemon over SSH | |
| 20 | + | vev ls list sessions | |
| 21 | + | vev kill <name> kill a named session | |
| 22 | + | vev --help show help | |
| 23 | + | vev --version show version | |
| 24 | + | ``` | |
| 25 | + | ||
| 26 | + | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain. | |
| 27 | + | ||
| 28 | + | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`: | |
| 29 | + | ||
| 30 | + | ``` | |
| 31 | + | Host * | |
| 32 | + | ControlMaster auto | |
| 33 | + | ControlPath ~/.ssh/cm-%r@%h:%p | |
| 34 | + | ControlPersist 10m | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | Omitting `:session` opens an ephemeral session on the remote. | |
| 38 | + | ||
| 39 | + | ## Keys | |
| 40 | + | ||
| 41 | + | All bindings use the Alt modifier directly, no prefix key. | |
| 42 | + | ||
| 43 | + | | Key | Action | | |
| 44 | + | |---|---| | |
| 45 | + | | Alt+c | new window | | |
| 46 | + | | Alt+n / Alt+p | next / previous window | | |
| 47 | + | | Alt+1 .. Alt+9 | select window by number | | |
| 48 | + | | Alt+x | close window (last window ends the session) | | |
| 49 | + | | Alt+d | detach | | |
| 50 | + | | Alt+r | promote the current ephemeral session to named | | |
| 51 | + | | Alt+u | enter copy mode | | |
| 52 | + | ||
| 53 | + | ## Copy mode | |
| 54 | + | ||
| 55 | + | Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position. | |
| 56 | + | ||
| 57 | + | | Key | Action | | |
| 58 | + | |---|---| | |
| 59 | + | | j / k, Up / Down | move one line | | |
| 60 | + | | PgUp / PgDn | move one page | | |
| 61 | + | | g / G | jump to top / bottom | | |
| 62 | + | | Space | toggle line selection | | |
| 63 | + | | y or Enter | copy selection and exit | | |
| 64 | + | | q, Esc, Ctrl-C | exit without copying | | |
| 65 | + | ||
| 66 | + | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. | |
| 67 | + | ||
| 68 | + | ## Development | |
| 69 | + | ||
| 70 | + | ```sh | |
| 71 | + | go test ./... -race | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | `pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`. | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | # vev | |||
| 2 | + | ||||
| 3 | + | A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link). | |||
| 4 | + | ||||
| 5 | + | ## Install | |||
| 6 | + | ||||
| 7 | + | ```sh | |||
| 8 | + | go install github.com/bnema/vev@latest | |||
| 9 | + | ``` | |||
| 10 | + | ||||
| 11 | + | Or from a checkout: `go build -o vev .` | |||
| 12 | + | ||||
| 13 | + | ## Usage | |||
| 14 | + | ||||
| 15 | + | ``` | |||
| 16 | + | vev attach to (or create) an ephemeral session | |||
| 17 | + | vev new <name> create and attach to a named session | |||
| 18 | + | vev attach <name> attach to an existing named session (alias: a) | |||
| 19 | + | vev attach user@host[:session] attach to a remote vev daemon over SSH | |||
| 20 | + | vev ls list sessions | |||
| 21 | + | vev kill <name> kill a named session | |||
| 22 | + | vev --help show help | |||
| 23 | + | vev --version show version | |||
| 24 | + | ``` | |||
| 25 | + | ||||
| 26 | + | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain. | |||
| 27 | + | ||||
| 28 | + | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`: | |||
| 29 | + | ||||
| 30 | + | ``` | |||
| 31 | + | Host * | |||
| 32 | + | ControlMaster auto | |||
| 33 | + | ControlPath ~/.ssh/cm-%r@%h:%p | |||
| 34 | + | ControlPersist 10m | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | Omitting `:session` opens an ephemeral session on the remote. | |||
| 38 | + | ||||
| 39 | + | ## Keys | |||
| 40 | + | ||||
| 41 | + | All bindings use the Alt modifier directly, no prefix key. | |||
| 42 | + | ||||
| 43 | + | | Key | Action | | |||
| 44 | + | |---|---| | |||
| 45 | + | | Alt+c | new window | | |||
| 46 | + | | Alt+n / Alt+p | next / previous window | | |||
| 47 | + | | Alt+1 .. Alt+9 | select window by number | | |||
| 48 | + | | Alt+x | close window (last window ends the session) | | |||
| 49 | + | | Alt+d | detach | | |||
| 50 | + | | Alt+r | promote the current ephemeral session to named | | |||
| 51 | + | | Alt+u | enter copy mode | | |||
| 52 | + | ||||
| 53 | + | ## Copy mode | |||
| 54 | + | ||||
| 55 | + | Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position. | |||
| 56 | + | ||||
| 57 | + | | Key | Action | | |||
| 58 | + | |---|---| | |||
| 59 | + | | j / k, Up / Down | move one line | | |||
| 60 | + | | PgUp / PgDn | move one page | | |||
| 61 | + | | g / G | jump to top / bottom | | |||
| 62 | + | | Space | toggle line selection | | |||
| 63 | + | | y or Enter | copy selection and exit | | |||
| 64 | + | | q, Esc, Ctrl-C | exit without copying | | |||
| 65 | + | ||||
| 66 | + | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. | |||
| 67 | + | ||||
| 68 | + | ## Development | |||
| 69 | + | ||||
| 70 | + | ```sh | |||
| 71 | + | go test ./... -race | |||
| 72 | + | ``` | |||
| 73 | + | ||||
| 74 | + | `pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`. | |||
M internal/adapters/sshstdio/transport.go +34 -10
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | package sshstdio | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "bytes" | |
| 8 | 9 | "encoding/binary" | |
| 9 | 10 | "errors" | |
| 10 | 11 | "fmt" | |
| @@ -12,13 +13,15 @@ import ( | |||
| 12 | 13 | "os/exec" | |
| 13 | 14 | "strings" | |
| 14 | 15 | "sync" | |
| 16 | + | "time" | |
| 15 | 17 | ||
| 16 | 18 | "github.com/bnema/vev/internal/ports" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | const ( | |
| 20 | - | maxFrameLen = 16 << 20 | |
| 21 | - | frameHeaderLen = 4 | |
| 22 | + | maxFrameLen = 16 << 20 | |
| 23 | + | frameHeaderLen = 4 | |
| 24 | + | sshCloseTimeout = 3 * time.Second | |
| 22 | 25 | ) | |
| 23 | 26 | ||
| 24 | 27 | var ( | |
| @@ -91,6 +94,32 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 91 | 94 | ||
| 92 | 95 | func (t *transport) Close() error { return t.close() } | |
| 93 | 96 | ||
| 97 | + | func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc { | |
| 98 | + | return func() error { | |
| 99 | + | _ = stdin.Close() | |
| 100 | + | ||
| 101 | + | waitErr := make(chan error, 1) | |
| 102 | + | go func() { waitErr <- cmd.Wait() }() | |
| 103 | + | ||
| 104 | + | var err error | |
| 105 | + | select { | |
| 106 | + | case err = <-waitErr: | |
| 107 | + | case <-time.After(timeout): | |
| 108 | + | _ = cmd.Process.Kill() | |
| 109 | + | err = <-waitErr | |
| 110 | + | } | |
| 111 | + | if err == nil { | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | stderrText := strings.TrimSpace(stderr.String()) | |
| 116 | + | if stderrText != "" { | |
| 117 | + | return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText) | |
| 118 | + | } | |
| 119 | + | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 94 | 123 | // CommandSpec is the exact ssh argv vev will execute locally. | |
| 95 | 124 | type CommandSpec struct { | |
| 96 | 125 | Path string | |
| @@ -127,16 +156,11 @@ func Dial(target, session string) (ports.Transport, error) { | |||
| 127 | 156 | if err != nil { | |
| 128 | 157 | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | |
| 129 | 158 | } | |
| 159 | + | var stderr bytes.Buffer | |
| 160 | + | cmd.Stderr = &stderr | |
| 130 | 161 | if err := cmd.Start(); err != nil { | |
| 131 | 162 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 132 | 163 | } | |
| 133 | 164 | ||
| 134 | - | return NewTransport(stdout, stdin, func() error { | |
| 135 | - | _ = stdin.Close() | |
| 136 | - | err := cmd.Wait() | |
| 137 | - | if err != nil { | |
| 138 | - | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |
| 139 | - | } | |
| 140 | - | return nil | |
| 141 | - | }), nil | |
| 165 | + | return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil | |
| 142 | 166 | } | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | package sshstdio | 5 | package sshstdio | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "bytes" | |||
| 8 | "encoding/binary" | 9 | "encoding/binary" | ||
| 9 | "errors" | 10 | "errors" | ||
| 10 | "fmt" | 11 | "fmt" | ||
| @@ -12,13 +13,15 @@ import ( | |||||
| 12 | "os/exec" | 13 | "os/exec" | ||
| 13 | "strings" | 14 | "strings" | ||
| 14 | "sync" | 15 | "sync" | ||
| 16 | + | "time" | |||
| 15 | 17 | ||||
| 16 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | const ( | 21 | const ( | ||
| 20 | - | maxFrameLen = 16 << 20 | 22 | + | maxFrameLen = 16 << 20 |
| 21 | - | frameHeaderLen = 4 | 23 | + | frameHeaderLen = 4 |
| 24 | + | sshCloseTimeout = 3 * time.Second | |||
| 22 | ) | 25 | ) | ||
| 23 | 26 | ||||
| 24 | var ( | 27 | var ( | ||
| @@ -91,6 +94,32 @@ func (t *transport) Recv() (ports.Frame, error) { | |||||
| 91 | 94 | ||||
| 92 | func (t *transport) Close() error { return t.close() } | 95 | func (t *transport) Close() error { return t.close() } | ||
| 93 | 96 | ||||
| 97 | + | func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc { | |||
| 98 | + | return func() error { | |||
| 99 | + | _ = stdin.Close() | |||
| 100 | + | ||||
| 101 | + | waitErr := make(chan error, 1) | |||
| 102 | + | go func() { waitErr <- cmd.Wait() }() | |||
| 103 | + | ||||
| 104 | + | var err error | |||
| 105 | + | select { | |||
| 106 | + | case err = <-waitErr: | |||
| 107 | + | case <-time.After(timeout): | |||
| 108 | + | _ = cmd.Process.Kill() | |||
| 109 | + | err = <-waitErr | |||
| 110 | + | } | |||
| 111 | + | if err == nil { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | stderrText := strings.TrimSpace(stderr.String()) | |||
| 116 | + | if stderrText != "" { | |||
| 117 | + | return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText) | |||
| 118 | + | } | |||
| 119 | + | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 94 | // CommandSpec is the exact ssh argv vev will execute locally. | 123 | // CommandSpec is the exact ssh argv vev will execute locally. | ||
| 95 | type CommandSpec struct { | 124 | type CommandSpec struct { | ||
| 96 | Path string | 125 | Path string | ||
| @@ -127,16 +156,11 @@ func Dial(target, session string) (ports.Transport, error) { | |||||
| 127 | if err != nil { | 156 | if err != nil { | ||
| 128 | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | 157 | return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err) | ||
| 129 | } | 158 | } | ||
| 159 | + | var stderr bytes.Buffer | |||
| 160 | + | cmd.Stderr = &stderr | |||
| 130 | if err := cmd.Start(); err != nil { | 161 | if err := cmd.Start(); err != nil { | ||
| 131 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | 162 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | ||
| 132 | } | 163 | } | ||
| 133 | 164 | ||||
| 134 | - | return NewTransport(stdout, stdin, func() error { | 165 | + | return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil |
| 135 | - | _ = stdin.Close() | |||
| 136 | - | err := cmd.Wait() | |||
| 137 | - | if err != nil { | |||
| 138 | - | return fmt.Errorf("sshstdio: ssh exited: %w", err) | |||
| 139 | - | } | |||
| 140 | - | return nil | |||
| 141 | - | }), nil | |||
| 142 | } | 166 | } | ||
M internal/adapters/sshstdio/transport_test.go +60 -0
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package sshstdio | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "io" | |
| 7 | + | "os/exec" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "testing" | |
| 10 | + | "time" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/bnema/vev/internal/ports" | |
| 10 | 13 | ) | |
| @@ -98,3 +101,60 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||
| 98 | 101 | t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err) | |
| 99 | 102 | } | |
| 100 | 103 | } | |
| 104 | + | ||
| 105 | + | func TestProcessCloser(t *testing.T) { | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | cmd *exec.Cmd | |
| 109 | + | timeout time.Duration | |
| 110 | + | wantErrs []string | |
| 111 | + | wantBound bool | |
| 112 | + | }{ | |
| 113 | + | { | |
| 114 | + | name: "wedged process is killed after timeout and reaped", | |
| 115 | + | cmd: exec.Command("sleep", "30"), | |
| 116 | + | timeout: 50 * time.Millisecond, | |
| 117 | + | wantErrs: []string{"sshstdio: ssh exited:"}, | |
| 118 | + | wantBound: true, | |
| 119 | + | }, | |
| 120 | + | { | |
| 121 | + | name: "stderr from failing shell command is included in error", | |
| 122 | + | cmd: exec.Command("sh", "-c", "echo kaboom >&2; exit 7"), | |
| 123 | + | timeout: time.Second, | |
| 124 | + | wantErrs: []string{"sshstdio: ssh exited:", "kaboom"}, | |
| 125 | + | }, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | stdin, err := tt.cmd.StdinPipe() | |
| 131 | + | if err != nil { | |
| 132 | + | t.Fatalf("StdinPipe: %v", err) | |
| 133 | + | } | |
| 134 | + | var stderr bytes.Buffer | |
| 135 | + | tt.cmd.Stderr = &stderr | |
| 136 | + | if err := tt.cmd.Start(); err != nil { | |
| 137 | + | t.Fatalf("Start: %v", err) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | started := time.Now() | |
| 141 | + | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)() | |
| 142 | + | elapsed := time.Since(started) | |
| 143 | + | ||
| 144 | + | if err == nil { | |
| 145 | + | t.Fatalf("Close error = nil, want error containing %q", tt.wantErrs) | |
| 146 | + | } | |
| 147 | + | for _, want := range tt.wantErrs { | |
| 148 | + | if !strings.Contains(err.Error(), want) { | |
| 149 | + | t.Fatalf("Close error = %q, want substring %q", err.Error(), want) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | if tt.wantBound && elapsed > time.Second { | |
| 153 | + | t.Fatalf("Close took %s, want bounded below 1s", elapsed) | |
| 154 | + | } | |
| 155 | + | if tt.cmd.ProcessState == nil { | |
| 156 | + | t.Fatalf("ProcessState is nil, want child reaped") | |
| 157 | + | } | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | } | |
| @@ -1,10 +1,13 @@ | |||||
| 1 | package sshstdio | 1 | package sshstdio | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "io" | 6 | "io" | ||
| 7 | + | "os/exec" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "testing" | 9 | "testing" | ||
| 10 | + | "time" | |||
| 8 | 11 | ||||
| 9 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 10 | ) | 13 | ) | ||
| @@ -98,3 +101,60 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||||
| 98 | t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err) | 101 | t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err) | ||
| 99 | } | 102 | } | ||
| 100 | } | 103 | } | ||
| 104 | + | ||||
| 105 | + | func TestProcessCloser(t *testing.T) { | |||
| 106 | + | tests := []struct { | |||
| 107 | + | name string | |||
| 108 | + | cmd *exec.Cmd | |||
| 109 | + | timeout time.Duration | |||
| 110 | + | wantErrs []string | |||
| 111 | + | wantBound bool | |||
| 112 | + | }{ | |||
| 113 | + | { | |||
| 114 | + | name: "wedged process is killed after timeout and reaped", | |||
| 115 | + | cmd: exec.Command("sleep", "30"), | |||
| 116 | + | timeout: 50 * time.Millisecond, | |||
| 117 | + | wantErrs: []string{"sshstdio: ssh exited:"}, | |||
| 118 | + | wantBound: true, | |||
| 119 | + | }, | |||
| 120 | + | { | |||
| 121 | + | name: "stderr from failing shell command is included in error", | |||
| 122 | + | cmd: exec.Command("sh", "-c", "echo kaboom >&2; exit 7"), | |||
| 123 | + | timeout: time.Second, | |||
| 124 | + | wantErrs: []string{"sshstdio: ssh exited:", "kaboom"}, | |||
| 125 | + | }, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for _, tt := range tests { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | stdin, err := tt.cmd.StdinPipe() | |||
| 131 | + | if err != nil { | |||
| 132 | + | t.Fatalf("StdinPipe: %v", err) | |||
| 133 | + | } | |||
| 134 | + | var stderr bytes.Buffer | |||
| 135 | + | tt.cmd.Stderr = &stderr | |||
| 136 | + | if err := tt.cmd.Start(); err != nil { | |||
| 137 | + | t.Fatalf("Start: %v", err) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | started := time.Now() | |||
| 141 | + | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)() | |||
| 142 | + | elapsed := time.Since(started) | |||
| 143 | + | ||||
| 144 | + | if err == nil { | |||
| 145 | + | t.Fatalf("Close error = nil, want error containing %q", tt.wantErrs) | |||
| 146 | + | } | |||
| 147 | + | for _, want := range tt.wantErrs { | |||
| 148 | + | if !strings.Contains(err.Error(), want) { | |||
| 149 | + | t.Fatalf("Close error = %q, want substring %q", err.Error(), want) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | if tt.wantBound && elapsed > time.Second { | |||
| 153 | + | t.Fatalf("Close took %s, want bounded below 1s", elapsed) | |||
| 154 | + | } | |||
| 155 | + | if tt.cmd.ProcessState == nil { | |||
| 156 | + | t.Fatalf("ProcessState is nil, want child reaped") | |||
| 157 | + | } | |||
| 158 | + | }) | |||
| 159 | + | } | |||
| 160 | + | } | |||
M internal/usecase/daemon/daemon.go +72 -16
| @@ -49,9 +49,10 @@ import ( | |||
| 49 | 49 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| 50 | 50 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | |
| 51 | 51 | const ( | |
| 52 | - | minDebounceInterval = 2 * time.Millisecond | |
| 53 | - | maxDebounceInterval = 16 * time.Millisecond | |
| 54 | - | debounceStep = 2 * time.Millisecond | |
| 52 | + | minDebounceInterval = 2 * time.Millisecond | |
| 53 | + | maxDebounceInterval = 16 * time.Millisecond | |
| 54 | + | debounceStep = 2 * time.Millisecond | |
| 55 | + | maxSyncUpdateDuration = 500 * time.Millisecond | |
| 55 | 56 | ) | |
| 56 | 57 | ||
| 57 | 58 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | |
| @@ -135,11 +136,12 @@ type session struct { | |||
| 135 | 136 | // already pending"), never by blocking the reader. | |
| 136 | 137 | type window struct { | |
| 137 | 138 | pty ports.PTY | |
| 138 | - | mu sync.Mutex // guards screen and every attached renderer's shadow | |
| 139 | + | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | |
| 139 | 140 | screen *vt.Screen | |
| 140 | 141 | scrollback *scopy.Scrollback | |
| 141 | 142 | dirty chan struct{} | |
| 142 | 143 | flush chan struct{} | |
| 144 | + | syncGen uint64 | |
| 143 | 145 | size domain.Size | |
| 144 | 146 | ctx context.Context | |
| 145 | 147 | cancel context.CancelFunc | |
| @@ -151,13 +153,14 @@ type window struct { | |||
| 151 | 153 | // scheduler and the connection handler — so the transport's single-writer | |
| 152 | 154 | // contract holds and Draw→Send stays atomic (correct output ordering). | |
| 153 | 155 | type attachedClient struct { | |
| 154 | - | tr ports.Transport | |
| 155 | - | rend *renderer.Renderer | |
| 156 | - | size domain.Size | |
| 157 | - | keys *keys.Router | |
| 158 | - | copyMu sync.Mutex | |
| 159 | - | copyMode *scopy.Mode | |
| 160 | - | sendMu sync.Mutex | |
| 156 | + | tr ports.Transport | |
| 157 | + | rend *renderer.Renderer | |
| 158 | + | size domain.Size | |
| 159 | + | keys *keys.Router | |
| 160 | + | copyMu sync.Mutex | |
| 161 | + | copyMode *scopy.Mode | |
| 162 | + | copyPending []byte | |
| 163 | + | sendMu sync.Mutex | |
| 161 | 164 | } | |
| 162 | 165 | ||
| 163 | 166 | func (ac *attachedClient) copyModeActive() bool { | |
| @@ -700,9 +703,6 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||
| 700 | 703 | d.handleCopyInput(sess, ac, data) | |
| 701 | 704 | return | |
| 702 | 705 | } | |
| 703 | - | if ac.keys == nil { | |
| 704 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 705 | - | } | |
| 706 | 706 | ac.keys.Route(data) | |
| 707 | 707 | } | |
| 708 | 708 | ||
| @@ -729,10 +729,18 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||
| 729 | 729 | win.mu.Lock() | |
| 730 | 730 | ac.copyMu.Lock() | |
| 731 | 731 | if ac.copyMode == nil { | |
| 732 | + | ac.copyPending = nil | |
| 732 | 733 | ac.copyMu.Unlock() | |
| 733 | 734 | win.mu.Unlock() | |
| 734 | 735 | return | |
| 735 | 736 | } | |
| 737 | + | if len(ac.copyPending) > 0 { | |
| 738 | + | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | |
| 739 | + | combined = append(combined, ac.copyPending...) | |
| 740 | + | combined = append(combined, data...) | |
| 741 | + | data = combined | |
| 742 | + | ac.copyPending = nil | |
| 743 | + | } | |
| 736 | 744 | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 737 | 745 | changed := false | |
| 738 | 746 | copyOut := false | |
| @@ -759,12 +767,17 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||
| 759 | 767 | exit = true | |
| 760 | 768 | case 'q', 0x03, 0x1b: | |
| 761 | 769 | if data[i] == 0x1b { | |
| 762 | - | consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:]) | |
| 770 | + | tail := data[i:] | |
| 771 | + | consumed, ok := routeCopyEscape(ac.copyMode, snap, tail) | |
| 763 | 772 | if ok { | |
| 764 | 773 | i += consumed - 1 | |
| 765 | 774 | changed = true | |
| 766 | 775 | continue | |
| 767 | 776 | } | |
| 777 | + | if isCopyEscapePrefix(tail) { | |
| 778 | + | ac.copyPending = append(ac.copyPending[:0], tail...) | |
| 779 | + | break | |
| 780 | + | } | |
| 768 | 781 | } | |
| 769 | 782 | exit = true | |
| 770 | 783 | } | |
| @@ -779,7 +792,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||
| 779 | 792 | ac.copyMu.Unlock() | |
| 780 | 793 | win.mu.Unlock() | |
| 781 | 794 | ||
| 782 | - | if copyOut { | |
| 795 | + | if copyOut && text != "" { | |
| 783 | 796 | for _, chunk := range scopy.OSC52(text) { | |
| 784 | 797 | if err := ac.send(frameOutput(chunk)); err != nil { | |
| 785 | 798 | d.detachOnSendError(sess, ac) | |
| @@ -820,6 +833,11 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool | |||
| 820 | 833 | return 0, false | |
| 821 | 834 | } | |
| 822 | 835 | ||
| 836 | + | func isCopyEscapePrefix(data []byte) bool { | |
| 837 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' || | |
| 838 | + | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 839 | + | } | |
| 840 | + | ||
| 823 | 841 | type daemonKeyHandler struct { | |
| 824 | 842 | d *Daemon | |
| 825 | 843 | sess *session | |
| @@ -951,6 +969,10 @@ func signal(ch chan struct{}) { | |||
| 951 | 969 | func (d *Daemon) ptyReader(sess *session, win *window) { | |
| 952 | 970 | defer d.sessWg.Done() | |
| 953 | 971 | buf := make([]byte, ptyReadBufSize) | |
| 972 | + | var resp []byte | |
| 973 | + | win.mu.Lock() | |
| 974 | + | win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 975 | + | win.mu.Unlock() | |
| 954 | 976 | for { | |
| 955 | 977 | n, err := win.pty.Read(buf) | |
| 956 | 978 | if n > 0 { | |
| @@ -960,6 +982,21 @@ func (d *Daemon) ptyReader(sess *session, win *window) { | |||
| 960 | 982 | win.screen.Write(data) | |
| 961 | 983 | isSyncing := win.screen.SyncUpdateActive() | |
| 962 | 984 | win.mu.Unlock() | |
| 985 | + | if len(resp) > 0 { | |
| 986 | + | if _, writeErr := win.pty.Write(resp); writeErr != nil { | |
| 987 | + | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |
| 988 | + | } | |
| 989 | + | resp = resp[:0] | |
| 990 | + | } | |
| 991 | + | if wasSyncing != isSyncing { | |
| 992 | + | win.mu.Lock() | |
| 993 | + | win.syncGen++ | |
| 994 | + | gen := win.syncGen | |
| 995 | + | win.mu.Unlock() | |
| 996 | + | if isSyncing { | |
| 997 | + | go d.syncWatchdog(win, gen) | |
| 998 | + | } | |
| 999 | + | } | |
| 963 | 1000 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | |
| 964 | 1001 | signal(win.flush) | |
| 965 | 1002 | continue | |
| @@ -1047,6 +1084,25 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||
| 1047 | 1084 | return delay | |
| 1048 | 1085 | } | |
| 1049 | 1086 | ||
| 1087 | + | func (d *Daemon) syncWatchdog(win *window, gen uint64) { | |
| 1088 | + | timer := d.clock.NewTimer(maxSyncUpdateDuration) | |
| 1089 | + | select { | |
| 1090 | + | case <-windowDone(win): | |
| 1091 | + | timer.Stop() | |
| 1092 | + | return | |
| 1093 | + | case <-timer.C(): | |
| 1094 | + | } | |
| 1095 | + | ||
| 1096 | + | win.mu.Lock() | |
| 1097 | + | if win.syncGen != gen || !win.screen.SyncUpdateActive() { | |
| 1098 | + | win.mu.Unlock() | |
| 1099 | + | return | |
| 1100 | + | } | |
| 1101 | + | win.screen.ForceSyncEnd() | |
| 1102 | + | win.mu.Unlock() | |
| 1103 | + | signal(win.flush) | |
| 1104 | + | } | |
| 1105 | + | ||
| 1050 | 1106 | func windowDone(win *window) <-chan struct{} { | |
| 1051 | 1107 | if win.ctx == nil { | |
| 1052 | 1108 | return nil | |
| @@ -49,9 +49,10 @@ import ( | |||||
| 49 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 49 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| 50 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | 50 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | ||
| 51 | const ( | 51 | const ( | ||
| 52 | - | minDebounceInterval = 2 * time.Millisecond | 52 | + | minDebounceInterval = 2 * time.Millisecond |
| 53 | - | maxDebounceInterval = 16 * time.Millisecond | 53 | + | maxDebounceInterval = 16 * time.Millisecond |
| 54 | - | debounceStep = 2 * time.Millisecond | 54 | + | debounceStep = 2 * time.Millisecond |
| 55 | + | maxSyncUpdateDuration = 500 * time.Millisecond | |||
| 55 | ) | 56 | ) | ||
| 56 | 57 | ||||
| 57 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | 58 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | ||
| @@ -135,11 +136,12 @@ type session struct { | |||||
| 135 | // already pending"), never by blocking the reader. | 136 | // already pending"), never by blocking the reader. | ||
| 136 | type window struct { | 137 | type window struct { | ||
| 137 | pty ports.PTY | 138 | pty ports.PTY | ||
| 138 | - | mu sync.Mutex // guards screen and every attached renderer's shadow | 139 | + | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow |
| 139 | screen *vt.Screen | 140 | screen *vt.Screen | ||
| 140 | scrollback *scopy.Scrollback | 141 | scrollback *scopy.Scrollback | ||
| 141 | dirty chan struct{} | 142 | dirty chan struct{} | ||
| 142 | flush chan struct{} | 143 | flush chan struct{} | ||
| 144 | + | syncGen uint64 | |||
| 143 | size domain.Size | 145 | size domain.Size | ||
| 144 | ctx context.Context | 146 | ctx context.Context | ||
| 145 | cancel context.CancelFunc | 147 | cancel context.CancelFunc | ||
| @@ -151,13 +153,14 @@ type window struct { | |||||
| 151 | // scheduler and the connection handler — so the transport's single-writer | 153 | // scheduler and the connection handler — so the transport's single-writer | ||
| 152 | // contract holds and Draw→Send stays atomic (correct output ordering). | 154 | // contract holds and Draw→Send stays atomic (correct output ordering). | ||
| 153 | type attachedClient struct { | 155 | type attachedClient struct { | ||
| 154 | - | tr ports.Transport | 156 | + | tr ports.Transport |
| 155 | - | rend *renderer.Renderer | 157 | + | rend *renderer.Renderer |
| 156 | - | size domain.Size | 158 | + | size domain.Size |
| 157 | - | keys *keys.Router | 159 | + | keys *keys.Router |
| 158 | - | copyMu sync.Mutex | 160 | + | copyMu sync.Mutex |
| 159 | - | copyMode *scopy.Mode | 161 | + | copyMode *scopy.Mode |
| 160 | - | sendMu sync.Mutex | 162 | + | copyPending []byte |
| 163 | + | sendMu sync.Mutex | |||
| 161 | } | 164 | } | ||
| 162 | 165 | ||||
| 163 | func (ac *attachedClient) copyModeActive() bool { | 166 | func (ac *attachedClient) copyModeActive() bool { | ||
| @@ -700,9 +703,6 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||||
| 700 | d.handleCopyInput(sess, ac, data) | 703 | d.handleCopyInput(sess, ac, data) | ||
| 701 | return | 704 | return | ||
| 702 | } | 705 | } | ||
| 703 | - | if ac.keys == nil { | |||
| 704 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |||
| 705 | - | } | |||
| 706 | ac.keys.Route(data) | 706 | ac.keys.Route(data) | ||
| 707 | } | 707 | } | ||
| 708 | 708 | ||||
| @@ -729,10 +729,18 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||||
| 729 | win.mu.Lock() | 729 | win.mu.Lock() | ||
| 730 | ac.copyMu.Lock() | 730 | ac.copyMu.Lock() | ||
| 731 | if ac.copyMode == nil { | 731 | if ac.copyMode == nil { | ||
| 732 | + | ac.copyPending = nil | |||
| 732 | ac.copyMu.Unlock() | 733 | ac.copyMu.Unlock() | ||
| 733 | win.mu.Unlock() | 734 | win.mu.Unlock() | ||
| 734 | return | 735 | return | ||
| 735 | } | 736 | } | ||
| 737 | + | if len(ac.copyPending) > 0 { | |||
| 738 | + | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | |||
| 739 | + | combined = append(combined, ac.copyPending...) | |||
| 740 | + | combined = append(combined, data...) | |||
| 741 | + | data = combined | |||
| 742 | + | ac.copyPending = nil | |||
| 743 | + | } | |||
| 736 | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | 744 | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | ||
| 737 | changed := false | 745 | changed := false | ||
| 738 | copyOut := false | 746 | copyOut := false | ||
| @@ -759,12 +767,17 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||||
| 759 | exit = true | 767 | exit = true | ||
| 760 | case 'q', 0x03, 0x1b: | 768 | case 'q', 0x03, 0x1b: | ||
| 761 | if data[i] == 0x1b { | 769 | if data[i] == 0x1b { | ||
| 762 | - | consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:]) | 770 | + | tail := data[i:] |
| 771 | + | consumed, ok := routeCopyEscape(ac.copyMode, snap, tail) | |||
| 763 | if ok { | 772 | if ok { | ||
| 764 | i += consumed - 1 | 773 | i += consumed - 1 | ||
| 765 | changed = true | 774 | changed = true | ||
| 766 | continue | 775 | continue | ||
| 767 | } | 776 | } | ||
| 777 | + | if isCopyEscapePrefix(tail) { | |||
| 778 | + | ac.copyPending = append(ac.copyPending[:0], tail...) | |||
| 779 | + | break | |||
| 780 | + | } | |||
| 768 | } | 781 | } | ||
| 769 | exit = true | 782 | exit = true | ||
| 770 | } | 783 | } | ||
| @@ -779,7 +792,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||||
| 779 | ac.copyMu.Unlock() | 792 | ac.copyMu.Unlock() | ||
| 780 | win.mu.Unlock() | 793 | win.mu.Unlock() | ||
| 781 | 794 | ||||
| 782 | - | if copyOut { | 795 | + | if copyOut && text != "" { |
| 783 | for _, chunk := range scopy.OSC52(text) { | 796 | for _, chunk := range scopy.OSC52(text) { | ||
| 784 | if err := ac.send(frameOutput(chunk)); err != nil { | 797 | if err := ac.send(frameOutput(chunk)); err != nil { | ||
| 785 | d.detachOnSendError(sess, ac) | 798 | d.detachOnSendError(sess, ac) | ||
| @@ -820,6 +833,11 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool | |||||
| 820 | return 0, false | 833 | return 0, false | ||
| 821 | } | 834 | } | ||
| 822 | 835 | ||||
| 836 | + | func isCopyEscapePrefix(data []byte) bool { | |||
| 837 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' || | |||
| 838 | + | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |||
| 839 | + | } | |||
| 840 | + | ||||
| 823 | type daemonKeyHandler struct { | 841 | type daemonKeyHandler struct { | ||
| 824 | d *Daemon | 842 | d *Daemon | ||
| 825 | sess *session | 843 | sess *session | ||
| @@ -951,6 +969,10 @@ func signal(ch chan struct{}) { | |||||
| 951 | func (d *Daemon) ptyReader(sess *session, win *window) { | 969 | func (d *Daemon) ptyReader(sess *session, win *window) { | ||
| 952 | defer d.sessWg.Done() | 970 | defer d.sessWg.Done() | ||
| 953 | buf := make([]byte, ptyReadBufSize) | 971 | buf := make([]byte, ptyReadBufSize) | ||
| 972 | + | var resp []byte | |||
| 973 | + | win.mu.Lock() | |||
| 974 | + | win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |||
| 975 | + | win.mu.Unlock() | |||
| 954 | for { | 976 | for { | ||
| 955 | n, err := win.pty.Read(buf) | 977 | n, err := win.pty.Read(buf) | ||
| 956 | if n > 0 { | 978 | if n > 0 { | ||
| @@ -960,6 +982,21 @@ func (d *Daemon) ptyReader(sess *session, win *window) { | |||||
| 960 | win.screen.Write(data) | 982 | win.screen.Write(data) | ||
| 961 | isSyncing := win.screen.SyncUpdateActive() | 983 | isSyncing := win.screen.SyncUpdateActive() | ||
| 962 | win.mu.Unlock() | 984 | win.mu.Unlock() | ||
| 985 | + | if len(resp) > 0 { | |||
| 986 | + | if _, writeErr := win.pty.Write(resp); writeErr != nil { | |||
| 987 | + | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |||
| 988 | + | } | |||
| 989 | + | resp = resp[:0] | |||
| 990 | + | } | |||
| 991 | + | if wasSyncing != isSyncing { | |||
| 992 | + | win.mu.Lock() | |||
| 993 | + | win.syncGen++ | |||
| 994 | + | gen := win.syncGen | |||
| 995 | + | win.mu.Unlock() | |||
| 996 | + | if isSyncing { | |||
| 997 | + | go d.syncWatchdog(win, gen) | |||
| 998 | + | } | |||
| 999 | + | } | |||
| 963 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | 1000 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | ||
| 964 | signal(win.flush) | 1001 | signal(win.flush) | ||
| 965 | continue | 1002 | continue | ||
| @@ -1047,6 +1084,25 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||||
| 1047 | return delay | 1084 | return delay | ||
| 1048 | } | 1085 | } | ||
| 1049 | 1086 | ||||
| 1087 | + | func (d *Daemon) syncWatchdog(win *window, gen uint64) { | |||
| 1088 | + | timer := d.clock.NewTimer(maxSyncUpdateDuration) | |||
| 1089 | + | select { | |||
| 1090 | + | case <-windowDone(win): | |||
| 1091 | + | timer.Stop() | |||
| 1092 | + | return | |||
| 1093 | + | case <-timer.C(): | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 1096 | + | win.mu.Lock() | |||
| 1097 | + | if win.syncGen != gen || !win.screen.SyncUpdateActive() { | |||
| 1098 | + | win.mu.Unlock() | |||
| 1099 | + | return | |||
| 1100 | + | } | |||
| 1101 | + | win.screen.ForceSyncEnd() | |||
| 1102 | + | win.mu.Unlock() | |||
| 1103 | + | signal(win.flush) | |||
| 1104 | + | } | |||
| 1105 | + | ||||
| 1050 | func windowDone(win *window) <-chan struct{} { | 1106 | func windowDone(win *window) <-chan struct{} { | ||
| 1051 | if win.ctx == nil { | 1107 | if win.ctx == nil { | ||
| 1052 | return nil | 1108 | return nil | ||
M internal/usecase/daemon/daemon_test.go +297 -2
| @@ -3,7 +3,9 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 7 | 9 | "io" | |
| 8 | 10 | "log/slog" | |
| 9 | 11 | "strings" | |
| @@ -19,6 +21,7 @@ import ( | |||
| 19 | 21 | "github.com/bnema/vev/internal/ports" | |
| 20 | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 21 | 23 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 24 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 22 | 25 | "github.com/bnema/vev/pkg/renderer" | |
| 23 | 26 | "github.com/bnema/vev/pkg/vt" | |
| 24 | 27 | ) | |
| @@ -41,15 +44,31 @@ func (stubTimer) Stop() bool { return true } | |||
| 41 | 44 | ||
| 42 | 45 | type signalClock struct { | |
| 43 | 46 | called chan struct{} | |
| 47 | + | timers chan *signalTimer | |
| 44 | 48 | once sync.Once | |
| 45 | 49 | } | |
| 46 | 50 | ||
| 47 | 51 | func (c *signalClock) Now() time.Time { return time.Time{} } | |
| 48 | 52 | func (c *signalClock) NewTimer(time.Duration) ports.Timer { | |
| 49 | - | c.once.Do(func() { close(c.called) }) | |
| 50 | - | return stubTimer{} | |
| 53 | + | if c.called != nil { | |
| 54 | + | c.once.Do(func() { close(c.called) }) | |
| 55 | + | } | |
| 56 | + | if c.timers == nil { | |
| 57 | + | return stubTimer{} | |
| 58 | + | } | |
| 59 | + | t := &signalTimer{ch: make(chan time.Time, 1)} | |
| 60 | + | c.timers <- t | |
| 61 | + | return t | |
| 51 | 62 | } | |
| 52 | 63 | ||
| 64 | + | type signalTimer struct { | |
| 65 | + | ch chan time.Time | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (t *signalTimer) C() <-chan time.Time { return t.ch } | |
| 69 | + | func (t *signalTimer) Reset(time.Duration) bool { return false } | |
| 70 | + | func (t *signalTimer) Stop() bool { return true } | |
| 71 | + | ||
| 53 | 72 | func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | |
| 54 | 73 | t.Helper() | |
| 55 | 74 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| @@ -161,6 +180,14 @@ func frameInput(data []byte) ports.Frame { | |||
| 161 | 180 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | |
| 162 | 181 | } | |
| 163 | 182 | ||
| 183 | + | func screenLineText(s *vt.Screen, y int) string { | |
| 184 | + | out := make([]rune, s.Frame.Width) | |
| 185 | + | for x := range s.Frame.Width { | |
| 186 | + | out[x] = s.Frame.At(x, y).Rune | |
| 187 | + | } | |
| 188 | + | return string(out) | |
| 189 | + | } | |
| 190 | + | ||
| 164 | 191 | // awaitFrame waits for the next frame of type typ on ch, failing on timeout. | |
| 165 | 192 | func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame { | |
| 166 | 193 | t.Helper() | |
| @@ -226,6 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 226 | 253 | windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | |
| 227 | 254 | } | |
| 228 | 255 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | |
| 256 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 229 | 257 | d.sessions[sess.id] = sess | |
| 230 | 258 | t.Cleanup(cancel) | |
| 231 | 259 | return d, sess, ac, sends | |
| @@ -339,6 +367,76 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 339 | 367 | } | |
| 340 | 368 | } | |
| 341 | 369 | ||
| 370 | + | func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |
| 371 | + | p := portsmocks.NewMockPTY(t) | |
| 372 | + | reads := make(chan []byte, 64) | |
| 373 | + | readDone := make(chan struct{}) | |
| 374 | + | var closeOnce sync.Once | |
| 375 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |
| 376 | + | select { | |
| 377 | + | case data := <-reads: | |
| 378 | + | return copy(b, data), nil | |
| 379 | + | case <-readDone: | |
| 380 | + | return 0, io.EOF | |
| 381 | + | } | |
| 382 | + | }).Maybe() | |
| 383 | + | p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe() | |
| 384 | + | p.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 385 | + | p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe() | |
| 386 | + | p.EXPECT().Pid().Return(4242).Maybe() | |
| 387 | + | ||
| 388 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 389 | + | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5})) | |
| 390 | + | var hg sync.WaitGroup | |
| 391 | + | hg.Go(func() { d.handleConn(tr) }) | |
| 392 | + | awaitFrame(t, sends, ports.MsgWelcome) | |
| 393 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 394 | + | ||
| 395 | + | for i := range 12 { | |
| 396 | + | reads <- fmt.Appendf(nil, "line-%02d\r\n", i) | |
| 397 | + | } | |
| 398 | + | require.Eventually(t, func() bool { | |
| 399 | + | sess := firstSession(d) | |
| 400 | + | if sess == nil { | |
| 401 | + | return false | |
| 402 | + | } | |
| 403 | + | win := sess.activeWindow() | |
| 404 | + | if win == nil { | |
| 405 | + | return false | |
| 406 | + | } | |
| 407 | + | win.mu.Lock() | |
| 408 | + | defer win.mu.Unlock() | |
| 409 | + | return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12 | |
| 410 | + | }, 2*time.Second, 5*time.Millisecond) | |
| 411 | + | ||
| 412 | + | sess := firstSession(d) | |
| 413 | + | require.NotNil(t, sess) | |
| 414 | + | ac := sess.client | |
| 415 | + | require.NotNil(t, ac) | |
| 416 | + | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 417 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 418 | + | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |
| 419 | + | ||
| 420 | + | var payload string | |
| 421 | + | require.Eventually(t, func() bool { | |
| 422 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 423 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 424 | + | require.NoError(t, err) | |
| 425 | + | payload = string(msg.Data) | |
| 426 | + | return strings.HasPrefix(payload, "\x1b]52;c;") | |
| 427 | + | }, 2*time.Second, 5*time.Millisecond) | |
| 428 | + | require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload) | |
| 429 | + | require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload) | |
| 430 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a")) | |
| 431 | + | require.NoError(t, err) | |
| 432 | + | require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded)) | |
| 433 | + | ||
| 434 | + | releaseConn() | |
| 435 | + | closeOnce.Do(func() { close(readDone) }) | |
| 436 | + | hg.Wait() | |
| 437 | + | d.sessWg.Wait() | |
| 438 | + | } | |
| 439 | + | ||
| 342 | 440 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 343 | 441 | p, _ := newBlockingPTY(t) | |
| 344 | 442 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -360,6 +458,75 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |||
| 360 | 458 | } | |
| 361 | 459 | } | |
| 362 | 460 | ||
| 461 | + | func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |
| 462 | + | cases := []struct { | |
| 463 | + | name string | |
| 464 | + | firstInput []byte | |
| 465 | + | secondInput []byte | |
| 466 | + | wantActive bool | |
| 467 | + | }{ | |
| 468 | + | {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true}, | |
| 469 | + | {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true}, | |
| 470 | + | {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false}, | |
| 471 | + | } | |
| 472 | + | for _, tc := range cases { | |
| 473 | + | t.Run(tc.name, func(t *testing.T) { | |
| 474 | + | p, _ := newBlockingPTY(t) | |
| 475 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 476 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 477 | + | sess.windows[0].scrollback.Append(testRow("old1 ")) | |
| 478 | + | sess.windows[0].scrollback.Append(testRow("old2 ")) | |
| 479 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 480 | + | ||
| 481 | + | d.enterCopyMode(sess, ac) | |
| 482 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 483 | + | d.handleInput(sess, ac, tc.firstInput) | |
| 484 | + | if tc.secondInput != nil { | |
| 485 | + | d.handleInput(sess, ac, tc.secondInput) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | if gotActive := ac.copyMode != nil; gotActive != tc.wantActive { | |
| 489 | + | t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive) | |
| 490 | + | } | |
| 491 | + | }) | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |
| 496 | + | cases := []struct { | |
| 497 | + | name string | |
| 498 | + | input []byte | |
| 499 | + | }{ | |
| 500 | + | {name: "yank", input: []byte("y")}, | |
| 501 | + | {name: "enter", input: []byte("\r")}, | |
| 502 | + | } | |
| 503 | + | for _, tc := range cases { | |
| 504 | + | t.Run(tc.name, func(t *testing.T) { | |
| 505 | + | p, _ := newBlockingPTY(t) | |
| 506 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 507 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 508 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 509 | + | ||
| 510 | + | d.enterCopyMode(sess, ac) | |
| 511 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 512 | + | d.handleInput(sess, ac, tc.input) | |
| 513 | + | ||
| 514 | + | if ac.copyMode != nil { | |
| 515 | + | t.Fatal("copy mode still active after empty yank") | |
| 516 | + | } | |
| 517 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 518 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 519 | + | require.NoError(t, err) | |
| 520 | + | if strings.Contains(string(msg.Data), "\x1b]52;") { | |
| 521 | + | t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data)) | |
| 522 | + | } | |
| 523 | + | if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") { | |
| 524 | + | t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data)) | |
| 525 | + | } | |
| 526 | + | }) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | ||
| 363 | 530 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 364 | 531 | p, _ := newBlockingPTY(t) | |
| 365 | 532 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -1421,6 +1588,46 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 1421 | 1588 | d.sessWg.Wait() | |
| 1422 | 1589 | } | |
| 1423 | 1590 | ||
| 1591 | + | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |
| 1592 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1593 | + | p := portsmocks.NewMockPTY(t) | |
| 1594 | + | chunks := [][]byte{[]byte("\x1b[6n")} | |
| 1595 | + | writes := make(chan []byte, 1) | |
| 1596 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 1597 | + | if len(chunks) == 0 { | |
| 1598 | + | return 0, io.EOF | |
| 1599 | + | } | |
| 1600 | + | n := copy(buf, chunks[0]) | |
| 1601 | + | chunks = chunks[1:] | |
| 1602 | + | return n, nil | |
| 1603 | + | }) | |
| 1604 | + | p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |
| 1605 | + | writes <- append([]byte(nil), b...) | |
| 1606 | + | return len(b), nil | |
| 1607 | + | }).Once() | |
| 1608 | + | p.EXPECT().Close().Return(nil).Maybe() | |
| 1609 | + | ||
| 1610 | + | tr, sends := newCapturingTransport(t) | |
| 1611 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 1612 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1613 | + | sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1614 | + | d.sessions[sess.id] = sess | |
| 1615 | + | d.sessWg.Add(1) | |
| 1616 | + | d.ptyReader(sess, win) | |
| 1617 | + | ||
| 1618 | + | select { | |
| 1619 | + | case got := <-writes: | |
| 1620 | + | require.Equal(t, []byte("\x1b[1;1R"), got) | |
| 1621 | + | case <-time.After(time.Second): | |
| 1622 | + | t.Fatal("timed out waiting for PTY response write") | |
| 1623 | + | } | |
| 1624 | + | select { | |
| 1625 | + | case f := <-sends: | |
| 1626 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |
| 1627 | + | default: | |
| 1628 | + | } | |
| 1629 | + | } | |
| 1630 | + | ||
| 1424 | 1631 | func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) { | |
| 1425 | 1632 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1426 | 1633 | p := portsmocks.NewMockPTY(t) | |
| @@ -1486,3 +1693,91 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing. | |||
| 1486 | 1693 | t.Fatal("sync end did not request immediate flush") | |
| 1487 | 1694 | } | |
| 1488 | 1695 | } | |
| 1696 | + | ||
| 1697 | + | func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |
| 1698 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1699 | + | d := newTestDaemon(t, nil, clk) | |
| 1700 | + | p := portsmocks.NewMockPTY(t) | |
| 1701 | + | release := make(chan struct{}) | |
| 1702 | + | read := 0 | |
| 1703 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 1704 | + | if read == 0 { | |
| 1705 | + | read++ | |
| 1706 | + | return copy(buf, []byte("\x1b[?2026habandoned")), nil | |
| 1707 | + | } | |
| 1708 | + | <-release | |
| 1709 | + | return 0, io.EOF | |
| 1710 | + | }) | |
| 1711 | + | p.EXPECT().Close().Return(nil).Maybe() | |
| 1712 | + | ||
| 1713 | + | tr, sends := newCapturingTransport(t) | |
| 1714 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 1715 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1716 | + | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1717 | + | d.sessions[sess.id] = sess | |
| 1718 | + | d.sessWg.Add(2) | |
| 1719 | + | go d.scheduler(sess, win) | |
| 1720 | + | go d.ptyReader(sess, win) | |
| 1721 | + | ||
| 1722 | + | timer := <-clk.timers | |
| 1723 | + | timer.ch <- time.Now() | |
| 1724 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1725 | + | ||
| 1726 | + | win.mu.Lock() | |
| 1727 | + | active := win.screen.SyncUpdateActive() | |
| 1728 | + | got := screenLineText(win.screen, 0) | |
| 1729 | + | win.mu.Unlock() | |
| 1730 | + | require.False(t, active) | |
| 1731 | + | require.Contains(t, got, "abandoned") | |
| 1732 | + | ||
| 1733 | + | close(release) | |
| 1734 | + | cancel() | |
| 1735 | + | d.sessWg.Wait() | |
| 1736 | + | } | |
| 1737 | + | ||
| 1738 | + | func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |
| 1739 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1740 | + | d := newTestDaemon(t, nil, clk) | |
| 1741 | + | p := portsmocks.NewMockPTY(t) | |
| 1742 | + | release := make(chan struct{}) | |
| 1743 | + | chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")} | |
| 1744 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 1745 | + | if len(chunks) > 0 { | |
| 1746 | + | n := copy(buf, chunks[0]) | |
| 1747 | + | chunks = chunks[1:] | |
| 1748 | + | return n, nil | |
| 1749 | + | } | |
| 1750 | + | <-release | |
| 1751 | + | return 0, io.EOF | |
| 1752 | + | }) | |
| 1753 | + | p.EXPECT().Close().Return(nil).Maybe() | |
| 1754 | + | ||
| 1755 | + | tr, sends := newCapturingTransport(t) | |
| 1756 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 1757 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1758 | + | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1759 | + | d.sessions[sess.id] = sess | |
| 1760 | + | d.sessWg.Add(2) | |
| 1761 | + | go d.scheduler(sess, win) | |
| 1762 | + | go d.ptyReader(sess, win) | |
| 1763 | + | ||
| 1764 | + | timer := <-clk.timers | |
| 1765 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1766 | + | timer.ch <- time.Now() | |
| 1767 | + | ||
| 1768 | + | select { | |
| 1769 | + | case f := <-sends: | |
| 1770 | + | if f.Type == ports.MsgOutput { | |
| 1771 | + | t.Fatal("stale synchronized update watchdog produced an extra render") | |
| 1772 | + | } | |
| 1773 | + | case <-time.After(50 * time.Millisecond): | |
| 1774 | + | } | |
| 1775 | + | win.mu.Lock() | |
| 1776 | + | active := win.screen.SyncUpdateActive() | |
| 1777 | + | win.mu.Unlock() | |
| 1778 | + | require.False(t, active) | |
| 1779 | + | ||
| 1780 | + | close(release) | |
| 1781 | + | cancel() | |
| 1782 | + | d.sessWg.Wait() | |
| 1783 | + | } | |
| @@ -3,7 +3,9 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "encoding/base64" | |||
| 6 | "errors" | 7 | "errors" | ||
| 8 | + | "fmt" | |||
| 7 | "io" | 9 | "io" | ||
| 8 | "log/slog" | 10 | "log/slog" | ||
| 9 | "strings" | 11 | "strings" | ||
| @@ -19,6 +21,7 @@ import ( | |||||
| 19 | "github.com/bnema/vev/internal/ports" | 21 | "github.com/bnema/vev/internal/ports" | ||
| 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 21 | scopy "github.com/bnema/vev/internal/usecase/copy" | 23 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 24 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 22 | "github.com/bnema/vev/pkg/renderer" | 25 | "github.com/bnema/vev/pkg/renderer" | ||
| 23 | "github.com/bnema/vev/pkg/vt" | 26 | "github.com/bnema/vev/pkg/vt" | ||
| 24 | ) | 27 | ) | ||
| @@ -41,15 +44,31 @@ func (stubTimer) Stop() bool { return true } | |||||
| 41 | 44 | ||||
| 42 | type signalClock struct { | 45 | type signalClock struct { | ||
| 43 | called chan struct{} | 46 | called chan struct{} | ||
| 47 | + | timers chan *signalTimer | |||
| 44 | once sync.Once | 48 | once sync.Once | ||
| 45 | } | 49 | } | ||
| 46 | 50 | ||||
| 47 | func (c *signalClock) Now() time.Time { return time.Time{} } | 51 | func (c *signalClock) Now() time.Time { return time.Time{} } | ||
| 48 | func (c *signalClock) NewTimer(time.Duration) ports.Timer { | 52 | func (c *signalClock) NewTimer(time.Duration) ports.Timer { | ||
| 49 | - | c.once.Do(func() { close(c.called) }) | 53 | + | if c.called != nil { |
| 50 | - | return stubTimer{} | 54 | + | c.once.Do(func() { close(c.called) }) |
| 55 | + | } | |||
| 56 | + | if c.timers == nil { | |||
| 57 | + | return stubTimer{} | |||
| 58 | + | } | |||
| 59 | + | t := &signalTimer{ch: make(chan time.Time, 1)} | |||
| 60 | + | c.timers <- t | |||
| 61 | + | return t | |||
| 51 | } | 62 | } | ||
| 52 | 63 | ||||
| 64 | + | type signalTimer struct { | |||
| 65 | + | ch chan time.Time | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (t *signalTimer) C() <-chan time.Time { return t.ch } | |||
| 69 | + | func (t *signalTimer) Reset(time.Duration) bool { return false } | |||
| 70 | + | func (t *signalTimer) Stop() bool { return true } | |||
| 71 | + | ||||
| 53 | func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | 72 | func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | ||
| 54 | t.Helper() | 73 | t.Helper() | ||
| 55 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | 74 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| @@ -161,6 +180,14 @@ func frameInput(data []byte) ports.Frame { | |||||
| 161 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | 180 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | ||
| 162 | } | 181 | } | ||
| 163 | 182 | ||||
| 183 | + | func screenLineText(s *vt.Screen, y int) string { | |||
| 184 | + | out := make([]rune, s.Frame.Width) | |||
| 185 | + | for x := range s.Frame.Width { | |||
| 186 | + | out[x] = s.Frame.At(x, y).Rune | |||
| 187 | + | } | |||
| 188 | + | return string(out) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 164 | // awaitFrame waits for the next frame of type typ on ch, failing on timeout. | 191 | // awaitFrame waits for the next frame of type typ on ch, failing on timeout. | ||
| 165 | func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame { | 192 | func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame { | ||
| 166 | t.Helper() | 193 | t.Helper() | ||
| @@ -226,6 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 226 | windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | 253 | windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | ||
| 227 | } | 254 | } | ||
| 228 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | 255 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | ||
| 256 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |||
| 229 | d.sessions[sess.id] = sess | 257 | d.sessions[sess.id] = sess | ||
| 230 | t.Cleanup(cancel) | 258 | t.Cleanup(cancel) | ||
| 231 | return d, sess, ac, sends | 259 | return d, sess, ac, sends | ||
| @@ -339,6 +367,76 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 339 | } | 367 | } | ||
| 340 | } | 368 | } | ||
| 341 | 369 | ||||
| 370 | + | func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 371 | + | p := portsmocks.NewMockPTY(t) | |||
| 372 | + | reads := make(chan []byte, 64) | |||
| 373 | + | readDone := make(chan struct{}) | |||
| 374 | + | var closeOnce sync.Once | |||
| 375 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |||
| 376 | + | select { | |||
| 377 | + | case data := <-reads: | |||
| 378 | + | return copy(b, data), nil | |||
| 379 | + | case <-readDone: | |||
| 380 | + | return 0, io.EOF | |||
| 381 | + | } | |||
| 382 | + | }).Maybe() | |||
| 383 | + | p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe() | |||
| 384 | + | p.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 385 | + | p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe() | |||
| 386 | + | p.EXPECT().Pid().Return(4242).Maybe() | |||
| 387 | + | ||||
| 388 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 389 | + | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5})) | |||
| 390 | + | var hg sync.WaitGroup | |||
| 391 | + | hg.Go(func() { d.handleConn(tr) }) | |||
| 392 | + | awaitFrame(t, sends, ports.MsgWelcome) | |||
| 393 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 394 | + | ||||
| 395 | + | for i := range 12 { | |||
| 396 | + | reads <- fmt.Appendf(nil, "line-%02d\r\n", i) | |||
| 397 | + | } | |||
| 398 | + | require.Eventually(t, func() bool { | |||
| 399 | + | sess := firstSession(d) | |||
| 400 | + | if sess == nil { | |||
| 401 | + | return false | |||
| 402 | + | } | |||
| 403 | + | win := sess.activeWindow() | |||
| 404 | + | if win == nil { | |||
| 405 | + | return false | |||
| 406 | + | } | |||
| 407 | + | win.mu.Lock() | |||
| 408 | + | defer win.mu.Unlock() | |||
| 409 | + | return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12 | |||
| 410 | + | }, 2*time.Second, 5*time.Millisecond) | |||
| 411 | + | ||||
| 412 | + | sess := firstSession(d) | |||
| 413 | + | require.NotNil(t, sess) | |||
| 414 | + | ac := sess.client | |||
| 415 | + | require.NotNil(t, ac) | |||
| 416 | + | d.handleInput(sess, ac, []byte("\x1bu")) | |||
| 417 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 418 | + | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |||
| 419 | + | ||||
| 420 | + | var payload string | |||
| 421 | + | require.Eventually(t, func() bool { | |||
| 422 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 423 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 424 | + | require.NoError(t, err) | |||
| 425 | + | payload = string(msg.Data) | |||
| 426 | + | return strings.HasPrefix(payload, "\x1b]52;c;") | |||
| 427 | + | }, 2*time.Second, 5*time.Millisecond) | |||
| 428 | + | require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload) | |||
| 429 | + | require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload) | |||
| 430 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a")) | |||
| 431 | + | require.NoError(t, err) | |||
| 432 | + | require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded)) | |||
| 433 | + | ||||
| 434 | + | releaseConn() | |||
| 435 | + | closeOnce.Do(func() { close(readDone) }) | |||
| 436 | + | hg.Wait() | |||
| 437 | + | d.sessWg.Wait() | |||
| 438 | + | } | |||
| 439 | + | ||||
| 342 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | 440 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | ||
| 343 | p, _ := newBlockingPTY(t) | 441 | p, _ := newBlockingPTY(t) | ||
| 344 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 442 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -360,6 +458,75 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |||||
| 360 | } | 458 | } | ||
| 361 | } | 459 | } | ||
| 362 | 460 | ||||
| 461 | + | func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 462 | + | cases := []struct { | |||
| 463 | + | name string | |||
| 464 | + | firstInput []byte | |||
| 465 | + | secondInput []byte | |||
| 466 | + | wantActive bool | |||
| 467 | + | }{ | |||
| 468 | + | {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true}, | |||
| 469 | + | {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true}, | |||
| 470 | + | {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false}, | |||
| 471 | + | } | |||
| 472 | + | for _, tc := range cases { | |||
| 473 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 474 | + | p, _ := newBlockingPTY(t) | |||
| 475 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 476 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |||
| 477 | + | sess.windows[0].scrollback.Append(testRow("old1 ")) | |||
| 478 | + | sess.windows[0].scrollback.Append(testRow("old2 ")) | |||
| 479 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 480 | + | ||||
| 481 | + | d.enterCopyMode(sess, ac) | |||
| 482 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 483 | + | d.handleInput(sess, ac, tc.firstInput) | |||
| 484 | + | if tc.secondInput != nil { | |||
| 485 | + | d.handleInput(sess, ac, tc.secondInput) | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | if gotActive := ac.copyMode != nil; gotActive != tc.wantActive { | |||
| 489 | + | t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive) | |||
| 490 | + | } | |||
| 491 | + | }) | |||
| 492 | + | } | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 496 | + | cases := []struct { | |||
| 497 | + | name string | |||
| 498 | + | input []byte | |||
| 499 | + | }{ | |||
| 500 | + | {name: "yank", input: []byte("y")}, | |||
| 501 | + | {name: "enter", input: []byte("\r")}, | |||
| 502 | + | } | |||
| 503 | + | for _, tc := range cases { | |||
| 504 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 505 | + | p, _ := newBlockingPTY(t) | |||
| 506 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 507 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |||
| 508 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 509 | + | ||||
| 510 | + | d.enterCopyMode(sess, ac) | |||
| 511 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 512 | + | d.handleInput(sess, ac, tc.input) | |||
| 513 | + | ||||
| 514 | + | if ac.copyMode != nil { | |||
| 515 | + | t.Fatal("copy mode still active after empty yank") | |||
| 516 | + | } | |||
| 517 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 518 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 519 | + | require.NoError(t, err) | |||
| 520 | + | if strings.Contains(string(msg.Data), "\x1b]52;") { | |||
| 521 | + | t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data)) | |||
| 522 | + | } | |||
| 523 | + | if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") { | |||
| 524 | + | t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data)) | |||
| 525 | + | } | |||
| 526 | + | }) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | ||||
| 363 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | 530 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | ||
| 364 | p, _ := newBlockingPTY(t) | 531 | p, _ := newBlockingPTY(t) | ||
| 365 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 532 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -1421,6 +1588,46 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 1421 | d.sessWg.Wait() | 1588 | d.sessWg.Wait() | ||
| 1422 | } | 1589 | } | ||
| 1423 | 1590 | ||||
| 1591 | + | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1592 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1593 | + | p := portsmocks.NewMockPTY(t) | |||
| 1594 | + | chunks := [][]byte{[]byte("\x1b[6n")} | |||
| 1595 | + | writes := make(chan []byte, 1) | |||
| 1596 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1597 | + | if len(chunks) == 0 { | |||
| 1598 | + | return 0, io.EOF | |||
| 1599 | + | } | |||
| 1600 | + | n := copy(buf, chunks[0]) | |||
| 1601 | + | chunks = chunks[1:] | |||
| 1602 | + | return n, nil | |||
| 1603 | + | }) | |||
| 1604 | + | p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |||
| 1605 | + | writes <- append([]byte(nil), b...) | |||
| 1606 | + | return len(b), nil | |||
| 1607 | + | }).Once() | |||
| 1608 | + | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1609 | + | ||||
| 1610 | + | tr, sends := newCapturingTransport(t) | |||
| 1611 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1612 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |||
| 1613 | + | sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |||
| 1614 | + | d.sessions[sess.id] = sess | |||
| 1615 | + | d.sessWg.Add(1) | |||
| 1616 | + | d.ptyReader(sess, win) | |||
| 1617 | + | ||||
| 1618 | + | select { | |||
| 1619 | + | case got := <-writes: | |||
| 1620 | + | require.Equal(t, []byte("\x1b[1;1R"), got) | |||
| 1621 | + | case <-time.After(time.Second): | |||
| 1622 | + | t.Fatal("timed out waiting for PTY response write") | |||
| 1623 | + | } | |||
| 1624 | + | select { | |||
| 1625 | + | case f := <-sends: | |||
| 1626 | + | require.NotEqual(t, ports.MsgOutput, f.Type) | |||
| 1627 | + | default: | |||
| 1628 | + | } | |||
| 1629 | + | } | |||
| 1630 | + | ||||
| 1424 | func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) { | 1631 | func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) { | ||
| 1425 | d := newTestDaemon(t, nil, stubClock{}) | 1632 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 1426 | p := portsmocks.NewMockPTY(t) | 1633 | p := portsmocks.NewMockPTY(t) | ||
| @@ -1486,3 +1693,91 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing. | |||||
| 1486 | t.Fatal("sync end did not request immediate flush") | 1693 | t.Fatal("sync end did not request immediate flush") | ||
| 1487 | } | 1694 | } | ||
| 1488 | } | 1695 | } | ||
| 1696 | + | ||||
| 1697 | + | func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||
| 1698 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1699 | + | d := newTestDaemon(t, nil, clk) | |||
| 1700 | + | p := portsmocks.NewMockPTY(t) | |||
| 1701 | + | release := make(chan struct{}) | |||
| 1702 | + | read := 0 | |||
| 1703 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1704 | + | if read == 0 { | |||
| 1705 | + | read++ | |||
| 1706 | + | return copy(buf, []byte("\x1b[?2026habandoned")), nil | |||
| 1707 | + | } | |||
| 1708 | + | <-release | |||
| 1709 | + | return 0, io.EOF | |||
| 1710 | + | }) | |||
| 1711 | + | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1712 | + | ||||
| 1713 | + | tr, sends := newCapturingTransport(t) | |||
| 1714 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1715 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |||
| 1716 | + | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |||
| 1717 | + | d.sessions[sess.id] = sess | |||
| 1718 | + | d.sessWg.Add(2) | |||
| 1719 | + | go d.scheduler(sess, win) | |||
| 1720 | + | go d.ptyReader(sess, win) | |||
| 1721 | + | ||||
| 1722 | + | timer := <-clk.timers | |||
| 1723 | + | timer.ch <- time.Now() | |||
| 1724 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1725 | + | ||||
| 1726 | + | win.mu.Lock() | |||
| 1727 | + | active := win.screen.SyncUpdateActive() | |||
| 1728 | + | got := screenLineText(win.screen, 0) | |||
| 1729 | + | win.mu.Unlock() | |||
| 1730 | + | require.False(t, active) | |||
| 1731 | + | require.Contains(t, got, "abandoned") | |||
| 1732 | + | ||||
| 1733 | + | close(release) | |||
| 1734 | + | cancel() | |||
| 1735 | + | d.sessWg.Wait() | |||
| 1736 | + | } | |||
| 1737 | + | ||||
| 1738 | + | func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||
| 1739 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1740 | + | d := newTestDaemon(t, nil, clk) | |||
| 1741 | + | p := portsmocks.NewMockPTY(t) | |||
| 1742 | + | release := make(chan struct{}) | |||
| 1743 | + | chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")} | |||
| 1744 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1745 | + | if len(chunks) > 0 { | |||
| 1746 | + | n := copy(buf, chunks[0]) | |||
| 1747 | + | chunks = chunks[1:] | |||
| 1748 | + | return n, nil | |||
| 1749 | + | } | |||
| 1750 | + | <-release | |||
| 1751 | + | return 0, io.EOF | |||
| 1752 | + | }) | |||
| 1753 | + | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1754 | + | ||||
| 1755 | + | tr, sends := newCapturingTransport(t) | |||
| 1756 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1757 | + | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |||
| 1758 | + | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |||
| 1759 | + | d.sessions[sess.id] = sess | |||
| 1760 | + | d.sessWg.Add(2) | |||
| 1761 | + | go d.scheduler(sess, win) | |||
| 1762 | + | go d.ptyReader(sess, win) | |||
| 1763 | + | ||||
| 1764 | + | timer := <-clk.timers | |||
| 1765 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1766 | + | timer.ch <- time.Now() | |||
| 1767 | + | ||||
| 1768 | + | select { | |||
| 1769 | + | case f := <-sends: | |||
| 1770 | + | if f.Type == ports.MsgOutput { | |||
| 1771 | + | t.Fatal("stale synchronized update watchdog produced an extra render") | |||
| 1772 | + | } | |||
| 1773 | + | case <-time.After(50 * time.Millisecond): | |||
| 1774 | + | } | |||
| 1775 | + | win.mu.Lock() | |||
| 1776 | + | active := win.screen.SyncUpdateActive() | |||
| 1777 | + | win.mu.Unlock() | |||
| 1778 | + | require.False(t, active) | |||
| 1779 | + | ||||
| 1780 | + | close(release) | |||
| 1781 | + | cancel() | |||
| 1782 | + | d.sessWg.Wait() | |||
| 1783 | + | } | |||
M internal/usecase/keys/router.go +0 -10
| @@ -78,16 +78,6 @@ func (r *Router) Route(data []byte) { | |||
| 78 | 78 | r.route(data) | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | - | // PartialInputSuffixLen reports the suffix length that must be retained for a | |
| 82 | - | // later read. The current protocol only has one partial suffix: a lone trailing | |
| 83 | - | // ESC. This mirrors the YMUX partial-input pattern in a deliberately small form. | |
| 84 | - | func PartialInputSuffixLen(data []byte) int { | |
| 85 | - | if len(data) > 0 && data[len(data)-1] == ESC { | |
| 86 | - | return 1 | |
| 87 | - | } | |
| 88 | - | return 0 | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 81 | func (r *Router) route(data []byte) { | |
| 92 | 82 | buf := make([]byte, 0, len(data)) | |
| 93 | 83 | flush := func() { | |
| @@ -78,16 +78,6 @@ func (r *Router) Route(data []byte) { | |||||
| 78 | r.route(data) | 78 | r.route(data) | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | - | // PartialInputSuffixLen reports the suffix length that must be retained for a | |||
| 82 | - | // later read. The current protocol only has one partial suffix: a lone trailing | |||
| 83 | - | // ESC. This mirrors the YMUX partial-input pattern in a deliberately small form. | |||
| 84 | - | func PartialInputSuffixLen(data []byte) int { | |||
| 85 | - | if len(data) > 0 && data[len(data)-1] == ESC { | |||
| 86 | - | return 1 | |||
| 87 | - | } | |||
| 88 | - | return 0 | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | func (r *Router) route(data []byte) { | 81 | func (r *Router) route(data []byte) { | ||
| 92 | buf := make([]byte, 0, len(data)) | 82 | buf := make([]byte, 0, len(data)) | ||
| 93 | flush := func() { | 83 | flush := func() { | ||
M internal/usecase/keys/router_test.go +41 -5
| @@ -121,6 +121,47 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||
| 121 | 121 | require.Empty(t, h.forwards) | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |
| 125 | + | cases := []struct { | |
| 126 | + | name string | |
| 127 | + | secondInput []byte | |
| 128 | + | want []byte | |
| 129 | + | }{ | |
| 130 | + | {name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}}, | |
| 131 | + | {name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}}, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | for _, tc := range cases { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | clk := &fakeClock{} | |
| 137 | + | h := &captureHandler{} | |
| 138 | + | r := NewRouter(clk, h) | |
| 139 | + | r.Route([]byte{ESC}) | |
| 140 | + | require.Len(t, clk.timers, 1) | |
| 141 | + | require.Empty(t, h.forwards) | |
| 142 | + | ||
| 143 | + | r.Route(tc.secondInput) | |
| 144 | + | ||
| 145 | + | require.True(t, clk.timers[0].stopped) | |
| 146 | + | require.Empty(t, h.actions) | |
| 147 | + | require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |
| 153 | + | clk := &fakeClock{} | |
| 154 | + | h := &captureHandler{} | |
| 155 | + | r := NewRouter(clk, h) | |
| 156 | + | r.Route([]byte{ESC}) | |
| 157 | + | ||
| 158 | + | r.Route([]byte{'[', 'A', ESC, 'c'}) | |
| 159 | + | ||
| 160 | + | require.True(t, clk.timers[0].stopped) | |
| 161 | + | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | |
| 162 | + | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |
| 163 | + | } | |
| 164 | + | ||
| 124 | 165 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |
| 125 | 166 | clk := &fakeClock{} | |
| 126 | 167 | h := &captureHandler{} | |
| @@ -167,8 +208,3 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |||
| 167 | 208 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | |
| 168 | 209 | require.Empty(t, h.actions) | |
| 169 | 210 | } | |
| 170 | - | ||
| 171 | - | func TestPartialInputSuffixLen(t *testing.T) { | |
| 172 | - | require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b"))) | |
| 173 | - | require.Equal(t, 0, PartialInputSuffixLen([]byte("abc"))) | |
| 174 | - | } | |
| @@ -121,6 +121,47 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||||
| 121 | require.Empty(t, h.forwards) | 121 | require.Empty(t, h.forwards) | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |||
| 125 | + | cases := []struct { | |||
| 126 | + | name string | |||
| 127 | + | secondInput []byte | |||
| 128 | + | want []byte | |||
| 129 | + | }{ | |||
| 130 | + | {name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}}, | |||
| 131 | + | {name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}}, | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | for _, tc := range cases { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 136 | + | clk := &fakeClock{} | |||
| 137 | + | h := &captureHandler{} | |||
| 138 | + | r := NewRouter(clk, h) | |||
| 139 | + | r.Route([]byte{ESC}) | |||
| 140 | + | require.Len(t, clk.timers, 1) | |||
| 141 | + | require.Empty(t, h.forwards) | |||
| 142 | + | ||||
| 143 | + | r.Route(tc.secondInput) | |||
| 144 | + | ||||
| 145 | + | require.True(t, clk.timers[0].stopped) | |||
| 146 | + | require.Empty(t, h.actions) | |||
| 147 | + | require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards) | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||
| 153 | + | clk := &fakeClock{} | |||
| 154 | + | h := &captureHandler{} | |||
| 155 | + | r := NewRouter(clk, h) | |||
| 156 | + | r.Route([]byte{ESC}) | |||
| 157 | + | ||||
| 158 | + | r.Route([]byte{'[', 'A', ESC, 'c'}) | |||
| 159 | + | ||||
| 160 | + | require.True(t, clk.timers[0].stopped) | |||
| 161 | + | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | |||
| 162 | + | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 124 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | 165 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | ||
| 125 | clk := &fakeClock{} | 166 | clk := &fakeClock{} | ||
| 126 | h := &captureHandler{} | 167 | h := &captureHandler{} | ||
| @@ -167,8 +208,3 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |||||
| 167 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | 208 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | ||
| 168 | require.Empty(t, h.actions) | 209 | require.Empty(t, h.actions) | ||
| 169 | } | 210 | } | ||
| 170 | - | ||||
| 171 | - | func TestPartialInputSuffixLen(t *testing.T) { | |||
| 172 | - | require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b"))) | |||
| 173 | - | require.Equal(t, 0, PartialInputSuffixLen([]byte("abc"))) | |||
| 174 | - | } | |||
M pkg/renderer/renderer_test.go +0 -100
| @@ -2,10 +2,8 @@ package renderer | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | - | "context" | |
| 6 | 5 | "strings" | |
| 7 | 6 | "testing" | |
| 8 | - | "time" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 11 | 9 | // markFrame writes uniform printable content so each row is distinguishable. | |
| @@ -507,104 +505,6 @@ func TestPartialDamage(t *testing.T) { | |||
| 507 | 505 | } | |
| 508 | 506 | } | |
| 509 | 507 | ||
| 510 | - | // --------------------------------------------------------------------------- | |
| 511 | - | // Scheduler coalescing | |
| 512 | - | // --------------------------------------------------------------------------- | |
| 513 | - | ||
| 514 | - | func TestScheduler(t *testing.T) { | |
| 515 | - | tests := []struct { | |
| 516 | - | name string | |
| 517 | - | run func(t *testing.T) | |
| 518 | - | }{ | |
| 519 | - | { | |
| 520 | - | name: "coalesces bursts of requests into a single frame", | |
| 521 | - | run: func(t *testing.T) { | |
| 522 | - | s := NewScheduler(30 * time.Millisecond) | |
| 523 | - | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |
| 524 | - | defer cancel() | |
| 525 | - | ||
| 526 | - | frames := s.Run(ctx) | |
| 527 | - | ||
| 528 | - | // Fire several requests before the timer fires. | |
| 529 | - | s.Request() | |
| 530 | - | s.Request() | |
| 531 | - | s.Request() | |
| 532 | - | ||
| 533 | - | // Expect exactly one frame. | |
| 534 | - | select { | |
| 535 | - | case _, ok := <-frames: | |
| 536 | - | if !ok { | |
| 537 | - | t.Fatal("channel closed unexpectedly") | |
| 538 | - | } | |
| 539 | - | case <-ctx.Done(): | |
| 540 | - | t.Fatal("timeout waiting for frame") | |
| 541 | - | } | |
| 542 | - | ||
| 543 | - | // No second frame should arrive without another request. | |
| 544 | - | select { | |
| 545 | - | case <-frames: | |
| 546 | - | t.Fatal("unexpected second frame") | |
| 547 | - | case <-time.After(80 * time.Millisecond): | |
| 548 | - | // good – coalesced. | |
| 549 | - | } | |
| 550 | - | }, | |
| 551 | - | }, | |
| 552 | - | { | |
| 553 | - | name: "delivers a frame per request without spurious extras", | |
| 554 | - | run: func(t *testing.T) { | |
| 555 | - | s := NewScheduler(10 * time.Millisecond) | |
| 556 | - | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |
| 557 | - | defer cancel() | |
| 558 | - | ||
| 559 | - | frames := s.Run(ctx) | |
| 560 | - | ||
| 561 | - | s.Request() | |
| 562 | - | <-frames // consume first | |
| 563 | - | ||
| 564 | - | s.Request() | |
| 565 | - | <-frames // consume second | |
| 566 | - | ||
| 567 | - | // No pending request – should not deliver another frame. | |
| 568 | - | select { | |
| 569 | - | case <-frames: | |
| 570 | - | t.Fatal("unexpected frame without request") | |
| 571 | - | case <-time.After(40 * time.Millisecond): | |
| 572 | - | // good | |
| 573 | - | } | |
| 574 | - | }, | |
| 575 | - | }, | |
| 576 | - | { | |
| 577 | - | name: "closes frames channel on context cancellation", | |
| 578 | - | run: func(t *testing.T) { | |
| 579 | - | s := NewScheduler(50 * time.Millisecond) | |
| 580 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 581 | - | ||
| 582 | - | frames := s.Run(ctx) | |
| 583 | - | s.Request() | |
| 584 | - | ||
| 585 | - | // Cancel before the timer fires. | |
| 586 | - | cancel() | |
| 587 | - | ||
| 588 | - | // Channel should close without ever delivering a frame. | |
| 589 | - | select { | |
| 590 | - | case _, ok := <-frames: | |
| 591 | - | if ok { | |
| 592 | - | // A frame may have been emitted before cancellation took effect; | |
| 593 | - | // that is acceptable. What matters is that the goroutine exits. | |
| 594 | - | _ = ok | |
| 595 | - | } | |
| 596 | - | case <-time.After(200 * time.Millisecond): | |
| 597 | - | // Channel didn't close – goroutine may be stuck. | |
| 598 | - | } | |
| 599 | - | }, | |
| 600 | - | }, | |
| 601 | - | } | |
| 602 | - | ||
| 603 | - | for _, tt := range tests { | |
| 604 | - | t.Run(tt.name, tt.run) | |
| 605 | - | } | |
| 606 | - | } | |
| 607 | - | ||
| 608 | 508 | // --------------------------------------------------------------------------- | |
| 609 | 509 | // Reset | |
| 610 | 510 | // --------------------------------------------------------------------------- | |
| @@ -2,10 +2,8 @@ package renderer | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | - | "context" | |||
| 6 | "strings" | 5 | "strings" | ||
| 7 | "testing" | 6 | "testing" | ||
| 8 | - | "time" | |||
| 9 | ) | 7 | ) | ||
| 10 | 8 | ||||
| 11 | // markFrame writes uniform printable content so each row is distinguishable. | 9 | // markFrame writes uniform printable content so each row is distinguishable. | ||
| @@ -507,104 +505,6 @@ func TestPartialDamage(t *testing.T) { | |||||
| 507 | } | 505 | } | ||
| 508 | } | 506 | } | ||
| 509 | 507 | ||||
| 510 | - | // --------------------------------------------------------------------------- | |||
| 511 | - | // Scheduler coalescing | |||
| 512 | - | // --------------------------------------------------------------------------- | |||
| 513 | - | ||||
| 514 | - | func TestScheduler(t *testing.T) { | |||
| 515 | - | tests := []struct { | |||
| 516 | - | name string | |||
| 517 | - | run func(t *testing.T) | |||
| 518 | - | }{ | |||
| 519 | - | { | |||
| 520 | - | name: "coalesces bursts of requests into a single frame", | |||
| 521 | - | run: func(t *testing.T) { | |||
| 522 | - | s := NewScheduler(30 * time.Millisecond) | |||
| 523 | - | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |||
| 524 | - | defer cancel() | |||
| 525 | - | ||||
| 526 | - | frames := s.Run(ctx) | |||
| 527 | - | ||||
| 528 | - | // Fire several requests before the timer fires. | |||
| 529 | - | s.Request() | |||
| 530 | - | s.Request() | |||
| 531 | - | s.Request() | |||
| 532 | - | ||||
| 533 | - | // Expect exactly one frame. | |||
| 534 | - | select { | |||
| 535 | - | case _, ok := <-frames: | |||
| 536 | - | if !ok { | |||
| 537 | - | t.Fatal("channel closed unexpectedly") | |||
| 538 | - | } | |||
| 539 | - | case <-ctx.Done(): | |||
| 540 | - | t.Fatal("timeout waiting for frame") | |||
| 541 | - | } | |||
| 542 | - | ||||
| 543 | - | // No second frame should arrive without another request. | |||
| 544 | - | select { | |||
| 545 | - | case <-frames: | |||
| 546 | - | t.Fatal("unexpected second frame") | |||
| 547 | - | case <-time.After(80 * time.Millisecond): | |||
| 548 | - | // good – coalesced. | |||
| 549 | - | } | |||
| 550 | - | }, | |||
| 551 | - | }, | |||
| 552 | - | { | |||
| 553 | - | name: "delivers a frame per request without spurious extras", | |||
| 554 | - | run: func(t *testing.T) { | |||
| 555 | - | s := NewScheduler(10 * time.Millisecond) | |||
| 556 | - | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |||
| 557 | - | defer cancel() | |||
| 558 | - | ||||
| 559 | - | frames := s.Run(ctx) | |||
| 560 | - | ||||
| 561 | - | s.Request() | |||
| 562 | - | <-frames // consume first | |||
| 563 | - | ||||
| 564 | - | s.Request() | |||
| 565 | - | <-frames // consume second | |||
| 566 | - | ||||
| 567 | - | // No pending request – should not deliver another frame. | |||
| 568 | - | select { | |||
| 569 | - | case <-frames: | |||
| 570 | - | t.Fatal("unexpected frame without request") | |||
| 571 | - | case <-time.After(40 * time.Millisecond): | |||
| 572 | - | // good | |||
| 573 | - | } | |||
| 574 | - | }, | |||
| 575 | - | }, | |||
| 576 | - | { | |||
| 577 | - | name: "closes frames channel on context cancellation", | |||
| 578 | - | run: func(t *testing.T) { | |||
| 579 | - | s := NewScheduler(50 * time.Millisecond) | |||
| 580 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 581 | - | ||||
| 582 | - | frames := s.Run(ctx) | |||
| 583 | - | s.Request() | |||
| 584 | - | ||||
| 585 | - | // Cancel before the timer fires. | |||
| 586 | - | cancel() | |||
| 587 | - | ||||
| 588 | - | // Channel should close without ever delivering a frame. | |||
| 589 | - | select { | |||
| 590 | - | case _, ok := <-frames: | |||
| 591 | - | if ok { | |||
| 592 | - | // A frame may have been emitted before cancellation took effect; | |||
| 593 | - | // that is acceptable. What matters is that the goroutine exits. | |||
| 594 | - | _ = ok | |||
| 595 | - | } | |||
| 596 | - | case <-time.After(200 * time.Millisecond): | |||
| 597 | - | // Channel didn't close – goroutine may be stuck. | |||
| 598 | - | } | |||
| 599 | - | }, | |||
| 600 | - | }, | |||
| 601 | - | } | |||
| 602 | - | ||||
| 603 | - | for _, tt := range tests { | |||
| 604 | - | t.Run(tt.name, tt.run) | |||
| 605 | - | } | |||
| 606 | - | } | |||
| 607 | - | ||||
| 608 | // --------------------------------------------------------------------------- | 508 | // --------------------------------------------------------------------------- | ||
| 609 | // Reset | 509 | // Reset | ||
| 610 | // --------------------------------------------------------------------------- | 510 | // --------------------------------------------------------------------------- | ||
D pkg/renderer/scheduler.go +0 -62
| @@ -1,62 +0,0 @@ | |||
| 1 | - | package renderer | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "time" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | const DefaultFrameInterval = 10 * time.Millisecond | |
| 9 | - | ||
| 10 | - | type Scheduler struct { | |
| 11 | - | interval time.Duration | |
| 12 | - | requests chan struct{} | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | func NewScheduler(interval time.Duration) *Scheduler { | |
| 16 | - | if interval <= 0 { | |
| 17 | - | interval = DefaultFrameInterval | |
| 18 | - | } | |
| 19 | - | return &Scheduler{interval: interval, requests: make(chan struct{}, 1)} | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func (s *Scheduler) Request() { | |
| 23 | - | select { | |
| 24 | - | case s.requests <- struct{}{}: | |
| 25 | - | default: | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (s *Scheduler) Run(ctx context.Context) <-chan struct{} { | |
| 30 | - | frames := make(chan struct{}, 1) | |
| 31 | - | go func() { | |
| 32 | - | defer close(frames) | |
| 33 | - | var timer *time.Timer | |
| 34 | - | var timerC <-chan time.Time | |
| 35 | - | pending := false | |
| 36 | - | for { | |
| 37 | - | select { | |
| 38 | - | case <-ctx.Done(): | |
| 39 | - | if timer != nil { | |
| 40 | - | timer.Stop() | |
| 41 | - | } | |
| 42 | - | return | |
| 43 | - | case <-s.requests: | |
| 44 | - | if pending { | |
| 45 | - | continue | |
| 46 | - | } | |
| 47 | - | pending = true | |
| 48 | - | timer = time.NewTimer(s.interval) | |
| 49 | - | timerC = timer.C | |
| 50 | - | case <-timerC: | |
| 51 | - | pending = false | |
| 52 | - | timerC = nil | |
| 53 | - | select { | |
| 54 | - | case frames <- struct{}{}: | |
| 55 | - | case <-ctx.Done(): | |
| 56 | - | return | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | }() | |
| 61 | - | return frames | |
| 62 | - | } | |
| @@ -1,62 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "time" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | const DefaultFrameInterval = 10 * time.Millisecond | |||
| 9 | - | ||||
| 10 | - | type Scheduler struct { | |||
| 11 | - | interval time.Duration | |||
| 12 | - | requests chan struct{} | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | func NewScheduler(interval time.Duration) *Scheduler { | |||
| 16 | - | if interval <= 0 { | |||
| 17 | - | interval = DefaultFrameInterval | |||
| 18 | - | } | |||
| 19 | - | return &Scheduler{interval: interval, requests: make(chan struct{}, 1)} | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | func (s *Scheduler) Request() { | |||
| 23 | - | select { | |||
| 24 | - | case s.requests <- struct{}{}: | |||
| 25 | - | default: | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func (s *Scheduler) Run(ctx context.Context) <-chan struct{} { | |||
| 30 | - | frames := make(chan struct{}, 1) | |||
| 31 | - | go func() { | |||
| 32 | - | defer close(frames) | |||
| 33 | - | var timer *time.Timer | |||
| 34 | - | var timerC <-chan time.Time | |||
| 35 | - | pending := false | |||
| 36 | - | for { | |||
| 37 | - | select { | |||
| 38 | - | case <-ctx.Done(): | |||
| 39 | - | if timer != nil { | |||
| 40 | - | timer.Stop() | |||
| 41 | - | } | |||
| 42 | - | return | |||
| 43 | - | case <-s.requests: | |||
| 44 | - | if pending { | |||
| 45 | - | continue | |||
| 46 | - | } | |||
| 47 | - | pending = true | |||
| 48 | - | timer = time.NewTimer(s.interval) | |||
| 49 | - | timerC = timer.C | |||
| 50 | - | case <-timerC: | |||
| 51 | - | pending = false | |||
| 52 | - | timerC = nil | |||
| 53 | - | select { | |||
| 54 | - | case frames <- struct{}{}: | |||
| 55 | - | case <-ctx.Done(): | |||
| 56 | - | return | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | }() | |||
| 61 | - | return frames | |||
| 62 | - | } | |||
A pkg/vt/query_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestScreenQueryResponses(t *testing.T) { | |
| 9 | + | cases := []struct { | |
| 10 | + | name string | |
| 11 | + | input string | |
| 12 | + | want string | |
| 13 | + | }{ | |
| 14 | + | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"}, | |
| 15 | + | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"}, | |
| 16 | + | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | |
| 17 | + | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | |
| 18 | + | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | |
| 19 | + | {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"}, | |
| 20 | + | {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"}, | |
| 21 | + | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | |
| 22 | + | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | |
| 23 | + | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | |
| 24 | + | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | |
| 25 | + | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | |
| 26 | + | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |
| 27 | + | {name: "DA split across writes", input: "", want: "\x1b[?6c"}, | |
| 28 | + | } | |
| 29 | + | for _, tc := range cases { | |
| 30 | + | t.Run(tc.name, func(t *testing.T) { | |
| 31 | + | s := NewScreen(80, 24) | |
| 32 | + | var got bytes.Buffer | |
| 33 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 34 | + | if tc.name == "DA split across writes" { | |
| 35 | + | s.Write([]byte("\x1b[")) | |
| 36 | + | s.Write([]byte("0c")) | |
| 37 | + | } else { | |
| 38 | + | s.Write([]byte(tc.input)) | |
| 39 | + | } | |
| 40 | + | if got.String() != tc.want { | |
| 41 | + | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |
| 42 | + | } | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | |
| 48 | + | s := NewScreen(80, 24) | |
| 49 | + | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestCSIuDispatch(t *testing.T) { | |
| 53 | + | cases := []struct { | |
| 54 | + | name string | |
| 55 | + | input string | |
| 56 | + | wantRow int | |
| 57 | + | wantCol int | |
| 58 | + | }{ | |
| 59 | + | {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9}, | |
| 60 | + | {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1}, | |
| 61 | + | {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1}, | |
| 62 | + | {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1}, | |
| 63 | + | } | |
| 64 | + | for _, tc := range cases { | |
| 65 | + | t.Run(tc.name, func(t *testing.T) { | |
| 66 | + | s := NewScreen(80, 24) | |
| 67 | + | s.Write([]byte(tc.input)) | |
| 68 | + | if s.Row != tc.wantRow || s.Col != tc.wantCol { | |
| 69 | + | t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol) | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestScreenQueryResponses(t *testing.T) { | |||
| 9 | + | cases := []struct { | |||
| 10 | + | name string | |||
| 11 | + | input string | |||
| 12 | + | want string | |||
| 13 | + | }{ | |||
| 14 | + | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"}, | |||
| 15 | + | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"}, | |||
| 16 | + | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | |||
| 17 | + | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | |||
| 18 | + | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | |||
| 19 | + | {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"}, | |||
| 20 | + | {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"}, | |||
| 21 | + | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | |||
| 22 | + | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | |||
| 23 | + | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | |||
| 24 | + | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | |||
| 25 | + | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | |||
| 26 | + | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |||
| 27 | + | {name: "DA split across writes", input: "", want: "\x1b[?6c"}, | |||
| 28 | + | } | |||
| 29 | + | for _, tc := range cases { | |||
| 30 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 31 | + | s := NewScreen(80, 24) | |||
| 32 | + | var got bytes.Buffer | |||
| 33 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 34 | + | if tc.name == "DA split across writes" { | |||
| 35 | + | s.Write([]byte("\x1b[")) | |||
| 36 | + | s.Write([]byte("0c")) | |||
| 37 | + | } else { | |||
| 38 | + | s.Write([]byte(tc.input)) | |||
| 39 | + | } | |||
| 40 | + | if got.String() != tc.want { | |||
| 41 | + | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | |||
| 48 | + | s := NewScreen(80, 24) | |||
| 49 | + | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestCSIuDispatch(t *testing.T) { | |||
| 53 | + | cases := []struct { | |||
| 54 | + | name string | |||
| 55 | + | input string | |||
| 56 | + | wantRow int | |||
| 57 | + | wantCol int | |||
| 58 | + | }{ | |||
| 59 | + | {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9}, | |||
| 60 | + | {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1}, | |||
| 61 | + | {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1}, | |||
| 62 | + | {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1}, | |||
| 63 | + | } | |||
| 64 | + | for _, tc := range cases { | |||
| 65 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 66 | + | s := NewScreen(80, 24) | |||
| 67 | + | s.Write([]byte(tc.input)) | |||
| 68 | + | if s.Row != tc.wantRow || s.Col != tc.wantCol { | |||
| 69 | + | t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M pkg/vt/screen.go +63 -1
| @@ -36,6 +36,10 @@ type Screen struct { | |||
| 36 | 36 | // OnLineEvicted is called just before a full-width upward scroll recycles | |
| 37 | 37 | // and blanks rows. The callback receives a stable copy of each evicted row. | |
| 38 | 38 | OnLineEvicted func([]renderer.Cell) | |
| 39 | + | // OnResponse is called synchronously from Write with reply bytes that the | |
| 40 | + | // emulator must send back to the child process (DA, DSR, DECRQM reports). | |
| 41 | + | // The host wires it to the PTY input. Nil disables responses. | |
| 42 | + | OnResponse func([]byte) | |
| 39 | 43 | ||
| 40 | 44 | damage []renderer.Damage | |
| 41 | 45 | escapeBuf []byte | |
| @@ -80,6 +84,11 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |||
| 80 | 84 | // is currently enabled by the child process. | |
| 81 | 85 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |
| 82 | 86 | ||
| 87 | + | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |
| 88 | + | // Hosts use this as a safety valve if a child enters synchronized update mode | |
| 89 | + | // and never sends the matching end sequence. | |
| 90 | + | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |
| 91 | + | ||
| 83 | 92 | func (s *Screen) Write(data []byte) { | |
| 84 | 93 | if len(s.escapeBuf) > 0 { | |
| 85 | 94 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| @@ -487,6 +496,12 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 487 | 496 | } | |
| 488 | 497 | } | |
| 489 | 498 | ||
| 499 | + | func (s *Screen) respond(b []byte) { | |
| 500 | + | if s.OnResponse != nil { | |
| 501 | + | s.OnResponse(b) | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | ||
| 490 | 505 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 491 | 506 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 492 | 507 | return | |
| @@ -629,6 +644,51 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 629 | 644 | private := strings.HasPrefix(params, "?") | |
| 630 | 645 | parts := parseCSIInts(params) | |
| 631 | 646 | switch cmd { | |
| 647 | + | case 'c': | |
| 648 | + | if params == "" || params == "0" { | |
| 649 | + | s.respond([]byte("\x1b[?6c")) | |
| 650 | + | } else if params == ">" || params == ">0" { | |
| 651 | + | s.respond([]byte("\x1b[>0;0;0c")) | |
| 652 | + | } | |
| 653 | + | case 'n': | |
| 654 | + | switch firstPositive(parts, 0) { | |
| 655 | + | case 5: | |
| 656 | + | if !private { | |
| 657 | + | s.respond([]byte("\x1b[0n")) | |
| 658 | + | } | |
| 659 | + | case 6: | |
| 660 | + | resp := make([]byte, 0, 16) | |
| 661 | + | resp = append(resp, "\x1b["...) | |
| 662 | + | if private { | |
| 663 | + | resp = append(resp, '?') | |
| 664 | + | } | |
| 665 | + | resp = strconv.AppendInt(resp, int64(s.Row+1), 10) | |
| 666 | + | resp = append(resp, ';') | |
| 667 | + | resp = strconv.AppendInt(resp, int64(s.Col+1), 10) | |
| 668 | + | resp = append(resp, 'R') | |
| 669 | + | s.respond(resp) | |
| 670 | + | } | |
| 671 | + | case 'p': | |
| 672 | + | if private && strings.HasSuffix(params, "$") { | |
| 673 | + | mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$")) | |
| 674 | + | if err != nil { | |
| 675 | + | return | |
| 676 | + | } | |
| 677 | + | state := 0 | |
| 678 | + | if mode == 2026 { | |
| 679 | + | state = 2 | |
| 680 | + | if s.syncUpdateActive { | |
| 681 | + | state = 1 | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | resp := make([]byte, 0, 16) | |
| 685 | + | resp = append(resp, "\x1b[?"...) | |
| 686 | + | resp = strconv.AppendInt(resp, int64(mode), 10) | |
| 687 | + | resp = append(resp, ';') | |
| 688 | + | resp = strconv.AppendInt(resp, int64(state), 10) | |
| 689 | + | resp = append(resp, "$y"...) | |
| 690 | + | s.respond(resp) | |
| 691 | + | } | |
| 632 | 692 | case 'm': | |
| 633 | 693 | if strings.HasPrefix(params, ">") { | |
| 634 | 694 | return | |
| @@ -693,7 +753,9 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 693 | 753 | case 's': | |
| 694 | 754 | s.saveCursor() | |
| 695 | 755 | case 'u': | |
| 696 | - | s.restoreCursor() | |
| 756 | + | if params == "" { | |
| 757 | + | s.restoreCursor() | |
| 758 | + | } | |
| 697 | 759 | case 'h': | |
| 698 | 760 | s.setMode(private, parts, true) | |
| 699 | 761 | case 'l': | |
| @@ -36,6 +36,10 @@ type Screen struct { | |||||
| 36 | // OnLineEvicted is called just before a full-width upward scroll recycles | 36 | // OnLineEvicted is called just before a full-width upward scroll recycles | ||
| 37 | // and blanks rows. The callback receives a stable copy of each evicted row. | 37 | // and blanks rows. The callback receives a stable copy of each evicted row. | ||
| 38 | OnLineEvicted func([]renderer.Cell) | 38 | OnLineEvicted func([]renderer.Cell) | ||
| 39 | + | // OnResponse is called synchronously from Write with reply bytes that the | |||
| 40 | + | // emulator must send back to the child process (DA, DSR, DECRQM reports). | |||
| 41 | + | // The host wires it to the PTY input. Nil disables responses. | |||
| 42 | + | OnResponse func([]byte) | |||
| 39 | 43 | ||||
| 40 | damage []renderer.Damage | 44 | damage []renderer.Damage | ||
| 41 | escapeBuf []byte | 45 | escapeBuf []byte | ||
| @@ -80,6 +84,11 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |||||
| 80 | // is currently enabled by the child process. | 84 | // is currently enabled by the child process. | ||
| 81 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | 85 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | ||
| 82 | 86 | ||||
| 87 | + | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |||
| 88 | + | // Hosts use this as a safety valve if a child enters synchronized update mode | |||
| 89 | + | // and never sends the matching end sequence. | |||
| 90 | + | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |||
| 91 | + | ||||
| 83 | func (s *Screen) Write(data []byte) { | 92 | func (s *Screen) Write(data []byte) { | ||
| 84 | if len(s.escapeBuf) > 0 { | 93 | if len(s.escapeBuf) > 0 { | ||
| 85 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 94 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | ||
| @@ -487,6 +496,12 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 487 | } | 496 | } | ||
| 488 | } | 497 | } | ||
| 489 | 498 | ||||
| 499 | + | func (s *Screen) respond(b []byte) { | |||
| 500 | + | if s.OnResponse != nil { | |||
| 501 | + | s.OnResponse(b) | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 504 | + | ||||
| 490 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | 505 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | ||
| 491 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | 506 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | ||
| 492 | return | 507 | return | ||
| @@ -629,6 +644,51 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 629 | private := strings.HasPrefix(params, "?") | 644 | private := strings.HasPrefix(params, "?") | ||
| 630 | parts := parseCSIInts(params) | 645 | parts := parseCSIInts(params) | ||
| 631 | switch cmd { | 646 | switch cmd { | ||
| 647 | + | case 'c': | |||
| 648 | + | if params == "" || params == "0" { | |||
| 649 | + | s.respond([]byte("\x1b[?6c")) | |||
| 650 | + | } else if params == ">" || params == ">0" { | |||
| 651 | + | s.respond([]byte("\x1b[>0;0;0c")) | |||
| 652 | + | } | |||
| 653 | + | case 'n': | |||
| 654 | + | switch firstPositive(parts, 0) { | |||
| 655 | + | case 5: | |||
| 656 | + | if !private { | |||
| 657 | + | s.respond([]byte("\x1b[0n")) | |||
| 658 | + | } | |||
| 659 | + | case 6: | |||
| 660 | + | resp := make([]byte, 0, 16) | |||
| 661 | + | resp = append(resp, "\x1b["...) | |||
| 662 | + | if private { | |||
| 663 | + | resp = append(resp, '?') | |||
| 664 | + | } | |||
| 665 | + | resp = strconv.AppendInt(resp, int64(s.Row+1), 10) | |||
| 666 | + | resp = append(resp, ';') | |||
| 667 | + | resp = strconv.AppendInt(resp, int64(s.Col+1), 10) | |||
| 668 | + | resp = append(resp, 'R') | |||
| 669 | + | s.respond(resp) | |||
| 670 | + | } | |||
| 671 | + | case 'p': | |||
| 672 | + | if private && strings.HasSuffix(params, "$") { | |||
| 673 | + | mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$")) | |||
| 674 | + | if err != nil { | |||
| 675 | + | return | |||
| 676 | + | } | |||
| 677 | + | state := 0 | |||
| 678 | + | if mode == 2026 { | |||
| 679 | + | state = 2 | |||
| 680 | + | if s.syncUpdateActive { | |||
| 681 | + | state = 1 | |||
| 682 | + | } | |||
| 683 | + | } | |||
| 684 | + | resp := make([]byte, 0, 16) | |||
| 685 | + | resp = append(resp, "\x1b[?"...) | |||
| 686 | + | resp = strconv.AppendInt(resp, int64(mode), 10) | |||
| 687 | + | resp = append(resp, ';') | |||
| 688 | + | resp = strconv.AppendInt(resp, int64(state), 10) | |||
| 689 | + | resp = append(resp, "$y"...) | |||
| 690 | + | s.respond(resp) | |||
| 691 | + | } | |||
| 632 | case 'm': | 692 | case 'm': | ||
| 633 | if strings.HasPrefix(params, ">") { | 693 | if strings.HasPrefix(params, ">") { | ||
| 634 | return | 694 | return | ||
| @@ -693,7 +753,9 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 693 | case 's': | 753 | case 's': | ||
| 694 | s.saveCursor() | 754 | s.saveCursor() | ||
| 695 | case 'u': | 755 | case 'u': | ||
| 696 | - | s.restoreCursor() | 756 | + | if params == "" { |
| 757 | + | s.restoreCursor() | |||
| 758 | + | } | |||
| 697 | case 'h': | 759 | case 'h': | ||
| 698 | s.setMode(private, parts, true) | 760 | s.setMode(private, parts, true) | ||
| 699 | case 'l': | 761 | case 'l': | ||
M pkg/vt/screen_test.go +12 -0
| @@ -2192,3 +2192,15 @@ func TestScreenReportsSynchronizedUpdateMode(t *testing.T) { | |||
| 2192 | 2192 | t.Fatal("sync update mode should be inactive") | |
| 2193 | 2193 | } | |
| 2194 | 2194 | } | |
| 2195 | + | ||
| 2196 | + | func TestForceSyncEnd(t *testing.T) { | |
| 2197 | + | s := NewScreen(10, 2) | |
| 2198 | + | s.Write([]byte("\x1b[?2026h")) | |
| 2199 | + | if !s.SyncUpdateActive() { | |
| 2200 | + | t.Fatal("sync update mode should be active before forcing end") | |
| 2201 | + | } | |
| 2202 | + | s.ForceSyncEnd() | |
| 2203 | + | if s.SyncUpdateActive() { | |
| 2204 | + | t.Fatal("sync update mode should be inactive after forcing end") | |
| 2205 | + | } | |
| 2206 | + | } | |
| @@ -2192,3 +2192,15 @@ func TestScreenReportsSynchronizedUpdateMode(t *testing.T) { | |||||
| 2192 | t.Fatal("sync update mode should be inactive") | 2192 | t.Fatal("sync update mode should be inactive") | ||
| 2193 | } | 2193 | } | ||
| 2194 | } | 2194 | } | ||
| 2195 | + | ||||
| 2196 | + | func TestForceSyncEnd(t *testing.T) { | |||
| 2197 | + | s := NewScreen(10, 2) | |||
| 2198 | + | s.Write([]byte("\x1b[?2026h")) | |||
| 2199 | + | if !s.SyncUpdateActive() { | |||
| 2200 | + | t.Fatal("sync update mode should be active before forcing end") | |||
| 2201 | + | } | |||
| 2202 | + | s.ForceSyncEnd() | |||
| 2203 | + | if s.SyncUpdateActive() { | |||
| 2204 | + | t.Fatal("sync update mode should be inactive after forcing end") | |||
| 2205 | + | } | |||
| 2206 | + | } | |||