Merge pull request 'feat(client): predictive local echo on remote attachments' (#7) from feat/predictive-echo into main
25 files · +2092 -31
M .mockery.yml +7 -0
| @@ -47,6 +47,13 @@ packages: | |||
| 47 | 47 | dir: internal/usecase/daemon | |
| 48 | 48 | filename: action_runner_mock_test.go | |
| 49 | 49 | pkgname: daemon | |
| 50 | + | github.com/bnema/vev/internal/usecase/client: | |
| 51 | + | interfaces: | |
| 52 | + | attachmentPredictionForeground: | |
| 53 | + | config: | |
| 54 | + | dir: internal/usecase/client | |
| 55 | + | filename: prediction_foreground_mock_test.go | |
| 56 | + | pkgname: client | |
| 50 | 57 | github.com/bnema/vev/internal/usecase/command: | |
| 51 | 58 | interfaces: | |
| 52 | 59 | Context: | |
| @@ -47,6 +47,13 @@ packages: | |||||
| 47 | dir: internal/usecase/daemon | 47 | dir: internal/usecase/daemon | ||
| 48 | filename: action_runner_mock_test.go | 48 | filename: action_runner_mock_test.go | ||
| 49 | pkgname: daemon | 49 | pkgname: daemon | ||
| 50 | + | github.com/bnema/vev/internal/usecase/client: | |||
| 51 | + | interfaces: | |||
| 52 | + | attachmentPredictionForeground: | |||
| 53 | + | config: | |||
| 54 | + | dir: internal/usecase/client | |||
| 55 | + | filename: prediction_foreground_mock_test.go | |||
| 56 | + | pkgname: client | |||
| 50 | github.com/bnema/vev/internal/usecase/command: | 57 | github.com/bnema/vev/internal/usecase/command: | ||
| 51 | interfaces: | 58 | interfaces: | ||
| 52 | Context: | 59 | Context: | ||
M AGENTS.md +1 -1
| @@ -43,7 +43,7 @@ Layer map: | |||
| 43 | 43 | - `internal/ports`: typed application connections and infrastructure-facing use-case seams. | |
| 44 | 44 | - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation. | |
| 45 | 45 | - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features. | |
| 46 | - | - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing. | |
| 46 | + | - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim. On remote attachments it also mirrors them in a local VT screen for predictive echo. | |
| 47 | 47 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations. | |
| 48 | 48 | ||
| 49 | 49 | Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`. | |
| @@ -43,7 +43,7 @@ Layer map: | |||||
| 43 | - `internal/ports`: typed application connections and infrastructure-facing use-case seams. | 43 | - `internal/ports`: typed application connections and infrastructure-facing use-case seams. | ||
| 44 | - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation. | 44 | - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation. | ||
| 45 | - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features. | 45 | - `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features. | ||
| 46 | - | - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing. | 46 | + | - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim. On remote attachments it also mirrors them in a local VT screen for predictive echo. |
| 47 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations. | 47 | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations. | ||
| 48 | 48 | ||||
| 49 | Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`. | 49 | Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`. | ||
M docs/architecture.md +2 -0
| @@ -93,6 +93,8 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||
| 93 | 93 | ||
| 94 | 94 | ### Terminal focus | |
| 95 | 95 | ||
| 96 | + | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`. | |
| 97 | + | ||
| 96 | 98 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | |
| 97 | 99 | ||
| 98 | 100 | ## Broker-owned hosts and daemon composition | |
| @@ -93,6 +93,8 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||||
| 93 | 93 | ||||
| 94 | ### Terminal focus | 94 | ### Terminal focus | ||
| 95 | 95 | ||||
| 96 | + | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`. | |||
| 97 | + | ||||
| 96 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | 98 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | ||
| 97 | 99 | ||||
| 98 | ## Broker-owned hosts and daemon composition | 100 | ## Broker-owned hosts and daemon composition | ||
M docs/configuration.md +4 -0
| @@ -46,6 +46,10 @@ keyboard.kitty-protocol = on | |||
| 46 | 46 | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | |
| 47 | 47 | terminal.colors = auto | |
| 48 | 48 | ||
| 49 | + | # Draw typed characters on remote sessions before the server echoes them: | |
| 50 | + | # adaptive (only on a slow link), always, or never. | |
| 51 | + | echo.predict = adaptive | |
| 52 | + | ||
| 49 | 53 | # Rebindable actions. Leave a line out to keep its built-in binding. | |
| 50 | 54 | open-palette = alt+space | |
| 51 | 55 | toggle-floating-pane = alt+f | |
| @@ -46,6 +46,10 @@ keyboard.kitty-protocol = on | |||||
| 46 | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | 46 | # Outer terminal colors: auto (detect), truecolor, 256, 16, or mono. | ||
| 47 | terminal.colors = auto | 47 | terminal.colors = auto | ||
| 48 | 48 | ||||
| 49 | + | # Draw typed characters on remote sessions before the server echoes them: | |||
| 50 | + | # adaptive (only on a slow link), always, or never. | |||
| 51 | + | echo.predict = adaptive | |||
| 52 | + | ||||
| 49 | # Rebindable actions. Leave a line out to keep its built-in binding. | 53 | # Rebindable actions. Leave a line out to keep its built-in binding. | ||
| 50 | open-palette = alt+space | 54 | open-palette = alt+space | ||
| 51 | toggle-floating-pane = alt+f | 55 | toggle-floating-pane = alt+f | ||
M docs/protocol.md +5 -2
| @@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain | |||
| 162 | 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | |
| 163 | 163 | applying + acknowledging daemon frames even while a picker lease owns | |
| 164 | 164 | the terminal (see below) — admission without physical write. | |
| 165 | - | - `Echo` carries the client's last input acknowledgement inside output; | |
| 166 | - | `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI | |
| 165 | + | - `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago | |
| 166 | + | (mosh's echo timeout), so the screen it ships with already holds that | |
| 167 | + | input's echo. When no frame is due, the daemon sends it in an empty | |
| 168 | + | side-effect `Output` (`New == 0`). Clients use it to confirm predicted | |
| 169 | + | echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI | |
| 167 | 170 | operations and `UIReceipt` reports the processed boundary | |
| 168 | 171 | (`Processed` requires epoch/state/publication; `Unavailable` carries | |
| 169 | 172 | none). | |
| @@ -162,8 +162,11 @@ base. `ValidateOutput` enforces the epoch/base/new chain | |||||
| 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | 162 | - The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps | ||
| 163 | applying + acknowledging daemon frames even while a picker lease owns | 163 | applying + acknowledging daemon frames even while a picker lease owns | ||
| 164 | the terminal (see below) — admission without physical write. | 164 | the terminal (see below) — admission without physical write. | ||
| 165 | - | - `Echo` carries the client's last input acknowledgement inside output; | 165 | + | - `Echo` is the newest `InputSeq` the daemon applied at least 50 ms ago |
| 166 | - | `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI | 166 | + | (mosh's echo timeout), so the screen it ships with already holds that |
| 167 | + | input's echo. When no frame is due, the daemon sends it in an empty | |||
| 168 | + | side-effect `Output` (`New == 0`). Clients use it to confirm predicted | |||
| 169 | + | echo. `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI | |||
| 167 | operations and `UIReceipt` reports the processed boundary | 170 | operations and `UIReceipt` reports the processed boundary | ||
| 168 | (`Processed` requires epoch/state/publication; `Unavailable` carries | 171 | (`Processed` requires epoch/state/publication; `Unavailable` carries | ||
| 169 | none). | 172 | none). | ||
M docs/remote-resilience.md +4 -0
| @@ -32,6 +32,10 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru | |||
| 32 | 32 | ||
| 33 | 33 | The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage. | |
| 34 | 34 | ||
| 35 | + | ## Typing on a slow link | |
| 36 | + | ||
| 37 | + | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). | |
| 38 | + | ||
| 35 | 39 | ## Connection states | |
| 36 | 40 | ||
| 37 | 41 | | What you see | Meaning | | |
| @@ -32,6 +32,10 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru | |||||
| 32 | 32 | ||||
| 33 | The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage. | 33 | The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage. | ||
| 34 | 34 | ||||
| 35 | + | ## Typing on a slow link | |||
| 36 | + | ||||
| 37 | + | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). | |||
| 38 | + | ||||
| 35 | ## Connection states | 39 | ## Connection states | ||
| 36 | 40 | ||||
| 37 | | What you see | Meaning | | 41 | | What you see | Meaning | | ||
M internal/adapters/config/config.go +21 -0
| @@ -38,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 38 | 38 | seenTabsKeys := make(map[string]bool) | |
| 39 | 39 | seenEphemeralKeys := make(map[string]bool) | |
| 40 | 40 | seenKeyboardKeys := make(map[string]bool) | |
| 41 | + | seenEchoKeys := make(map[string]bool) | |
| 41 | 42 | seenTerminalKeys := make(map[string]bool) | |
| 42 | 43 | seenScrollbackKeys := make(map[string]bool) | |
| 43 | 44 | seenWebKeys := make(map[string]bool) | |
| @@ -173,6 +174,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 173 | 174 | continue | |
| 174 | 175 | } | |
| 175 | 176 | cfg.Keyboard.KittyProtocol = on | |
| 177 | + | case key == "echo.predict": | |
| 178 | + | warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo) | |
| 179 | + | mode, ok := parseEchoPredict(value) | |
| 180 | + | if !ok { | |
| 181 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)}) | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | cfg.Echo.Predict = mode | |
| 176 | 185 | case key == "terminal.colors": | |
| 177 | 186 | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | |
| 178 | 187 | mode, auto, err := terminalcap.ParseColorMode(value) | |
| @@ -340,6 +349,18 @@ func fileStamp(path string) (stamp, error) { | |||
| 340 | 349 | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | |
| 341 | 350 | } | |
| 342 | 351 | ||
| 352 | + | func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { | |
| 353 | + | switch strings.ToLower(value) { | |
| 354 | + | case "adaptive": | |
| 355 | + | return domain.EchoPredictAdaptive, true | |
| 356 | + | case "always": | |
| 357 | + | return domain.EchoPredictAlways, true | |
| 358 | + | case "never": | |
| 359 | + | return domain.EchoPredictNever, true | |
| 360 | + | } | |
| 361 | + | return 0, false | |
| 362 | + | } | |
| 363 | + | ||
| 343 | 364 | func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { | |
| 344 | 365 | if seen[key] { | |
| 345 | 366 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| @@ -38,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 38 | seenTabsKeys := make(map[string]bool) | 38 | seenTabsKeys := make(map[string]bool) | ||
| 39 | seenEphemeralKeys := make(map[string]bool) | 39 | seenEphemeralKeys := make(map[string]bool) | ||
| 40 | seenKeyboardKeys := make(map[string]bool) | 40 | seenKeyboardKeys := make(map[string]bool) | ||
| 41 | + | seenEchoKeys := make(map[string]bool) | |||
| 41 | seenTerminalKeys := make(map[string]bool) | 42 | seenTerminalKeys := make(map[string]bool) | ||
| 42 | seenScrollbackKeys := make(map[string]bool) | 43 | seenScrollbackKeys := make(map[string]bool) | ||
| 43 | seenWebKeys := make(map[string]bool) | 44 | seenWebKeys := make(map[string]bool) | ||
| @@ -173,6 +174,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 173 | continue | 174 | continue | ||
| 174 | } | 175 | } | ||
| 175 | cfg.Keyboard.KittyProtocol = on | 176 | cfg.Keyboard.KittyProtocol = on | ||
| 177 | + | case key == "echo.predict": | |||
| 178 | + | warnings = warnDuplicateKey(warnings, seenEchoKeys, key, lineNo) | |||
| 179 | + | mode, ok := parseEchoPredict(value) | |||
| 180 | + | if !ok { | |||
| 181 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid echo.predict %q (want adaptive, always, or never)", value)}) | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | cfg.Echo.Predict = mode | |||
| 176 | case key == "terminal.colors": | 185 | case key == "terminal.colors": | ||
| 177 | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | 186 | warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo) | ||
| 178 | mode, auto, err := terminalcap.ParseColorMode(value) | 187 | mode, auto, err := terminalcap.ParseColorMode(value) | ||
| @@ -340,6 +349,18 @@ func fileStamp(path string) (stamp, error) { | |||||
| 340 | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | 349 | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | ||
| 341 | } | 350 | } | ||
| 342 | 351 | ||||
| 352 | + | func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { | |||
| 353 | + | switch strings.ToLower(value) { | |||
| 354 | + | case "adaptive": | |||
| 355 | + | return domain.EchoPredictAdaptive, true | |||
| 356 | + | case "always": | |||
| 357 | + | return domain.EchoPredictAlways, true | |||
| 358 | + | case "never": | |||
| 359 | + | return domain.EchoPredictNever, true | |||
| 360 | + | } | |||
| 361 | + | return 0, false | |||
| 362 | + | } | |||
| 363 | + | ||||
| 343 | func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { | 364 | func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { | ||
| 344 | if seen[key] { | 365 | if seen[key] { | ||
| 345 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 366 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
M internal/adapters/config/config_test.go +52 -0
| @@ -355,6 +355,9 @@ func TestParse(t *testing.T) { | |||
| 355 | 355 | if !tt.want.Keyboard.KittyProtocol { | |
| 356 | 356 | tt.want.Keyboard = domain.Defaults().Keyboard | |
| 357 | 357 | } | |
| 358 | + | if tt.want.Echo.Predict == domain.EchoPredictNever { | |
| 359 | + | tt.want.Echo = domain.Defaults().Echo | |
| 360 | + | } | |
| 358 | 361 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 359 | 362 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 360 | 363 | } | |
| @@ -563,6 +566,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) { | |||
| 563 | 566 | } | |
| 564 | 567 | } | |
| 565 | 568 | ||
| 569 | + | func TestParseEchoPredict(t *testing.T) { | |
| 570 | + | t.Parallel() | |
| 571 | + | ||
| 572 | + | tests := []struct { | |
| 573 | + | name string | |
| 574 | + | input string | |
| 575 | + | want domain.EchoPredictMode | |
| 576 | + | wantWarnings []domain.Warning | |
| 577 | + | }{ | |
| 578 | + | {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive}, | |
| 579 | + | {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive}, | |
| 580 | + | {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways}, | |
| 581 | + | {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever}, | |
| 582 | + | { | |
| 583 | + | name: "invalid value warns and keeps default", | |
| 584 | + | input: "echo.predict = sometimes\n", | |
| 585 | + | want: domain.EchoPredictAdaptive, | |
| 586 | + | wantWarnings: []domain.Warning{ | |
| 587 | + | {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"}, | |
| 588 | + | }, | |
| 589 | + | }, | |
| 590 | + | { | |
| 591 | + | name: "duplicate key warns and keeps last", | |
| 592 | + | input: "echo.predict = always\necho.predict = never\n", | |
| 593 | + | want: domain.EchoPredictNever, | |
| 594 | + | wantWarnings: []domain.Warning{ | |
| 595 | + | {Line: 2, Msg: "duplicate key \"echo.predict\""}, | |
| 596 | + | }, | |
| 597 | + | }, | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | for _, tt := range tests { | |
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |
| 602 | + | t.Parallel() | |
| 603 | + | ||
| 604 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 605 | + | if err != nil { | |
| 606 | + | t.Fatalf("Parse() error = %v", err) | |
| 607 | + | } | |
| 608 | + | if got.Echo.Predict != tt.want { | |
| 609 | + | t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want) | |
| 610 | + | } | |
| 611 | + | if !reflect.DeepEqual(warnings, tt.wantWarnings) { | |
| 612 | + | t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings) | |
| 613 | + | } | |
| 614 | + | }) | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 566 | 618 | func TestParseNavigationOverflow(t *testing.T) { | |
| 567 | 619 | t.Parallel() | |
| 568 | 620 | ||
| @@ -355,6 +355,9 @@ func TestParse(t *testing.T) { | |||||
| 355 | if !tt.want.Keyboard.KittyProtocol { | 355 | if !tt.want.Keyboard.KittyProtocol { | ||
| 356 | tt.want.Keyboard = domain.Defaults().Keyboard | 356 | tt.want.Keyboard = domain.Defaults().Keyboard | ||
| 357 | } | 357 | } | ||
| 358 | + | if tt.want.Echo.Predict == domain.EchoPredictNever { | |||
| 359 | + | tt.want.Echo = domain.Defaults().Echo | |||
| 360 | + | } | |||
| 358 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 361 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 359 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 362 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 360 | } | 363 | } | ||
| @@ -563,6 +566,55 @@ func TestParseKeyboardKittyProtocol(t *testing.T) { | |||||
| 563 | } | 566 | } | ||
| 564 | } | 567 | } | ||
| 565 | 568 | ||||
| 569 | + | func TestParseEchoPredict(t *testing.T) { | |||
| 570 | + | t.Parallel() | |||
| 571 | + | ||||
| 572 | + | tests := []struct { | |||
| 573 | + | name string | |||
| 574 | + | input string | |||
| 575 | + | want domain.EchoPredictMode | |||
| 576 | + | wantWarnings []domain.Warning | |||
| 577 | + | }{ | |||
| 578 | + | {name: "absent key defaults adaptive", input: "theme = dark\n", want: domain.EchoPredictAdaptive}, | |||
| 579 | + | {name: "adaptive", input: "echo.predict = adaptive\n", want: domain.EchoPredictAdaptive}, | |||
| 580 | + | {name: "always", input: "echo.predict = always\n", want: domain.EchoPredictAlways}, | |||
| 581 | + | {name: "never", input: "echo.predict = Never\n", want: domain.EchoPredictNever}, | |||
| 582 | + | { | |||
| 583 | + | name: "invalid value warns and keeps default", | |||
| 584 | + | input: "echo.predict = sometimes\n", | |||
| 585 | + | want: domain.EchoPredictAdaptive, | |||
| 586 | + | wantWarnings: []domain.Warning{ | |||
| 587 | + | {Line: 1, Msg: "invalid echo.predict \"sometimes\" (want adaptive, always, or never)"}, | |||
| 588 | + | }, | |||
| 589 | + | }, | |||
| 590 | + | { | |||
| 591 | + | name: "duplicate key warns and keeps last", | |||
| 592 | + | input: "echo.predict = always\necho.predict = never\n", | |||
| 593 | + | want: domain.EchoPredictNever, | |||
| 594 | + | wantWarnings: []domain.Warning{ | |||
| 595 | + | {Line: 2, Msg: "duplicate key \"echo.predict\""}, | |||
| 596 | + | }, | |||
| 597 | + | }, | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | for _, tt := range tests { | |||
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 602 | + | t.Parallel() | |||
| 603 | + | ||||
| 604 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 605 | + | if err != nil { | |||
| 606 | + | t.Fatalf("Parse() error = %v", err) | |||
| 607 | + | } | |||
| 608 | + | if got.Echo.Predict != tt.want { | |||
| 609 | + | t.Fatalf("Parse() Echo.Predict = %v, want %v", got.Echo.Predict, tt.want) | |||
| 610 | + | } | |||
| 611 | + | if !reflect.DeepEqual(warnings, tt.wantWarnings) { | |||
| 612 | + | t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings) | |||
| 613 | + | } | |||
| 614 | + | }) | |||
| 615 | + | } | |||
| 616 | + | } | |||
| 617 | + | ||||
| 566 | func TestParseNavigationOverflow(t *testing.T) { | 618 | func TestParseNavigationOverflow(t *testing.T) { | ||
| 567 | t.Parallel() | 619 | t.Parallel() | ||
| 568 | 620 | ||||
M internal/app/broker_client.go +6 -2
| @@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf | |||
| 685 | 685 | } | |
| 686 | 686 | ||
| 687 | 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | |
| 688 | - | // userTerminalAttachmentEnvironment for it. | |
| 688 | + | // userTerminalAttachmentEnvironment for it, with the user's echo setting. | |
| 689 | 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |
| 690 | - | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | |
| 690 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) | |
| 691 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |
| 692 | + | env.EchoPredict = cfg.Echo.Predict | |
| 693 | + | return env | |
| 691 | 694 | } | |
| 692 | 695 | ||
| 693 | 696 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | |
| @@ -698,6 +701,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir | |||
| 698 | 701 | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |
| 699 | 702 | env.ProbeTerminal = true | |
| 700 | 703 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | |
| 704 | + | env.EchoPredict = cfg.Echo.Predict | |
| 701 | 705 | return env | |
| 702 | 706 | } | |
| 703 | 707 | ||
| @@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf | |||||
| 685 | } | 685 | } | ||
| 686 | 686 | ||||
| 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | ||
| 688 | - | // userTerminalAttachmentEnvironment for it. | 688 | + | // userTerminalAttachmentEnvironment for it, with the user's echo setting. |
| 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | ||
| 690 | - | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | 690 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) |
| 691 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |||
| 692 | + | env.EchoPredict = cfg.Echo.Predict | |||
| 693 | + | return env | |||
| 691 | } | 694 | } | ||
| 692 | 695 | ||||
| 693 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | 696 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | ||
| @@ -698,6 +701,7 @@ func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvir | |||||
| 698 | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | 701 | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | ||
| 699 | env.ProbeTerminal = true | 702 | env.ProbeTerminal = true | ||
| 700 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | 703 | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | ||
| 704 | + | env.EchoPredict = cfg.Echo.Predict | |||
| 701 | return env | 705 | return env | ||
| 702 | } | 706 | } | ||
| 703 | 707 | ||||
M internal/domain/config.go +25 -0
| @@ -102,6 +102,27 @@ type KeyboardConfig struct { | |||
| 102 | 102 | KittyProtocol bool | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | + | // EchoPredictMode selects when a remote attachment shows predicted local echo. | |
| 106 | + | type EchoPredictMode uint8 | |
| 107 | + | ||
| 108 | + | const ( | |
| 109 | + | // EchoPredictNever disables prediction. It is the zero value, so a | |
| 110 | + | // client composed without the user's config (virtual terminals, UI | |
| 111 | + | // drivers) never predicts. | |
| 112 | + | EchoPredictNever EchoPredictMode = iota | |
| 113 | + | // EchoPredictAdaptive shows predictions only on a slow or glitchy link. | |
| 114 | + | EchoPredictAdaptive | |
| 115 | + | // EchoPredictAlways shows predictions whatever the link latency. | |
| 116 | + | EchoPredictAlways | |
| 117 | + | ) | |
| 118 | + | ||
| 119 | + | // EchoConfig contains client-side predictive echo settings. | |
| 120 | + | type EchoConfig struct { | |
| 121 | + | // Predict selects when remote attachments draw typed characters before | |
| 122 | + | // the daemon echoes them. | |
| 123 | + | Predict EchoPredictMode | |
| 124 | + | } | |
| 125 | + | ||
| 105 | 126 | // TerminalConfig contains client-side outer-terminal settings. It is read by | |
| 106 | 127 | // each client at startup and never applied by the daemon, so a change takes | |
| 107 | 128 | // effect on the next client process. | |
| @@ -153,6 +174,7 @@ type Config struct { | |||
| 153 | 174 | Tabs TabsConfig | |
| 154 | 175 | Ephemeral EphemeralConfig | |
| 155 | 176 | Keyboard KeyboardConfig | |
| 177 | + | Echo EchoConfig | |
| 156 | 178 | Terminal TerminalConfig | |
| 157 | 179 | Scrollback ScrollbackConfig | |
| 158 | 180 | } | |
| @@ -198,6 +220,9 @@ func Defaults() Config { | |||
| 198 | 220 | Keyboard: KeyboardConfig{ | |
| 199 | 221 | KittyProtocol: true, | |
| 200 | 222 | }, | |
| 223 | + | Echo: EchoConfig{ | |
| 224 | + | Predict: EchoPredictAdaptive, | |
| 225 | + | }, | |
| 201 | 226 | Scrollback: DefaultScrollbackConfig(), | |
| 202 | 227 | } | |
| 203 | 228 | } | |
| @@ -102,6 +102,27 @@ type KeyboardConfig struct { | |||||
| 102 | KittyProtocol bool | 102 | KittyProtocol bool | ||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | + | // EchoPredictMode selects when a remote attachment shows predicted local echo. | |||
| 106 | + | type EchoPredictMode uint8 | |||
| 107 | + | ||||
| 108 | + | const ( | |||
| 109 | + | // EchoPredictNever disables prediction. It is the zero value, so a | |||
| 110 | + | // client composed without the user's config (virtual terminals, UI | |||
| 111 | + | // drivers) never predicts. | |||
| 112 | + | EchoPredictNever EchoPredictMode = iota | |||
| 113 | + | // EchoPredictAdaptive shows predictions only on a slow or glitchy link. | |||
| 114 | + | EchoPredictAdaptive | |||
| 115 | + | // EchoPredictAlways shows predictions whatever the link latency. | |||
| 116 | + | EchoPredictAlways | |||
| 117 | + | ) | |||
| 118 | + | ||||
| 119 | + | // EchoConfig contains client-side predictive echo settings. | |||
| 120 | + | type EchoConfig struct { | |||
| 121 | + | // Predict selects when remote attachments draw typed characters before | |||
| 122 | + | // the daemon echoes them. | |||
| 123 | + | Predict EchoPredictMode | |||
| 124 | + | } | |||
| 125 | + | ||||
| 105 | // TerminalConfig contains client-side outer-terminal settings. It is read by | 126 | // TerminalConfig contains client-side outer-terminal settings. It is read by | ||
| 106 | // each client at startup and never applied by the daemon, so a change takes | 127 | // each client at startup and never applied by the daemon, so a change takes | ||
| 107 | // effect on the next client process. | 128 | // effect on the next client process. | ||
| @@ -153,6 +174,7 @@ type Config struct { | |||||
| 153 | Tabs TabsConfig | 174 | Tabs TabsConfig | ||
| 154 | Ephemeral EphemeralConfig | 175 | Ephemeral EphemeralConfig | ||
| 155 | Keyboard KeyboardConfig | 176 | Keyboard KeyboardConfig | ||
| 177 | + | Echo EchoConfig | |||
| 156 | Terminal TerminalConfig | 178 | Terminal TerminalConfig | ||
| 157 | Scrollback ScrollbackConfig | 179 | Scrollback ScrollbackConfig | ||
| 158 | } | 180 | } | ||
| @@ -198,6 +220,9 @@ func Defaults() Config { | |||||
| 198 | Keyboard: KeyboardConfig{ | 220 | Keyboard: KeyboardConfig{ | ||
| 199 | KittyProtocol: true, | 221 | KittyProtocol: true, | ||
| 200 | }, | 222 | }, | ||
| 223 | + | Echo: EchoConfig{ | |||
| 224 | + | Predict: EchoPredictAdaptive, | |||
| 225 | + | }, | |||
| 201 | Scrollback: DefaultScrollbackConfig(), | 226 | Scrollback: DefaultScrollbackConfig(), | ||
| 202 | } | 227 | } | ||
| 203 | } | 228 | } | ||
A internal/usecase/client/attachment_echo.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // echoTickInterval re-culls pending guesses while a timing trigger may still | |
| 13 | + | // fire without output, as mosh's PredictionEngine::wait_time. | |
| 14 | + | const echoTickInterval = 50 * time.Millisecond | |
| 15 | + | ||
| 16 | + | // attachmentEcho connects the prediction engine to one attached loop: it feeds | |
| 17 | + | // sent input and applied output to the engine and writes the guesses through | |
| 18 | + | // the foreground. It is nil when the attachment does not predict (local | |
| 19 | + | // attachments, scripted foregrounds, echo.predict = never), and every method | |
| 20 | + | // is a no-op on nil. | |
| 21 | + | type attachmentEcho struct { | |
| 22 | + | predictor *echoPredictor | |
| 23 | + | out attachmentPredictionForeground | |
| 24 | + | clock ports.Clock | |
| 25 | + | tick ports.Timer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newAttachmentEcho(w *sessionAttachmentWorker, fg AttachmentForeground) *attachmentEcho { | |
| 29 | + | if w.cfg.Request.Local || w.cfg.EchoPredict == domain.EchoPredictNever { | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | out, ok := fg.(attachmentPredictionForeground) | |
| 33 | + | if !ok { | |
| 34 | + | return nil | |
| 35 | + | } | |
| 36 | + | return &attachmentEcho{predictor: newEchoPredictor(w.cfg.EchoPredict), out: out, clock: w.cfg.Clock} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // seed mirrors output written before the loop started (the initial frame). | |
| 40 | + | func (e *attachmentEcho) seed(output protocol.Output) { | |
| 41 | + | if e == nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | e.predictor.applyOutput(output, e.clock.Now()) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // input records one session Input sent with seq. | |
| 48 | + | func (e *attachmentEcho) input(seq uint64, data []byte) error { | |
| 49 | + | if e == nil { | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | e.predictor.input(seq, data, e.clock.Now()) | |
| 53 | + | return e.draw() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // output mirrors one Output after the foreground wrote it. | |
| 57 | + | func (e *attachmentEcho) output(output protocol.Output) error { | |
| 58 | + | if e == nil { | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | e.predictor.applyOutput(output, e.clock.Now()) | |
| 62 | + | return e.draw() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // resized handles a terminal resize. A new size makes the daemon repaint | |
| 66 | + | // everything, so the guesses are simply forgotten. The same size brings no | |
| 67 | + | // repaint, so drawn guesses are erased from the mirror instead. | |
| 68 | + | func (e *attachmentEcho) resized(size domain.Size) error { | |
| 69 | + | if e == nil { | |
| 70 | + | return nil | |
| 71 | + | } | |
| 72 | + | screen := e.predictor.screen | |
| 73 | + | if size.Cols != screen.Columns() || size.Rows != screen.Rows() { | |
| 74 | + | e.predictor.forget() | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | e.predictor.reset() | |
| 78 | + | return e.draw() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // tickC fires while a pending guess waits on a timing trigger. | |
| 82 | + | func (e *attachmentEcho) tickC() <-chan time.Time { | |
| 83 | + | if e == nil || e.tick == nil { | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | return e.tick.C() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (e *attachmentEcho) ticked() error { | |
| 90 | + | if e == nil { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | e.tick = nil | |
| 94 | + | e.predictor.cull(e.clock.Now()) | |
| 95 | + | return e.draw() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (e *attachmentEcho) close() { | |
| 99 | + | if e != nil && e.tick != nil { | |
| 100 | + | e.tick.Stop() | |
| 101 | + | e.tick = nil | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (e *attachmentEcho) draw() error { | |
| 106 | + | defer e.armTick() | |
| 107 | + | data := e.predictor.render() | |
| 108 | + | if len(data) == 0 { | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | written, err := e.out.writePrediction(data) | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("writing predicted echo: %w", err) | |
| 114 | + | } | |
| 115 | + | if !written { | |
| 116 | + | // An overlay owns the terminal: its box covers the guesses and the | |
| 117 | + | // daemon repaints everything once it closes. | |
| 118 | + | e.predictor.forget() | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (e *attachmentEcho) armTick() { | |
| 124 | + | if e.tick != nil || !e.predictor.needsTick() { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | e.tick = e.clock.NewTimer(echoTickInterval) | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // echoTickInterval re-culls pending guesses while a timing trigger may still | |||
| 13 | + | // fire without output, as mosh's PredictionEngine::wait_time. | |||
| 14 | + | const echoTickInterval = 50 * time.Millisecond | |||
| 15 | + | ||||
| 16 | + | // attachmentEcho connects the prediction engine to one attached loop: it feeds | |||
| 17 | + | // sent input and applied output to the engine and writes the guesses through | |||
| 18 | + | // the foreground. It is nil when the attachment does not predict (local | |||
| 19 | + | // attachments, scripted foregrounds, echo.predict = never), and every method | |||
| 20 | + | // is a no-op on nil. | |||
| 21 | + | type attachmentEcho struct { | |||
| 22 | + | predictor *echoPredictor | |||
| 23 | + | out attachmentPredictionForeground | |||
| 24 | + | clock ports.Clock | |||
| 25 | + | tick ports.Timer | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func newAttachmentEcho(w *sessionAttachmentWorker, fg AttachmentForeground) *attachmentEcho { | |||
| 29 | + | if w.cfg.Request.Local || w.cfg.EchoPredict == domain.EchoPredictNever { | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | out, ok := fg.(attachmentPredictionForeground) | |||
| 33 | + | if !ok { | |||
| 34 | + | return nil | |||
| 35 | + | } | |||
| 36 | + | return &attachmentEcho{predictor: newEchoPredictor(w.cfg.EchoPredict), out: out, clock: w.cfg.Clock} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // seed mirrors output written before the loop started (the initial frame). | |||
| 40 | + | func (e *attachmentEcho) seed(output protocol.Output) { | |||
| 41 | + | if e == nil { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | e.predictor.applyOutput(output, e.clock.Now()) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // input records one session Input sent with seq. | |||
| 48 | + | func (e *attachmentEcho) input(seq uint64, data []byte) error { | |||
| 49 | + | if e == nil { | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | e.predictor.input(seq, data, e.clock.Now()) | |||
| 53 | + | return e.draw() | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // output mirrors one Output after the foreground wrote it. | |||
| 57 | + | func (e *attachmentEcho) output(output protocol.Output) error { | |||
| 58 | + | if e == nil { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | e.predictor.applyOutput(output, e.clock.Now()) | |||
| 62 | + | return e.draw() | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // resized handles a terminal resize. A new size makes the daemon repaint | |||
| 66 | + | // everything, so the guesses are simply forgotten. The same size brings no | |||
| 67 | + | // repaint, so drawn guesses are erased from the mirror instead. | |||
| 68 | + | func (e *attachmentEcho) resized(size domain.Size) error { | |||
| 69 | + | if e == nil { | |||
| 70 | + | return nil | |||
| 71 | + | } | |||
| 72 | + | screen := e.predictor.screen | |||
| 73 | + | if size.Cols != screen.Columns() || size.Rows != screen.Rows() { | |||
| 74 | + | e.predictor.forget() | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | e.predictor.reset() | |||
| 78 | + | return e.draw() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // tickC fires while a pending guess waits on a timing trigger. | |||
| 82 | + | func (e *attachmentEcho) tickC() <-chan time.Time { | |||
| 83 | + | if e == nil || e.tick == nil { | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | return e.tick.C() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (e *attachmentEcho) ticked() error { | |||
| 90 | + | if e == nil { | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | e.tick = nil | |||
| 94 | + | e.predictor.cull(e.clock.Now()) | |||
| 95 | + | return e.draw() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (e *attachmentEcho) close() { | |||
| 99 | + | if e != nil && e.tick != nil { | |||
| 100 | + | e.tick.Stop() | |||
| 101 | + | e.tick = nil | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (e *attachmentEcho) draw() error { | |||
| 106 | + | defer e.armTick() | |||
| 107 | + | data := e.predictor.render() | |||
| 108 | + | if len(data) == 0 { | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | written, err := e.out.writePrediction(data) | |||
| 112 | + | if err != nil { | |||
| 113 | + | return fmt.Errorf("writing predicted echo: %w", err) | |||
| 114 | + | } | |||
| 115 | + | if !written { | |||
| 116 | + | // An overlay owns the terminal: its box covers the guesses and the | |||
| 117 | + | // daemon repaints everything once it closes. | |||
| 118 | + | e.predictor.forget() | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (e *attachmentEcho) armTick() { | |||
| 124 | + | if e.tick != nil || !e.predictor.needsTick() { | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | e.tick = e.clock.NewTimer(echoTickInterval) | |||
| 128 | + | } | |||
A internal/usecase/client/attachment_echo_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) { | |
| 16 | + | t.Helper() | |
| 17 | + | out := newMockattachmentPredictionForeground(t) | |
| 18 | + | clock := portsmocks.NewMockClock(t) | |
| 19 | + | clock.EXPECT().Now().Return(echoTestStart).Maybe() | |
| 20 | + | echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock} | |
| 21 | + | echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}) | |
| 22 | + | // One acknowledged input gives the engine its first round trip. | |
| 23 | + | echo.predictor.input(1, nil, echoTestStart) | |
| 24 | + | echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay)) | |
| 25 | + | echo.predictor.confEpoch = echo.predictor.predEpoch | |
| 26 | + | return echo, out, clock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) { | |
| 30 | + | t.Parallel() | |
| 31 | + | fg := newMockattachmentPredictionForeground(t) | |
| 32 | + | tests := []struct { | |
| 33 | + | name string | |
| 34 | + | local bool | |
| 35 | + | mode domain.EchoPredictMode | |
| 36 | + | fg AttachmentForeground | |
| 37 | + | }{ | |
| 38 | + | {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive}, | |
| 39 | + | {name: "prediction off", mode: domain.EchoPredictNever}, | |
| 40 | + | {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive}, | |
| 41 | + | } | |
| 42 | + | for _, tt := range tests { | |
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |
| 44 | + | t.Parallel() | |
| 45 | + | w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}} | |
| 46 | + | w.cfg.Request.Local = tt.local | |
| 47 | + | require.Nil(t, newAttachmentEcho(w, tt.fg)) | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct { | |
| 51 | + | AttachmentForeground | |
| 52 | + | attachmentPredictionForeground | |
| 53 | + | }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) { | |
| 57 | + | t.Parallel() | |
| 58 | + | echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive) | |
| 59 | + | // A slow link: guesses show, and the tick waits on the glitch trigger. | |
| 60 | + | echo.predictor.srtt = 100 * time.Millisecond | |
| 61 | + | tick := portsmocks.NewMockTimer(t) | |
| 62 | + | clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once() | |
| 63 | + | var drawn []byte | |
| 64 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |
| 65 | + | drawn = append([]byte(nil), data...) | |
| 66 | + | return true, nil | |
| 67 | + | }).Once() | |
| 68 | + | ||
| 69 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 70 | + | require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn)) | |
| 71 | + | ||
| 72 | + | fired := make(chan time.Time) | |
| 73 | + | tick.EXPECT().C().Return(fired).Once() | |
| 74 | + | require.Equal(t, (<-chan time.Time)(fired), echo.tickC()) | |
| 75 | + | tick.EXPECT().Stop().Return(true).Once() | |
| 76 | + | echo.close() | |
| 77 | + | require.Nil(t, echo.tickC()) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) { | |
| 81 | + | t.Parallel() | |
| 82 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |
| 83 | + | out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once() | |
| 84 | + | ||
| 85 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 86 | + | require.False(t, echo.predictor.active(), "guesses survived an overlay") | |
| 87 | + | require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |
| 93 | + | out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once() | |
| 94 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 95 | + | ||
| 96 | + | var repaint []byte | |
| 97 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |
| 98 | + | repaint = append([]byte(nil), data...) | |
| 99 | + | return true, nil | |
| 100 | + | }).Once() | |
| 101 | + | require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3})) | |
| 102 | + | require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint)) | |
| 103 | + | ||
| 104 | + | // A new size brings a full repaint from the daemon: nothing is written. | |
| 105 | + | require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3})) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | out := newMockattachmentPredictionForeground(t) | |||
| 18 | + | clock := portsmocks.NewMockClock(t) | |||
| 19 | + | clock.EXPECT().Now().Return(echoTestStart).Maybe() | |||
| 20 | + | echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock} | |||
| 21 | + | echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}) | |||
| 22 | + | // One acknowledged input gives the engine its first round trip. | |||
| 23 | + | echo.predictor.input(1, nil, echoTestStart) | |||
| 24 | + | echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay)) | |||
| 25 | + | echo.predictor.confEpoch = echo.predictor.predEpoch | |||
| 26 | + | return echo, out, clock | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) { | |||
| 30 | + | t.Parallel() | |||
| 31 | + | fg := newMockattachmentPredictionForeground(t) | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name string | |||
| 34 | + | local bool | |||
| 35 | + | mode domain.EchoPredictMode | |||
| 36 | + | fg AttachmentForeground | |||
| 37 | + | }{ | |||
| 38 | + | {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive}, | |||
| 39 | + | {name: "prediction off", mode: domain.EchoPredictNever}, | |||
| 40 | + | {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive}, | |||
| 41 | + | } | |||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | t.Parallel() | |||
| 45 | + | w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}} | |||
| 46 | + | w.cfg.Request.Local = tt.local | |||
| 47 | + | require.Nil(t, newAttachmentEcho(w, tt.fg)) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct { | |||
| 51 | + | AttachmentForeground | |||
| 52 | + | attachmentPredictionForeground | |||
| 53 | + | }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) { | |||
| 57 | + | t.Parallel() | |||
| 58 | + | echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive) | |||
| 59 | + | // A slow link: guesses show, and the tick waits on the glitch trigger. | |||
| 60 | + | echo.predictor.srtt = 100 * time.Millisecond | |||
| 61 | + | tick := portsmocks.NewMockTimer(t) | |||
| 62 | + | clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once() | |||
| 63 | + | var drawn []byte | |||
| 64 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |||
| 65 | + | drawn = append([]byte(nil), data...) | |||
| 66 | + | return true, nil | |||
| 67 | + | }).Once() | |||
| 68 | + | ||||
| 69 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 70 | + | require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn)) | |||
| 71 | + | ||||
| 72 | + | fired := make(chan time.Time) | |||
| 73 | + | tick.EXPECT().C().Return(fired).Once() | |||
| 74 | + | require.Equal(t, (<-chan time.Time)(fired), echo.tickC()) | |||
| 75 | + | tick.EXPECT().Stop().Return(true).Once() | |||
| 76 | + | echo.close() | |||
| 77 | + | require.Nil(t, echo.tickC()) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) { | |||
| 81 | + | t.Parallel() | |||
| 82 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |||
| 83 | + | out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once() | |||
| 84 | + | ||||
| 85 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 86 | + | require.False(t, echo.predictor.active(), "guesses survived an overlay") | |||
| 87 | + | require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |||
| 93 | + | out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once() | |||
| 94 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 95 | + | ||||
| 96 | + | var repaint []byte | |||
| 97 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |||
| 98 | + | repaint = append([]byte(nil), data...) | |||
| 99 | + | return true, nil | |||
| 100 | + | }).Once() | |||
| 101 | + | require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3})) | |||
| 102 | + | require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint)) | |||
| 103 | + | ||||
| 104 | + | // A new size brings a full repaint from the daemon: nothing is written. | |||
| 105 | + | require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3})) | |||
| 106 | + | } | |||
M internal/usecase/client/attachment_input.go +8 -1
| @@ -33,6 +33,7 @@ type attachmentInput struct { | |||
| 33 | 33 | fg AttachmentForeground | |
| 34 | 34 | stream ports.BrokerLogicalConnection | |
| 35 | 35 | picker *attachmentMovePicker | |
| 36 | + | echo *attachmentEcho | |
| 36 | 37 | clock ports.Clock | |
| 37 | 38 | seq uint64 | |
| 38 | 39 | ||
| @@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati | |||
| 99 | 100 | return event | |
| 100 | 101 | } | |
| 101 | 102 | ||
| 102 | - | func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput { | |
| 103 | + | func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker, echo *attachmentEcho) *attachmentInput { | |
| 103 | 104 | in := &attachmentInput{ | |
| 104 | 105 | worker: w, | |
| 105 | 106 | fg: fg, | |
| 106 | 107 | stream: stream, | |
| 107 | 108 | picker: picker, | |
| 109 | + | echo: echo, | |
| 108 | 110 | clock: w.cfg.Clock, | |
| 109 | 111 | wake: make(chan struct{}, 1), | |
| 110 | 112 | themes: w.cfg.Theme, | |
| @@ -311,6 +313,11 @@ func (in *attachmentInput) flush(ctx context.Context) error { | |||
| 311 | 313 | if err := in.send(ctx, message); err != nil { | |
| 312 | 314 | return err | |
| 313 | 315 | } | |
| 316 | + | if m, ok := message.(protocol.Input); ok { | |
| 317 | + | if err := in.echo.input(m.InputSeq, m.Data); err != nil { | |
| 318 | + | return err | |
| 319 | + | } | |
| 320 | + | } | |
| 314 | 321 | } | |
| 315 | 322 | return nil | |
| 316 | 323 | } | |
| @@ -33,6 +33,7 @@ type attachmentInput struct { | |||||
| 33 | fg AttachmentForeground | 33 | fg AttachmentForeground | ||
| 34 | stream ports.BrokerLogicalConnection | 34 | stream ports.BrokerLogicalConnection | ||
| 35 | picker *attachmentMovePicker | 35 | picker *attachmentMovePicker | ||
| 36 | + | echo *attachmentEcho | |||
| 36 | clock ports.Clock | 37 | clock ports.Clock | ||
| 37 | seq uint64 | 38 | seq uint64 | ||
| 38 | 39 | ||||
| @@ -99,12 +100,13 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati | |||||
| 99 | return event | 100 | return event | ||
| 100 | } | 101 | } | ||
| 101 | 102 | ||||
| 102 | - | func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput { | 103 | + | func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker, echo *attachmentEcho) *attachmentInput { |
| 103 | in := &attachmentInput{ | 104 | in := &attachmentInput{ | ||
| 104 | worker: w, | 105 | worker: w, | ||
| 105 | fg: fg, | 106 | fg: fg, | ||
| 106 | stream: stream, | 107 | stream: stream, | ||
| 107 | picker: picker, | 108 | picker: picker, | ||
| 109 | + | echo: echo, | |||
| 108 | clock: w.cfg.Clock, | 110 | clock: w.cfg.Clock, | ||
| 109 | wake: make(chan struct{}, 1), | 111 | wake: make(chan struct{}, 1), | ||
| 110 | themes: w.cfg.Theme, | 112 | themes: w.cfg.Theme, | ||
| @@ -311,6 +313,11 @@ func (in *attachmentInput) flush(ctx context.Context) error { | |||||
| 311 | if err := in.send(ctx, message); err != nil { | 313 | if err := in.send(ctx, message); err != nil { | ||
| 312 | return err | 314 | return err | ||
| 313 | } | 315 | } | ||
| 316 | + | if m, ok := message.(protocol.Input); ok { | |||
| 317 | + | if err := in.echo.input(m.InputSeq, m.Data); err != nil { | |||
| 318 | + | return err | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 314 | } | 321 | } | ||
| 315 | return nil | 322 | return nil | ||
| 316 | } | 323 | } | ||
M internal/usecase/client/attachment_session.go +39 -18
| @@ -99,6 +99,8 @@ type sessionAttachmentConfig struct { | |||
| 99 | 99 | Tab attachmentTab | |
| 100 | 100 | // Tabs retains this client's committed tab per authority and session. | |
| 101 | 101 | Tabs *sessionTabMemory | |
| 102 | + | // EchoPredict selects predictive local echo on remote attachments. | |
| 103 | + | EchoPredict domain.EchoPredictMode | |
| 102 | 104 | } | |
| 103 | 105 | ||
| 104 | 106 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -191,35 +193,35 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou | |||
| 191 | 193 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)} | |
| 192 | 194 | } | |
| 193 | 195 | ||
| 194 | - | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | |
| 196 | + | state, initial, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | |
| 195 | 197 | if event != nil { | |
| 196 | 198 | return *event | |
| 197 | 199 | } | |
| 198 | 200 | ||
| 199 | - | return w.pumpAttached(ctx, fg, stream, token, state, incoming) | |
| 201 | + | return w.pumpAttached(ctx, fg, stream, token, state, initial, incoming) | |
| 200 | 202 | } | |
| 201 | 203 | ||
| 202 | 204 | // awaitInitialPublication consumes server messages until the first valid full | |
| 203 | - | // output frame is committed. It returns the output state to continue with, or | |
| 204 | - | // a typed terminal event. | |
| 205 | - | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) { | |
| 205 | + | // output frame is committed. It returns the output state to continue with and | |
| 206 | + | // the committed frame, or a typed terminal event. | |
| 207 | + | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, protocol.Output, *AttachmentEvent) { | |
| 206 | 208 | state := outputApplyState{} | |
| 207 | 209 | for { | |
| 208 | 210 | message, err := w.awaitServer(ctx, fg, incoming) | |
| 209 | 211 | if err != nil { | |
| 210 | 212 | event := w.settle(ctx, fg, stream, token, err) | |
| 211 | - | return state, &event | |
| 213 | + | return state, protocol.Output{}, &event | |
| 212 | 214 | } | |
| 213 | 215 | switch typed := message.(type) { | |
| 214 | 216 | case protocol.Output: | |
| 215 | 217 | next, ok := state.next(typed) | |
| 216 | 218 | if !ok { | |
| 217 | 219 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | |
| 218 | - | return state, &event | |
| 220 | + | return state, protocol.Output{}, &event | |
| 219 | 221 | } | |
| 220 | 222 | if err := w.validatePublication(typed); err != nil { | |
| 221 | 223 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 222 | - | return state, &event | |
| 224 | + | return state, protocol.Output{}, &event | |
| 223 | 225 | } | |
| 224 | 226 | state = next | |
| 225 | 227 | w.noteCommitted(fg, state.context) | |
| @@ -229,11 +231,11 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 229 | 231 | // that write, flush, and commit succeeded. | |
| 230 | 232 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | |
| 231 | 233 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)} | |
| 232 | - | return state, &event | |
| 234 | + | return state, protocol.Output{}, &event | |
| 233 | 235 | } | |
| 234 | 236 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | |
| 235 | 237 | event := w.settle(ctx, fg, stream, token, err) | |
| 236 | - | return state, &event | |
| 238 | + | return state, protocol.Output{}, &event | |
| 237 | 239 | } | |
| 238 | 240 | // Attachment is the committed publication. Complete the deadline | |
| 239 | 241 | // boundary before presenting attached, so attached always implies the | |
| @@ -241,14 +243,14 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 241 | 243 | if w.cfg.BeforeAttached != nil { | |
| 242 | 244 | if err := w.cfg.BeforeAttached(); err != nil { | |
| 243 | 245 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | |
| 244 | - | return state, &event | |
| 246 | + | return state, protocol.Output{}, &event | |
| 245 | 247 | } | |
| 246 | 248 | } | |
| 247 | 249 | // A refused marker means the grant was revoked underneath us, so the | |
| 248 | 250 | // run ends as a failure. | |
| 249 | 251 | if !fg.MarkAttached() { | |
| 250 | 252 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | |
| 251 | - | return state, &event | |
| 253 | + | return state, protocol.Output{}, &event | |
| 252 | 254 | } | |
| 253 | 255 | // The attached presentation is published last, once the committed frame and | |
| 254 | 256 | // the attached marker are in place. It carries the real action generation | |
| @@ -257,17 +259,17 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 257 | 259 | // drained. | |
| 258 | 260 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | |
| 259 | 261 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)} | |
| 260 | - | return state, &event | |
| 262 | + | return state, protocol.Output{}, &event | |
| 261 | 263 | } | |
| 262 | - | return state, nil | |
| 264 | + | return state, typed, nil | |
| 263 | 265 | case protocol.ErrorMsg: | |
| 264 | 266 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 265 | - | return state, &event | |
| 267 | + | return state, protocol.Output{}, &event | |
| 266 | 268 | case protocol.Detached: | |
| 267 | 269 | // The daemon ended the attempt before publishing anything: it | |
| 268 | 270 | // never attached. | |
| 269 | 271 | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | |
| 270 | - | return state, &event | |
| 272 | + | return state, protocol.Output{}, &event | |
| 271 | 273 | default: | |
| 272 | 274 | // Everything else the daemon may publish before the initial frame | |
| 273 | 275 | // stays outside this slice's scope and is ignored. | |
| @@ -301,7 +303,7 @@ var ( | |||
| 301 | 303 | ||
| 302 | 304 | // pumpAttached publishes output and concurrently forwards the foreground's | |
| 303 | 305 | // authorized input, latest geometry and explicit lifecycle decision. | |
| 304 | - | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | |
| 306 | + | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, initial protocol.Output, incoming <-chan serverReceive) AttachmentEvent { | |
| 305 | 307 | pumpCtx, cancel := context.WithCancel(ctx) | |
| 306 | 308 | defer cancel() | |
| 307 | 309 | // Every send while attached goes through the outbox so a stalled link | |
| @@ -329,7 +331,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 329 | 331 | samePeerUI := attachmentSamePeerUI(fg) | |
| 330 | 332 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} | |
| 331 | 333 | defer picker.stopEscape() | |
| 332 | - | input := newAttachmentInput(ctx, w, fg, stream, picker) | |
| 334 | + | echo := newAttachmentEcho(w, fg) | |
| 335 | + | defer echo.close() | |
| 336 | + | echo.seed(initial) | |
| 337 | + | input := newAttachmentInput(ctx, w, fg, stream, picker, echo) | |
| 333 | 338 | defer input.close() | |
| 334 | 339 | if err := input.start(ctx); err != nil { | |
| 335 | 340 | return w.settle(ctx, fg, stream, token, err) | |
| @@ -437,6 +442,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 437 | 442 | if err := input.completionFired(ctx); err != nil { | |
| 438 | 443 | return w.settle(ctx, fg, stream, token, err) | |
| 439 | 444 | } | |
| 445 | + | case <-echo.tickC(): | |
| 446 | + | if err := echo.ticked(); err != nil { | |
| 447 | + | return w.settle(ctx, fg, stream, token, err) | |
| 448 | + | } | |
| 440 | 449 | case <-repaint: | |
| 441 | 450 | // An overlay released the terminal: the picker box is still on screen | |
| 442 | 451 | // and suppressed frames were never written, so ask the daemon for an | |
| @@ -491,6 +500,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 491 | 500 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 492 | 501 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)} | |
| 493 | 502 | } | |
| 503 | + | // The guesses go on top of the frame just written. | |
| 504 | + | if err := echo.output(typed); err != nil { | |
| 505 | + | return w.settle(ctx, fg, stream, token, err) | |
| 506 | + | } | |
| 494 | 507 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | |
| 495 | 508 | return w.settle(ctx, fg, stream, token, err) | |
| 496 | 509 | } | |
| @@ -626,6 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 626 | 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | |
| 627 | 640 | } | |
| 628 | 641 | if resize, ok := event.message.(protocol.Resize); ok { | |
| 642 | + | if err := echo.resized(resize.Size); err != nil { | |
| 643 | + | return w.settle(ctx, fg, stream, token, err) | |
| 644 | + | } | |
| 629 | 645 | if err := picker.resize(ctx, state, resize.Size); err != nil { | |
| 630 | 646 | return w.settle(ctx, fg, stream, token, err) | |
| 631 | 647 | } | |
| @@ -645,6 +661,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 645 | 661 | if err := await(from, event.input.actionID); err != nil { | |
| 646 | 662 | return w.settle(ctx, fg, stream, token, err) | |
| 647 | 663 | } | |
| 664 | + | if message, ok := event.message.(protocol.Input); ok { | |
| 665 | + | if err := echo.input(message.InputSeq, message.Data); err != nil { | |
| 666 | + | return w.settle(ctx, fg, stream, token, err) | |
| 667 | + | } | |
| 668 | + | } | |
| 648 | 669 | } | |
| 649 | 670 | if event.action != 0 { | |
| 650 | 671 | // The lifecycle message must reach the daemon before the | |
| @@ -99,6 +99,8 @@ type sessionAttachmentConfig struct { | |||||
| 99 | Tab attachmentTab | 99 | Tab attachmentTab | ||
| 100 | // Tabs retains this client's committed tab per authority and session. | 100 | // Tabs retains this client's committed tab per authority and session. | ||
| 101 | Tabs *sessionTabMemory | 101 | Tabs *sessionTabMemory | ||
| 102 | + | // EchoPredict selects predictive local echo on remote attachments. | |||
| 103 | + | EchoPredict domain.EchoPredictMode | |||
| 102 | } | 104 | } | ||
| 103 | 105 | ||||
| 104 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | 106 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | ||
| @@ -191,35 +193,35 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou | |||||
| 191 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)} | 193 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)} | ||
| 192 | } | 194 | } | ||
| 193 | 195 | ||||
| 194 | - | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | 196 | + | state, initial, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) |
| 195 | if event != nil { | 197 | if event != nil { | ||
| 196 | return *event | 198 | return *event | ||
| 197 | } | 199 | } | ||
| 198 | 200 | ||||
| 199 | - | return w.pumpAttached(ctx, fg, stream, token, state, incoming) | 201 | + | return w.pumpAttached(ctx, fg, stream, token, state, initial, incoming) |
| 200 | } | 202 | } | ||
| 201 | 203 | ||||
| 202 | // awaitInitialPublication consumes server messages until the first valid full | 204 | // awaitInitialPublication consumes server messages until the first valid full | ||
| 203 | - | // output frame is committed. It returns the output state to continue with, or | 205 | + | // output frame is committed. It returns the output state to continue with and |
| 204 | - | // a typed terminal event. | 206 | + | // the committed frame, or a typed terminal event. |
| 205 | - | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) { | 207 | + | func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, protocol.Output, *AttachmentEvent) { |
| 206 | state := outputApplyState{} | 208 | state := outputApplyState{} | ||
| 207 | for { | 209 | for { | ||
| 208 | message, err := w.awaitServer(ctx, fg, incoming) | 210 | message, err := w.awaitServer(ctx, fg, incoming) | ||
| 209 | if err != nil { | 211 | if err != nil { | ||
| 210 | event := w.settle(ctx, fg, stream, token, err) | 212 | event := w.settle(ctx, fg, stream, token, err) | ||
| 211 | - | return state, &event | 213 | + | return state, protocol.Output{}, &event |
| 212 | } | 214 | } | ||
| 213 | switch typed := message.(type) { | 215 | switch typed := message.(type) { | ||
| 214 | case protocol.Output: | 216 | case protocol.Output: | ||
| 215 | next, ok := state.next(typed) | 217 | next, ok := state.next(typed) | ||
| 216 | if !ok { | 218 | if !ok { | ||
| 217 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | 219 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication} | ||
| 218 | - | return state, &event | 220 | + | return state, protocol.Output{}, &event |
| 219 | } | 221 | } | ||
| 220 | if err := w.validatePublication(typed); err != nil { | 222 | if err := w.validatePublication(typed); err != nil { | ||
| 221 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | 223 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | ||
| 222 | - | return state, &event | 224 | + | return state, protocol.Output{}, &event |
| 223 | } | 225 | } | ||
| 224 | state = next | 226 | state = next | ||
| 225 | w.noteCommitted(fg, state.context) | 227 | w.noteCommitted(fg, state.context) | ||
| @@ -229,11 +231,11 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 229 | // that write, flush, and commit succeeded. | 231 | // that write, flush, and commit succeeded. | ||
| 230 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | 232 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | ||
| 231 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)} | 233 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)} | ||
| 232 | - | return state, &event | 234 | + | return state, protocol.Output{}, &event |
| 233 | } | 235 | } | ||
| 234 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | 236 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | ||
| 235 | event := w.settle(ctx, fg, stream, token, err) | 237 | event := w.settle(ctx, fg, stream, token, err) | ||
| 236 | - | return state, &event | 238 | + | return state, protocol.Output{}, &event |
| 237 | } | 239 | } | ||
| 238 | // Attachment is the committed publication. Complete the deadline | 240 | // Attachment is the committed publication. Complete the deadline | ||
| 239 | // boundary before presenting attached, so attached always implies the | 241 | // boundary before presenting attached, so attached always implies the | ||
| @@ -241,14 +243,14 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 241 | if w.cfg.BeforeAttached != nil { | 243 | if w.cfg.BeforeAttached != nil { | ||
| 242 | if err := w.cfg.BeforeAttached(); err != nil { | 244 | if err := w.cfg.BeforeAttached(); err != nil { | ||
| 243 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | 245 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err} | ||
| 244 | - | return state, &event | 246 | + | return state, protocol.Output{}, &event |
| 245 | } | 247 | } | ||
| 246 | } | 248 | } | ||
| 247 | // A refused marker means the grant was revoked underneath us, so the | 249 | // A refused marker means the grant was revoked underneath us, so the | ||
| 248 | // run ends as a failure. | 250 | // run ends as a failure. | ||
| 249 | if !fg.MarkAttached() { | 251 | if !fg.MarkAttached() { | ||
| 250 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | 252 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked} | ||
| 251 | - | return state, &event | 253 | + | return state, protocol.Output{}, &event |
| 252 | } | 254 | } | ||
| 253 | // The attached presentation is published last, once the committed frame and | 255 | // The attached presentation is published last, once the committed frame and | ||
| 254 | // the attached marker are in place. It carries the real action generation | 256 | // the attached marker are in place. It carries the real action generation | ||
| @@ -257,17 +259,17 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 257 | // drained. | 259 | // drained. | ||
| 258 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | 260 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | ||
| 259 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)} | 261 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)} | ||
| 260 | - | return state, &event | 262 | + | return state, protocol.Output{}, &event |
| 261 | } | 263 | } | ||
| 262 | - | return state, nil | 264 | + | return state, typed, nil |
| 263 | case protocol.ErrorMsg: | 265 | case protocol.ErrorMsg: | ||
| 264 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | 266 | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | ||
| 265 | - | return state, &event | 267 | + | return state, protocol.Output{}, &event |
| 266 | case protocol.Detached: | 268 | case protocol.Detached: | ||
| 267 | // The daemon ended the attempt before publishing anything: it | 269 | // The daemon ended the attempt before publishing anything: it | ||
| 268 | // never attached. | 270 | // never attached. | ||
| 269 | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | 271 | event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded} | ||
| 270 | - | return state, &event | 272 | + | return state, protocol.Output{}, &event |
| 271 | default: | 273 | default: | ||
| 272 | // Everything else the daemon may publish before the initial frame | 274 | // Everything else the daemon may publish before the initial frame | ||
| 273 | // stays outside this slice's scope and is ignored. | 275 | // stays outside this slice's scope and is ignored. | ||
| @@ -301,7 +303,7 @@ var ( | |||||
| 301 | 303 | ||||
| 302 | // pumpAttached publishes output and concurrently forwards the foreground's | 304 | // pumpAttached publishes output and concurrently forwards the foreground's | ||
| 303 | // authorized input, latest geometry and explicit lifecycle decision. | 305 | // authorized input, latest geometry and explicit lifecycle decision. | ||
| 304 | - | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent { | 306 | + | func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, raw ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, initial protocol.Output, incoming <-chan serverReceive) AttachmentEvent { |
| 305 | pumpCtx, cancel := context.WithCancel(ctx) | 307 | pumpCtx, cancel := context.WithCancel(ctx) | ||
| 306 | defer cancel() | 308 | defer cancel() | ||
| 307 | // Every send while attached goes through the outbox so a stalled link | 309 | // Every send while attached goes through the outbox so a stalled link | ||
| @@ -329,7 +331,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 329 | samePeerUI := attachmentSamePeerUI(fg) | 331 | samePeerUI := attachmentSamePeerUI(fg) | ||
| 330 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} | 332 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)} | ||
| 331 | defer picker.stopEscape() | 333 | defer picker.stopEscape() | ||
| 332 | - | input := newAttachmentInput(ctx, w, fg, stream, picker) | 334 | + | echo := newAttachmentEcho(w, fg) |
| 335 | + | defer echo.close() | |||
| 336 | + | echo.seed(initial) | |||
| 337 | + | input := newAttachmentInput(ctx, w, fg, stream, picker, echo) | |||
| 333 | defer input.close() | 338 | defer input.close() | ||
| 334 | if err := input.start(ctx); err != nil { | 339 | if err := input.start(ctx); err != nil { | ||
| 335 | return w.settle(ctx, fg, stream, token, err) | 340 | return w.settle(ctx, fg, stream, token, err) | ||
| @@ -437,6 +442,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 437 | if err := input.completionFired(ctx); err != nil { | 442 | if err := input.completionFired(ctx); err != nil { | ||
| 438 | return w.settle(ctx, fg, stream, token, err) | 443 | return w.settle(ctx, fg, stream, token, err) | ||
| 439 | } | 444 | } | ||
| 445 | + | case <-echo.tickC(): | |||
| 446 | + | if err := echo.ticked(); err != nil { | |||
| 447 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 448 | + | } | |||
| 440 | case <-repaint: | 449 | case <-repaint: | ||
| 441 | // An overlay released the terminal: the picker box is still on screen | 450 | // An overlay released the terminal: the picker box is still on screen | ||
| 442 | // and suppressed frames were never written, so ask the daemon for an | 451 | // and suppressed frames were never written, so ask the daemon for an | ||
| @@ -491,6 +500,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 491 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | 500 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | ||
| 492 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)} | 501 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)} | ||
| 493 | } | 502 | } | ||
| 503 | + | // The guesses go on top of the frame just written. | |||
| 504 | + | if err := echo.output(typed); err != nil { | |||
| 505 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 506 | + | } | |||
| 494 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | 507 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | ||
| 495 | return w.settle(ctx, fg, stream, token, err) | 508 | return w.settle(ctx, fg, stream, token, err) | ||
| 496 | } | 509 | } | ||
| @@ -626,6 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 626 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | ||
| 627 | } | 640 | } | ||
| 628 | if resize, ok := event.message.(protocol.Resize); ok { | 641 | if resize, ok := event.message.(protocol.Resize); ok { | ||
| 642 | + | if err := echo.resized(resize.Size); err != nil { | |||
| 643 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 644 | + | } | |||
| 629 | if err := picker.resize(ctx, state, resize.Size); err != nil { | 645 | if err := picker.resize(ctx, state, resize.Size); err != nil { | ||
| 630 | return w.settle(ctx, fg, stream, token, err) | 646 | return w.settle(ctx, fg, stream, token, err) | ||
| 631 | } | 647 | } | ||
| @@ -645,6 +661,11 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 645 | if err := await(from, event.input.actionID); err != nil { | 661 | if err := await(from, event.input.actionID); err != nil { | ||
| 646 | return w.settle(ctx, fg, stream, token, err) | 662 | return w.settle(ctx, fg, stream, token, err) | ||
| 647 | } | 663 | } | ||
| 664 | + | if message, ok := event.message.(protocol.Input); ok { | |||
| 665 | + | if err := echo.input(message.InputSeq, message.Data); err != nil { | |||
| 666 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 667 | + | } | |||
| 668 | + | } | |||
| 648 | } | 669 | } | ||
| 649 | if event.action != 0 { | 670 | if event.action != 0 { | ||
| 650 | // The lifecycle message must reach the daemon before the | 671 | // The lifecycle message must reach the daemon before the | ||
M internal/usecase/client/attachment_worker.go +47 -0
| @@ -997,6 +997,53 @@ func (f *attachmentForeground) writeTerminalQuery(data []byte) error { | |||
| 997 | 997 | return err | |
| 998 | 998 | } | |
| 999 | 999 | ||
| 1000 | + | // attachmentPredictionForeground is the optional predictive-echo seam of the | |
| 1001 | + | // real foreground. Scripted foregrounds may omit it; the worker then never | |
| 1002 | + | // predicts. | |
| 1003 | + | type attachmentPredictionForeground interface { | |
| 1004 | + | writePrediction(data []byte) (bool, error) | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | var _ attachmentPredictionForeground = (*attachmentForeground)(nil) | |
| 1008 | + | ||
| 1009 | + | // writePrediction writes and flushes predicted echo under the output lease. | |
| 1010 | + | // It reports false without writing while an overlay owns the terminal. The | |
| 1011 | + | // guesses are client-local drawing, so the UI observation channel is not | |
| 1012 | + | // involved. | |
| 1013 | + | func (f *attachmentForeground) writePrediction(data []byte) (bool, error) { | |
| 1014 | + | if f == nil { | |
| 1015 | + | return false, errAttachmentForegroundRevoked | |
| 1016 | + | } | |
| 1017 | + | var err error | |
| 1018 | + | written := false | |
| 1019 | + | ok := f.lease.send(func() bool { | |
| 1020 | + | if !f.authority.actionAuthorized(f) { | |
| 1021 | + | return false | |
| 1022 | + | } | |
| 1023 | + | if f.overlayKind() != attachmentOverlayNone { | |
| 1024 | + | return true | |
| 1025 | + | } | |
| 1026 | + | if supervisorNil(f.term) || supervisorNil(f.term.Out()) { | |
| 1027 | + | err = ports.ErrUIUnavailable | |
| 1028 | + | return true | |
| 1029 | + | } | |
| 1030 | + | var n int | |
| 1031 | + | n, err = f.term.Out().Write(data) | |
| 1032 | + | if err == nil && n != len(data) { | |
| 1033 | + | err = io.ErrShortWrite | |
| 1034 | + | } | |
| 1035 | + | if err == nil { | |
| 1036 | + | err = f.term.Flush() | |
| 1037 | + | } | |
| 1038 | + | written = err == nil | |
| 1039 | + | return true | |
| 1040 | + | }) | |
| 1041 | + | if !ok { | |
| 1042 | + | return false, errAttachmentForegroundRevoked | |
| 1043 | + | } | |
| 1044 | + | return written, err | |
| 1045 | + | } | |
| 1046 | + | ||
| 1000 | 1047 | // attachmentRun is one in-flight worker generation owned by the supervisor. | |
| 1001 | 1048 | type attachmentRun struct { | |
| 1002 | 1049 | host *attachmentHost | |
| @@ -997,6 +997,53 @@ func (f *attachmentForeground) writeTerminalQuery(data []byte) error { | |||||
| 997 | return err | 997 | return err | ||
| 998 | } | 998 | } | ||
| 999 | 999 | ||||
| 1000 | + | // attachmentPredictionForeground is the optional predictive-echo seam of the | |||
| 1001 | + | // real foreground. Scripted foregrounds may omit it; the worker then never | |||
| 1002 | + | // predicts. | |||
| 1003 | + | type attachmentPredictionForeground interface { | |||
| 1004 | + | writePrediction(data []byte) (bool, error) | |||
| 1005 | + | } | |||
| 1006 | + | ||||
| 1007 | + | var _ attachmentPredictionForeground = (*attachmentForeground)(nil) | |||
| 1008 | + | ||||
| 1009 | + | // writePrediction writes and flushes predicted echo under the output lease. | |||
| 1010 | + | // It reports false without writing while an overlay owns the terminal. The | |||
| 1011 | + | // guesses are client-local drawing, so the UI observation channel is not | |||
| 1012 | + | // involved. | |||
| 1013 | + | func (f *attachmentForeground) writePrediction(data []byte) (bool, error) { | |||
| 1014 | + | if f == nil { | |||
| 1015 | + | return false, errAttachmentForegroundRevoked | |||
| 1016 | + | } | |||
| 1017 | + | var err error | |||
| 1018 | + | written := false | |||
| 1019 | + | ok := f.lease.send(func() bool { | |||
| 1020 | + | if !f.authority.actionAuthorized(f) { | |||
| 1021 | + | return false | |||
| 1022 | + | } | |||
| 1023 | + | if f.overlayKind() != attachmentOverlayNone { | |||
| 1024 | + | return true | |||
| 1025 | + | } | |||
| 1026 | + | if supervisorNil(f.term) || supervisorNil(f.term.Out()) { | |||
| 1027 | + | err = ports.ErrUIUnavailable | |||
| 1028 | + | return true | |||
| 1029 | + | } | |||
| 1030 | + | var n int | |||
| 1031 | + | n, err = f.term.Out().Write(data) | |||
| 1032 | + | if err == nil && n != len(data) { | |||
| 1033 | + | err = io.ErrShortWrite | |||
| 1034 | + | } | |||
| 1035 | + | if err == nil { | |||
| 1036 | + | err = f.term.Flush() | |||
| 1037 | + | } | |||
| 1038 | + | written = err == nil | |||
| 1039 | + | return true | |||
| 1040 | + | }) | |||
| 1041 | + | if !ok { | |||
| 1042 | + | return false, errAttachmentForegroundRevoked | |||
| 1043 | + | } | |||
| 1044 | + | return written, err | |||
| 1045 | + | } | |||
| 1046 | + | ||||
| 1000 | // attachmentRun is one in-flight worker generation owned by the supervisor. | 1047 | // attachmentRun is one in-flight worker generation owned by the supervisor. | ||
| 1001 | type attachmentRun struct { | 1048 | type attachmentRun struct { | ||
| 1002 | host *attachmentHost | 1049 | host *attachmentHost | ||
A internal/usecase/client/prediction_foreground_mock_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package client | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func newMockattachmentPredictionForeground(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *mockattachmentPredictionForeground { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | mock := &mockattachmentPredictionForeground{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { | |
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 26 | + | helper.Helper() | |
| 27 | + | } | |
| 28 | + | mock.AssertExpectations(t) | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | return mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type | |
| 35 | + | type mockattachmentPredictionForeground struct { | |
| 36 | + | mock.Mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type mockattachmentPredictionForeground_Expecter struct { | |
| 40 | + | mock *mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter { | |
| 44 | + | return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // writePrediction provides a mock function for the type mockattachmentPredictionForeground | |
| 48 | + | func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) { | |
| 49 | + | ret := _mock.Called(data) | |
| 50 | + | ||
| 51 | + | if len(ret) == 0 { | |
| 52 | + | panic("no return value specified for writePrediction") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | var r0 bool | |
| 56 | + | var r1 error | |
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok { | |
| 58 | + | return returnFunc(data) | |
| 59 | + | } | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok { | |
| 61 | + | r0 = returnFunc(data) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(bool) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |
| 66 | + | r1 = returnFunc(data) | |
| 67 | + | } else { | |
| 68 | + | r1 = ret.Error(1) | |
| 69 | + | } | |
| 70 | + | return r0, r1 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction' | |
| 74 | + | type mockattachmentPredictionForeground_writePrediction_Call struct { | |
| 75 | + | *mock.Call | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // writePrediction is a helper method to define mock.On call | |
| 79 | + | // - data []byte | |
| 80 | + | func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 81 | + | return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 86 | + | var arg0 []byte | |
| 87 | + | if args[0] != nil { | |
| 88 | + | arg0 = args[0].([]byte) | |
| 89 | + | } | |
| 90 | + | run( | |
| 91 | + | arg0, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 98 | + | _c.Call.Return(b, err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package client | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func newMockattachmentPredictionForeground(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *mockattachmentPredictionForeground { | |||
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 18 | + | helper.Helper() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | mock := &mockattachmentPredictionForeground{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { | |||
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 26 | + | helper.Helper() | |||
| 27 | + | } | |||
| 28 | + | mock.AssertExpectations(t) | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | return mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type | |||
| 35 | + | type mockattachmentPredictionForeground struct { | |||
| 36 | + | mock.Mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type mockattachmentPredictionForeground_Expecter struct { | |||
| 40 | + | mock *mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter { | |||
| 44 | + | return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // writePrediction provides a mock function for the type mockattachmentPredictionForeground | |||
| 48 | + | func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) { | |||
| 49 | + | ret := _mock.Called(data) | |||
| 50 | + | ||||
| 51 | + | if len(ret) == 0 { | |||
| 52 | + | panic("no return value specified for writePrediction") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | var r0 bool | |||
| 56 | + | var r1 error | |||
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok { | |||
| 58 | + | return returnFunc(data) | |||
| 59 | + | } | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok { | |||
| 61 | + | r0 = returnFunc(data) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Get(0).(bool) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |||
| 66 | + | r1 = returnFunc(data) | |||
| 67 | + | } else { | |||
| 68 | + | r1 = ret.Error(1) | |||
| 69 | + | } | |||
| 70 | + | return r0, r1 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction' | |||
| 74 | + | type mockattachmentPredictionForeground_writePrediction_Call struct { | |||
| 75 | + | *mock.Call | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // writePrediction is a helper method to define mock.On call | |||
| 79 | + | // - data []byte | |||
| 80 | + | func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 81 | + | return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 86 | + | var arg0 []byte | |||
| 87 | + | if args[0] != nil { | |||
| 88 | + | arg0 = args[0].([]byte) | |||
| 89 | + | } | |||
| 90 | + | run( | |||
| 91 | + | arg0, | |||
| 92 | + | ) | |||
| 93 | + | }) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 98 | + | _c.Call.Return(b, err) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 103 | + | _c.Call.Return(run) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
A internal/usecase/client/predictive_echo.go +807 -0
| @@ -0,0 +1,807 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "slices" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | vt "github.com/bnema/vev-vt" | |
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/protocol" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Predictive local echo, after mosh's PredictionEngine | |
| 17 | + | // (src/frontend/terminaloverlay.cc). | |
| 18 | + | // | |
| 19 | + | // On a slow link the client guesses the echo of printable keys and backspace | |
| 20 | + | // on the cursor row and draws it before the daemon confirms it. The guess is | |
| 21 | + | // checked against a local mirror of the daemon's composed output once the | |
| 22 | + | // daemon acknowledges the input in Output.Echo. Anything the engine cannot | |
| 23 | + | // model (control bytes, escape sequences, wide runes, the last column) makes | |
| 24 | + | // later guesses tentative: they stay hidden until a correct guess of the same | |
| 25 | + | // epoch confirms it. A wrong tentative guess kills its epoch; a wrong | |
| 26 | + | // confirmed guess resets the engine. | |
| 27 | + | // | |
| 28 | + | // The attached loop is the only caller, so the engine holds no lock. | |
| 29 | + | ||
| 30 | + | // Adaptive display thresholds from mosh. They apply to the send interval, | |
| 31 | + | // half the smoothed round trip clamped to [20ms, 250ms]. | |
| 32 | + | const ( | |
| 33 | + | echoSRTTTriggerLow = 20 * time.Millisecond | |
| 34 | + | echoSRTTTriggerHigh = 30 * time.Millisecond | |
| 35 | + | echoFlagTriggerLow = 50 * time.Millisecond | |
| 36 | + | echoFlagTriggerHigh = 80 * time.Millisecond | |
| 37 | + | ||
| 38 | + | echoSendIntervalMin = 20 * time.Millisecond | |
| 39 | + | echoSendIntervalMax = 250 * time.Millisecond | |
| 40 | + | ||
| 41 | + | // echoGlitchThreshold shows predictions when one is pending this long. | |
| 42 | + | echoGlitchThreshold = 250 * time.Millisecond | |
| 43 | + | // echoGlitchFlagThreshold also underlines them. | |
| 44 | + | echoGlitchFlagThreshold = 5000 * time.Millisecond | |
| 45 | + | // echoGlitchRepairCount quick confirmations, each at least | |
| 46 | + | // echoGlitchRepairMinInterval apart, cure a glitch. | |
| 47 | + | echoGlitchRepairCount = 10 | |
| 48 | + | echoGlitchRepairMinInterval = 150 * time.Millisecond | |
| 49 | + | ||
| 50 | + | // echoServerDelay is the daemon's echo acknowledgement delay | |
| 51 | + | // (usecase/daemon echoAckDelay). It is removed from each round-trip | |
| 52 | + | // sample so the thresholds compare network time, as mosh's SRTT does. | |
| 53 | + | echoServerDelay = 50 * time.Millisecond | |
| 54 | + | ||
| 55 | + | // maxEchoSent bounds the send-time history used for round-trip samples. | |
| 56 | + | maxEchoSent = 256 | |
| 57 | + | // maxEchoOriginals bounds the overwritten contents kept per cell. | |
| 58 | + | maxEchoOriginals = 8 | |
| 59 | + | ) | |
| 60 | + | ||
| 61 | + | type echoValidity uint8 | |
| 62 | + | ||
| 63 | + | const ( | |
| 64 | + | echoInactive echoValidity = iota | |
| 65 | + | echoPending | |
| 66 | + | echoCorrect | |
| 67 | + | echoCorrectNoCredit | |
| 68 | + | echoIncorrect | |
| 69 | + | ) | |
| 70 | + | ||
| 71 | + | // echoCell is one predicted cell of the prediction row. | |
| 72 | + | type echoCell struct { | |
| 73 | + | active bool | |
| 74 | + | unknown bool | |
| 75 | + | expires uint64 | |
| 76 | + | epoch uint64 | |
| 77 | + | predictedAt time.Time | |
| 78 | + | replacement vt.Cell | |
| 79 | + | originals []rune | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed } | |
| 83 | + | ||
| 84 | + | func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} } | |
| 85 | + | ||
| 86 | + | // resetWithOriginal keeps a known replacement as overwritten content, so a | |
| 87 | + | // later guess that merely restores it earns no credit. | |
| 88 | + | func (c *echoCell) resetWithOriginal() { | |
| 89 | + | if !c.active || c.unknown { | |
| 90 | + | c.reset() | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | originals := append(c.originals, c.replacement.Rune) | |
| 94 | + | if len(originals) > maxEchoOriginals { | |
| 95 | + | originals = originals[len(originals)-maxEchoOriginals:] | |
| 96 | + | } | |
| 97 | + | *c = echoCell{originals: originals} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (c *echoCell) arm(seq, epoch uint64, now time.Time) { | |
| 101 | + | c.active = true | |
| 102 | + | c.epoch = epoch | |
| 103 | + | c.expires = seq | |
| 104 | + | c.predictedAt = now | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // echoCursor is the predicted cursor position. | |
| 108 | + | type echoCursor struct { | |
| 109 | + | active bool | |
| 110 | + | row int | |
| 111 | + | col int | |
| 112 | + | expires uint64 | |
| 113 | + | epoch uint64 | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | type echoSent struct { | |
| 117 | + | seq uint64 | |
| 118 | + | at time.Time | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // echoPredictor is the prediction engine of one attachment run. | |
| 122 | + | type echoPredictor struct { | |
| 123 | + | mode domain.EchoPredictMode | |
| 124 | + | screen *vt.Screen | |
| 125 | + | ||
| 126 | + | sent []echoSent | |
| 127 | + | echo uint64 | |
| 128 | + | srtt time.Duration | |
| 129 | + | srttKnown bool | |
| 130 | + | srttOn bool | |
| 131 | + | flagging bool | |
| 132 | + | glitch int | |
| 133 | + | lastQuick time.Time | |
| 134 | + | predEpoch uint64 | |
| 135 | + | confEpoch uint64 | |
| 136 | + | holdUntil uint64 | |
| 137 | + | row int | |
| 138 | + | cells []echoCell | |
| 139 | + | cursor echoCursor | |
| 140 | + | drawnRow int | |
| 141 | + | drawn []int | |
| 142 | + | cursorMove bool | |
| 143 | + | apc apcFilter | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor { | |
| 147 | + | return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // applyOutput feeds one applied Output to the mirror, samples the round trip | |
| 151 | + | // when Echo advances, and validates pending guesses. | |
| 152 | + | func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) { | |
| 153 | + | // Side effects (New == 0) may carry a placeholder size; only state | |
| 154 | + | // frames describe the grid. | |
| 155 | + | if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) { | |
| 156 | + | p.screen.Resize(output.Size.Cols, output.Size.Rows) | |
| 157 | + | // The resized frame repaints the whole terminal. | |
| 158 | + | p.forget() | |
| 159 | + | } | |
| 160 | + | if output.Full { | |
| 161 | + | // A full frame repainted every drawn guess. | |
| 162 | + | p.drawn = p.drawn[:0] | |
| 163 | + | p.cursorMove = false | |
| 164 | + | } | |
| 165 | + | if data := p.apc.strip(output.Data); len(data) > 0 { | |
| 166 | + | p.screen.Write(data) | |
| 167 | + | } | |
| 168 | + | if output.Echo > p.echo { | |
| 169 | + | p.sample(output.Echo, now) | |
| 170 | + | p.echo = output.Echo | |
| 171 | + | } | |
| 172 | + | p.cull(now) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // sample updates the smoothed round trip from the send time of seq. | |
| 176 | + | func (p *echoPredictor) sample(seq uint64, now time.Time) { | |
| 177 | + | keep := p.sent[:0] | |
| 178 | + | for _, sent := range p.sent { | |
| 179 | + | if sent.seq == seq { | |
| 180 | + | rtt := max(now.Sub(sent.at)-echoServerDelay, 0) | |
| 181 | + | if p.srttKnown { | |
| 182 | + | p.srtt = (7*p.srtt + rtt) / 8 | |
| 183 | + | } else { | |
| 184 | + | p.srtt, p.srttKnown = rtt, true | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | if sent.seq > seq { | |
| 188 | + | keep = append(keep, sent) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | p.sent = keep | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (p *echoPredictor) sendInterval() time.Duration { | |
| 195 | + | interval := (p.srtt + 1) / 2 | |
| 196 | + | return min(max(interval, echoSendIntervalMin), echoSendIntervalMax) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // enabled reports whether the mirror describes a screen the engine can | |
| 200 | + | // predict on: a visible cursor on a usable grid. The mirror holds the | |
| 201 | + | // daemon's composed frame, which never enters the alternate screen, so | |
| 202 | + | // full-screen pane programs rely on epoch validation, as in mosh. | |
| 203 | + | func (p *echoPredictor) enabled() bool { | |
| 204 | + | return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() && | |
| 205 | + | p.screen.Columns() > 1 && p.screen.Rows() > 0 | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (p *echoPredictor) active() bool { | |
| 209 | + | if p.cursor.active { | |
| 210 | + | return true | |
| 211 | + | } | |
| 212 | + | for i := range p.cells { | |
| 213 | + | if p.cells[i].active { | |
| 214 | + | return true | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | return false | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (p *echoPredictor) displaying() bool { | |
| 221 | + | switch p.mode { | |
| 222 | + | case domain.EchoPredictNever: | |
| 223 | + | return false | |
| 224 | + | case domain.EchoPredictAlways: | |
| 225 | + | return true | |
| 226 | + | } | |
| 227 | + | return p.srttOn || p.glitch > 0 | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (p *echoPredictor) becomeTentative() { p.predEpoch++ } | |
| 231 | + | ||
| 232 | + | // reset drops every guess. Drawn cells stay recorded so render repaints them. | |
| 233 | + | func (p *echoPredictor) reset() { | |
| 234 | + | for i := range p.cells { | |
| 235 | + | p.cells[i].reset() | |
| 236 | + | } | |
| 237 | + | p.cursor = echoCursor{} | |
| 238 | + | p.becomeTentative() | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // forget drops every guess and the drawn record, for when something else | |
| 242 | + | // repaints or owns the terminal (a full frame, an overlay). | |
| 243 | + | func (p *echoPredictor) forget() { | |
| 244 | + | p.reset() | |
| 245 | + | p.drawn = p.drawn[:0] | |
| 246 | + | p.cursorMove = false | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func (p *echoPredictor) killEpoch(epoch uint64) { | |
| 250 | + | for i := range p.cells { | |
| 251 | + | if p.cells[i].active && p.cells[i].epoch >= epoch { | |
| 252 | + | p.cells[i].reset() | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | if p.cursor.active && p.cursor.epoch >= epoch { | |
| 256 | + | p.cursor = echoCursor{} | |
| 257 | + | } | |
| 258 | + | p.becomeTentative() | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // cull validates guesses against the mirror and updates the display | |
| 262 | + | // triggers, as mosh's PredictionEngine::cull. | |
| 263 | + | func (p *echoPredictor) cull(now time.Time) { | |
| 264 | + | if p.mode == domain.EchoPredictNever { | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | if !p.enabled() { | |
| 268 | + | p.reset() | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | if p.srttKnown { | |
| 272 | + | interval := p.sendInterval() | |
| 273 | + | if interval > echoSRTTTriggerHigh { | |
| 274 | + | p.srttOn = true | |
| 275 | + | } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() { | |
| 276 | + | p.srttOn = false | |
| 277 | + | } | |
| 278 | + | if interval > echoFlagTriggerHigh { | |
| 279 | + | p.flagging = true | |
| 280 | + | } else if interval <= echoFlagTriggerLow { | |
| 281 | + | p.flagging = false | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | if p.glitch > echoGlitchRepairCount { | |
| 285 | + | p.flagging = true | |
| 286 | + | } | |
| 287 | + | if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() { | |
| 288 | + | p.cells = nil | |
| 289 | + | } | |
| 290 | + | for col := range p.cells { | |
| 291 | + | cell := &p.cells[col] | |
| 292 | + | switch p.cellValidity(col) { | |
| 293 | + | case echoIncorrect: | |
| 294 | + | if cell.tentative(p.confEpoch) { | |
| 295 | + | p.killEpoch(cell.epoch) | |
| 296 | + | continue | |
| 297 | + | } | |
| 298 | + | p.reset() | |
| 299 | + | return | |
| 300 | + | case echoCorrect: | |
| 301 | + | p.confEpoch = max(p.confEpoch, cell.epoch) | |
| 302 | + | if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval { | |
| 303 | + | p.glitch-- | |
| 304 | + | p.lastQuick = now | |
| 305 | + | } | |
| 306 | + | // Later guesses take the confirmed style. | |
| 307 | + | style := p.screen.Cell(col, p.row).Style | |
| 308 | + | for k := col; k < len(p.cells); k++ { | |
| 309 | + | p.cells[k].replacement.Style = style | |
| 310 | + | } | |
| 311 | + | cell.reset() | |
| 312 | + | case echoCorrectNoCredit: | |
| 313 | + | cell.reset() | |
| 314 | + | case echoPending: | |
| 315 | + | pending := now.Sub(cell.predictedAt) | |
| 316 | + | if pending >= echoGlitchFlagThreshold { | |
| 317 | + | p.glitch = 2 * echoGlitchRepairCount | |
| 318 | + | } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount { | |
| 319 | + | p.glitch = echoGlitchRepairCount | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | if p.cursor.active && p.echo >= p.cursor.expires { | |
| 324 | + | if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col { | |
| 325 | + | p.reset() | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | p.cursor = echoCursor{} | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (p *echoPredictor) cellValidity(col int) echoValidity { | |
| 333 | + | cell := &p.cells[col] | |
| 334 | + | if !cell.active { | |
| 335 | + | return echoInactive | |
| 336 | + | } | |
| 337 | + | if p.echo < cell.expires { | |
| 338 | + | return echoPending | |
| 339 | + | } | |
| 340 | + | if cell.unknown || isBlankRune(cell.replacement.Rune) { | |
| 341 | + | return echoCorrectNoCredit | |
| 342 | + | } | |
| 343 | + | current := p.screen.Cell(col, p.row) | |
| 344 | + | if current.Rune != cell.replacement.Rune { | |
| 345 | + | return echoIncorrect | |
| 346 | + | } | |
| 347 | + | for _, original := range cell.originals { | |
| 348 | + | if original == cell.replacement.Rune { | |
| 349 | + | return echoCorrectNoCredit | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | return echoCorrect | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func isBlankRune(r rune) bool { return r == 0 || r == ' ' } | |
| 356 | + | ||
| 357 | + | // isBorderRune reports a box-drawing rune: a pane border or bar the shell | |
| 358 | + | // line never shifts across. | |
| 359 | + | func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f } | |
| 360 | + | ||
| 361 | + | // isPlainCell reports a cell the engine can move and repaint exactly: one | |
| 362 | + | // column, no grapheme or hyperlink payload. | |
| 363 | + | func isPlainCell(cell vt.Cell) bool { | |
| 364 | + | return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // unknownKey gives up on the cursor row until the daemon answers this input. | |
| 368 | + | func (p *echoPredictor) unknownKey(seq uint64) { | |
| 369 | + | p.becomeTentative() | |
| 370 | + | p.cursor = echoCursor{} | |
| 371 | + | p.holdUntil = seq | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // input records the Input numbered seq and predicts its bytes. | |
| 375 | + | func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |
| 376 | + | if seq == 0 || p.mode == domain.EchoPredictNever { | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | if len(p.sent) == maxEchoSent { | |
| 380 | + | p.sent = append(p.sent[:0], p.sent[1:]...) | |
| 381 | + | } | |
| 382 | + | p.sent = append(p.sent, echoSent{seq: seq, at: now}) | |
| 383 | + | p.cull(now) | |
| 384 | + | // A daemon that never acknowledges echo (Echo stays 0) could never | |
| 385 | + | // confirm or refute a guess, so nothing is predicted before the first | |
| 386 | + | // acknowledgement. | |
| 387 | + | if !p.enabled() || !p.srttKnown { | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | // A bracketed paste arrives as one Input: it is not typing. | |
| 391 | + | if bytes.Contains(data, []byte("\x1b[200~")) { | |
| 392 | + | p.becomeTentative() | |
| 393 | + | return | |
| 394 | + | } | |
| 395 | + | for len(data) > 0 { | |
| 396 | + | if data[0] == 0x1b { | |
| 397 | + | data = data[escapeLength(data):] | |
| 398 | + | p.becomeTentative() | |
| 399 | + | continue | |
| 400 | + | } | |
| 401 | + | r, size := utf8.DecodeRune(data) | |
| 402 | + | data = data[size:] | |
| 403 | + | switch { | |
| 404 | + | case r == utf8.RuneError && size == 1: | |
| 405 | + | p.becomeTentative() | |
| 406 | + | case r == 0x7f: | |
| 407 | + | p.backspace(seq, now) | |
| 408 | + | case r == '\r': | |
| 409 | + | // The next row's state is only known once the daemon answers. | |
| 410 | + | p.unknownKey(seq) | |
| 411 | + | case vt.RuneWidth(r) != 1: | |
| 412 | + | p.becomeTentative() | |
| 413 | + | default: | |
| 414 | + | p.print(r, seq, now) | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // escapeLength is the length of the escape sequence at the start of data, or | |
| 420 | + | // all of data when it is incomplete. | |
| 421 | + | func escapeLength(data []byte) int { | |
| 422 | + | if len(data) < 2 { | |
| 423 | + | return len(data) | |
| 424 | + | } | |
| 425 | + | switch data[1] { | |
| 426 | + | case '[': | |
| 427 | + | for i := 2; i < len(data); i++ { | |
| 428 | + | if data[i] >= 0x40 && data[i] <= 0x7e { | |
| 429 | + | return i + 1 | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | return len(data) | |
| 433 | + | case 'O': | |
| 434 | + | return min(3, len(data)) | |
| 435 | + | case ']', 'P', '_', '^', 'X': | |
| 436 | + | // OSC, DCS, APC, PM, SOS: up to BEL or ST. | |
| 437 | + | for i := 2; i < len(data); i++ { | |
| 438 | + | if data[i] == 0x07 { | |
| 439 | + | return i + 1 | |
| 440 | + | } | |
| 441 | + | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| 442 | + | return i + 2 | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | return len(data) | |
| 446 | + | } | |
| 447 | + | return 2 | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // initCursor starts the cursor prediction from the mirror, and the cell row | |
| 451 | + | // with it. It reports false while the cursor row is not known yet. | |
| 452 | + | func (p *echoPredictor) initCursor() bool { | |
| 453 | + | if p.echo < p.holdUntil { | |
| 454 | + | return false | |
| 455 | + | } | |
| 456 | + | if !p.cursor.active { | |
| 457 | + | row, col := p.screen.CursorRow(), p.screen.CursorCol() | |
| 458 | + | if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() { | |
| 459 | + | return false | |
| 460 | + | } | |
| 461 | + | p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch} | |
| 462 | + | } | |
| 463 | + | if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() { | |
| 464 | + | p.row = p.cursor.row | |
| 465 | + | p.cells = make([]echoCell, p.screen.Columns()) | |
| 466 | + | } | |
| 467 | + | return true | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // effective is the cell the user will see at col: a known guess, else the | |
| 471 | + | // mirror. | |
| 472 | + | func (p *echoPredictor) effective(col int) (vt.Cell, bool) { | |
| 473 | + | cell := &p.cells[col] | |
| 474 | + | if cell.active { | |
| 475 | + | if cell.unknown { | |
| 476 | + | return vt.Cell{}, false | |
| 477 | + | } | |
| 478 | + | return cell.replacement, true | |
| 479 | + | } | |
| 480 | + | return p.screen.Cell(col, p.row), true | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func (p *echoPredictor) moveCursor(col int, seq uint64) { | |
| 484 | + | p.cursor.col = col | |
| 485 | + | p.cursor.expires = seq | |
| 486 | + | p.cursor.epoch = p.predEpoch | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // print predicts one width-1 rune in insert mode. The shift stops at the | |
| 490 | + | // first blank, so a split layout's borders to the right never move. | |
| 491 | + | func (p *echoPredictor) print(r rune, seq uint64, now time.Time) { | |
| 492 | + | if !p.initCursor() { | |
| 493 | + | p.becomeTentative() | |
| 494 | + | return | |
| 495 | + | } | |
| 496 | + | width := len(p.cells) | |
| 497 | + | col := p.cursor.col | |
| 498 | + | if col+1 >= width { | |
| 499 | + | // mosh: the last column is tricky (wrap markers, shells differ). | |
| 500 | + | p.becomeTentative() | |
| 501 | + | } | |
| 502 | + | end := col | |
| 503 | + | for end < width-1 { | |
| 504 | + | cell, known := p.effective(end) | |
| 505 | + | if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) { | |
| 506 | + | // Wide or payload text cannot be shifted exactly, and a border | |
| 507 | + | // would move with the line. | |
| 508 | + | p.unknownKey(seq) | |
| 509 | + | return | |
| 510 | + | } | |
| 511 | + | if known && isBlankRune(cell.Rune) { | |
| 512 | + | break | |
| 513 | + | } | |
| 514 | + | end++ | |
| 515 | + | } | |
| 516 | + | for i := end; i > col; i-- { | |
| 517 | + | cell := &p.cells[i] | |
| 518 | + | prev, known := p.effective(i - 1) | |
| 519 | + | original := p.screen.Cell(i, p.row).Rune | |
| 520 | + | cell.resetWithOriginal() | |
| 521 | + | cell.arm(seq, p.predEpoch, now) | |
| 522 | + | cell.originals = append(cell.originals, original) | |
| 523 | + | if i == width-1 || !known { | |
| 524 | + | cell.unknown = true | |
| 525 | + | } else { | |
| 526 | + | cell.replacement = prev | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | style := vt.DefaultStyle() | |
| 530 | + | if col > 0 { | |
| 531 | + | if left, known := p.effective(col - 1); known { | |
| 532 | + | style = left.Style | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | cell := &p.cells[col] | |
| 536 | + | original := p.screen.Cell(col, p.row).Rune | |
| 537 | + | cell.resetWithOriginal() | |
| 538 | + | cell.arm(seq, p.predEpoch, now) | |
| 539 | + | cell.replacement = vt.Cell{Rune: r, Style: style} | |
| 540 | + | cell.originals = append(cell.originals, original) | |
| 541 | + | if col < width-1 { | |
| 542 | + | p.moveCursor(col+1, seq) | |
| 543 | + | } else { | |
| 544 | + | p.becomeTentative() | |
| 545 | + | p.moveCursor(col, seq) | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // backspace predicts DEL: the cursor moves left and the text after it moves | |
| 550 | + | // left up to the first blank. | |
| 551 | + | func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |
| 552 | + | if !p.initCursor() { | |
| 553 | + | p.becomeTentative() | |
| 554 | + | return | |
| 555 | + | } | |
| 556 | + | if p.cursor.col == 0 { | |
| 557 | + | return | |
| 558 | + | } | |
| 559 | + | width := len(p.cells) | |
| 560 | + | col := p.cursor.col - 1 | |
| 561 | + | for i := col; i < width; i++ { | |
| 562 | + | cell, known := p.effective(i) | |
| 563 | + | if known && !isPlainCell(cell) { | |
| 564 | + | p.unknownKey(seq) | |
| 565 | + | return | |
| 566 | + | } | |
| 567 | + | if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) { | |
| 568 | + | break | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | p.moveCursor(col, seq) | |
| 572 | + | for i := col; i < width; i++ { | |
| 573 | + | cell := &p.cells[i] | |
| 574 | + | original := p.screen.Cell(i, p.row).Rune | |
| 575 | + | var next vt.Cell | |
| 576 | + | known := false | |
| 577 | + | if i+2 < width { | |
| 578 | + | next, known = p.effective(i + 1) | |
| 579 | + | } | |
| 580 | + | blank := known && isBlankRune(next.Rune) | |
| 581 | + | if known && isBorderRune(next.Rune) { | |
| 582 | + | // The text ends at a border: what fills this cell is unknown. | |
| 583 | + | known, blank = false, true | |
| 584 | + | } | |
| 585 | + | cell.resetWithOriginal() | |
| 586 | + | cell.arm(seq, p.predEpoch, now) | |
| 587 | + | cell.originals = append(cell.originals, original) | |
| 588 | + | if !known { | |
| 589 | + | cell.unknown = true | |
| 590 | + | } else { | |
| 591 | + | cell.replacement = next | |
| 592 | + | } | |
| 593 | + | if blank { | |
| 594 | + | break | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // needsTick reports whether a pending guess still waits on a timing trigger | |
| 600 | + | // (mosh's timing_tests_necessary), so the caller re-culls without output. | |
| 601 | + | func (p *echoPredictor) needsTick() bool { | |
| 602 | + | return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging) | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | // render returns the terminal bytes that bring the screen from the last | |
| 606 | + | // render to the current guesses: drawn cells that no longer show a guess are | |
| 607 | + | // repainted from the mirror, shown guesses are drawn again (a daemon frame | |
| 608 | + | // may have overwritten them), and the cursor goes to the predicted position | |
| 609 | + | // or back to the real one. | |
| 610 | + | func (p *echoPredictor) render() []byte { | |
| 611 | + | var out bytes.Buffer | |
| 612 | + | show := p.displaying() && p.enabled() | |
| 613 | + | var want []int | |
| 614 | + | if show && p.cells != nil && p.row < p.screen.Rows() { | |
| 615 | + | for col := range p.cells { | |
| 616 | + | cell := &p.cells[col] | |
| 617 | + | if !cell.active || cell.unknown || cell.tentative(p.confEpoch) { | |
| 618 | + | continue | |
| 619 | + | } | |
| 620 | + | if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging { | |
| 621 | + | continue | |
| 622 | + | } | |
| 623 | + | want = append(want, col) | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | for _, col := range p.drawn { | |
| 627 | + | if p.drawnRow == p.row && slices.Contains(want, col) { | |
| 628 | + | continue | |
| 629 | + | } | |
| 630 | + | if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() { | |
| 631 | + | p.restoreCell(&out, p.drawnRow, col) | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | for _, col := range want { | |
| 635 | + | cell := p.cells[col].replacement | |
| 636 | + | flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune)) | |
| 637 | + | writeEchoCell(&out, p.row, col, cell, flag) | |
| 638 | + | } | |
| 639 | + | p.drawn = append(p.drawn[:0], want...) | |
| 640 | + | p.drawnRow = p.row | |
| 641 | + | ||
| 642 | + | row, col := p.screen.CursorRow(), p.screen.CursorCol() | |
| 643 | + | moved := false | |
| 644 | + | if show && p.cursor.active && p.cursor.epoch <= p.confEpoch { | |
| 645 | + | moved = p.cursor.row != row || p.cursor.col != col | |
| 646 | + | row, col = p.cursor.row, p.cursor.col | |
| 647 | + | } | |
| 648 | + | if out.Len() > 0 || moved || p.cursorMove { | |
| 649 | + | out.WriteString("\x1b[0m") | |
| 650 | + | writeEchoCUP(&out, row, col) | |
| 651 | + | } | |
| 652 | + | p.cursorMove = moved | |
| 653 | + | return out.Bytes() | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func writeEchoCUP(out *bytes.Buffer, row, col int) { | |
| 657 | + | out.WriteString("\x1b[") | |
| 658 | + | out.WriteString(strconv.Itoa(row + 1)) | |
| 659 | + | out.WriteByte(';') | |
| 660 | + | out.WriteString(strconv.Itoa(col + 1)) | |
| 661 | + | out.WriteByte('H') | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | // restoreCell repaints the mirror's cell at (row, col). The right half of a | |
| 665 | + | // wide glyph is repainted from its left cell, which covers both columns. | |
| 666 | + | func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) { | |
| 667 | + | cell := p.screen.Cell(col, row) | |
| 668 | + | if cell.Continuation && col > 0 { | |
| 669 | + | col-- | |
| 670 | + | cell = p.screen.Cell(col, row) | |
| 671 | + | } | |
| 672 | + | writeEchoCell(out, row, col, cell, false) | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | // writeEchoCell draws one cell. Mirror styles come from daemon output already | |
| 676 | + | // projected to this terminal's color profile, so they are written as is. | |
| 677 | + | func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) { | |
| 678 | + | writeEchoCUP(out, row, col) | |
| 679 | + | out.WriteString(echoSGR(cell.Style, underline)) | |
| 680 | + | switch { | |
| 681 | + | case cell.Continuation || cell.Rune == 0: | |
| 682 | + | out.WriteByte(' ') | |
| 683 | + | case cell.Payload.Grapheme() != "": | |
| 684 | + | out.WriteString(cell.Payload.Grapheme()) | |
| 685 | + | default: | |
| 686 | + | out.WriteRune(cell.Rune) | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | func echoSGR(style vt.Style, underline bool) string { | |
| 691 | + | var b strings.Builder | |
| 692 | + | b.WriteString("\x1b[0") | |
| 693 | + | if style.Bold { | |
| 694 | + | b.WriteString(";1") | |
| 695 | + | } | |
| 696 | + | if style.Attrs&vt.AttrDim != 0 { | |
| 697 | + | b.WriteString(";2") | |
| 698 | + | } | |
| 699 | + | if style.Italic { | |
| 700 | + | b.WriteString(";3") | |
| 701 | + | } | |
| 702 | + | switch { | |
| 703 | + | case underline: | |
| 704 | + | b.WriteString(";4") | |
| 705 | + | case style.Attrs&vt.AttrUnderline != 0: | |
| 706 | + | switch style.UnderlineStyle { | |
| 707 | + | case vt.UnderlineDouble: | |
| 708 | + | b.WriteString(";21") | |
| 709 | + | case vt.UnderlineCurly: | |
| 710 | + | b.WriteString(";4:3") | |
| 711 | + | case vt.UnderlineDotted: | |
| 712 | + | b.WriteString(";4:4") | |
| 713 | + | case vt.UnderlineDashed: | |
| 714 | + | b.WriteString(";4:5") | |
| 715 | + | default: | |
| 716 | + | b.WriteString(";4") | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | if style.Attrs&vt.AttrBlink != 0 { | |
| 720 | + | b.WriteString(";5") | |
| 721 | + | } | |
| 722 | + | if style.Inverse { | |
| 723 | + | b.WriteString(";7") | |
| 724 | + | } | |
| 725 | + | if style.Attrs&vt.AttrStrikethrough != 0 { | |
| 726 | + | b.WriteString(";9") | |
| 727 | + | } | |
| 728 | + | writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90) | |
| 729 | + | writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100) | |
| 730 | + | switch { | |
| 731 | + | case style.HasUnderlineColorRGB: | |
| 732 | + | b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B))) | |
| 733 | + | case style.HasUnderlineColor: | |
| 734 | + | b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor)) | |
| 735 | + | } | |
| 736 | + | b.WriteByte('m') | |
| 737 | + | return b.String() | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) { | |
| 741 | + | switch { | |
| 742 | + | case hasRGB: | |
| 743 | + | b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B))) | |
| 744 | + | case index < 0: | |
| 745 | + | case index < 8: | |
| 746 | + | b.WriteString(";" + strconv.Itoa(normal+index)) | |
| 747 | + | case index < 16: | |
| 748 | + | b.WriteString(";" + strconv.Itoa(bright+index-8)) | |
| 749 | + | case index < 256: | |
| 750 | + | b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index)) | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | // apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from | |
| 755 | + | // the mirror's input: the engine needs text only, and decoding images would | |
| 756 | + | // cost the client CPU and memory. Its state spans Output boundaries. | |
| 757 | + | type apcFilter struct { | |
| 758 | + | state uint8 | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | const ( | |
| 762 | + | apcText uint8 = iota | |
| 763 | + | apcEscape | |
| 764 | + | apcBody | |
| 765 | + | apcBodyEscape | |
| 766 | + | ) | |
| 767 | + | ||
| 768 | + | func (f *apcFilter) strip(data []byte) []byte { | |
| 769 | + | if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 { | |
| 770 | + | return data | |
| 771 | + | } | |
| 772 | + | out := make([]byte, 0, len(data)) | |
| 773 | + | for _, b := range data { | |
| 774 | + | switch f.state { | |
| 775 | + | case apcText: | |
| 776 | + | if b == 0x1b { | |
| 777 | + | f.state = apcEscape | |
| 778 | + | continue | |
| 779 | + | } | |
| 780 | + | out = append(out, b) | |
| 781 | + | case apcEscape: | |
| 782 | + | if b == '_' { | |
| 783 | + | f.state = apcBody | |
| 784 | + | continue | |
| 785 | + | } | |
| 786 | + | out = append(out, 0x1b, b) | |
| 787 | + | f.state = apcText | |
| 788 | + | if b == 0x1b { | |
| 789 | + | out = out[:len(out)-1] | |
| 790 | + | f.state = apcEscape | |
| 791 | + | } | |
| 792 | + | case apcBody: | |
| 793 | + | if b == 0x1b { | |
| 794 | + | f.state = apcBodyEscape | |
| 795 | + | } | |
| 796 | + | case apcBodyEscape: | |
| 797 | + | switch b { | |
| 798 | + | case '\\': | |
| 799 | + | f.state = apcText | |
| 800 | + | case 0x1b: | |
| 801 | + | default: | |
| 802 | + | f.state = apcBody | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | } | |
| 806 | + | return out | |
| 807 | + | } | |
| @@ -0,0 +1,807 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "slices" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | vt "github.com/bnema/vev-vt" | |||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Predictive local echo, after mosh's PredictionEngine | |||
| 17 | + | // (src/frontend/terminaloverlay.cc). | |||
| 18 | + | // | |||
| 19 | + | // On a slow link the client guesses the echo of printable keys and backspace | |||
| 20 | + | // on the cursor row and draws it before the daemon confirms it. The guess is | |||
| 21 | + | // checked against a local mirror of the daemon's composed output once the | |||
| 22 | + | // daemon acknowledges the input in Output.Echo. Anything the engine cannot | |||
| 23 | + | // model (control bytes, escape sequences, wide runes, the last column) makes | |||
| 24 | + | // later guesses tentative: they stay hidden until a correct guess of the same | |||
| 25 | + | // epoch confirms it. A wrong tentative guess kills its epoch; a wrong | |||
| 26 | + | // confirmed guess resets the engine. | |||
| 27 | + | // | |||
| 28 | + | // The attached loop is the only caller, so the engine holds no lock. | |||
| 29 | + | ||||
| 30 | + | // Adaptive display thresholds from mosh. They apply to the send interval, | |||
| 31 | + | // half the smoothed round trip clamped to [20ms, 250ms]. | |||
| 32 | + | const ( | |||
| 33 | + | echoSRTTTriggerLow = 20 * time.Millisecond | |||
| 34 | + | echoSRTTTriggerHigh = 30 * time.Millisecond | |||
| 35 | + | echoFlagTriggerLow = 50 * time.Millisecond | |||
| 36 | + | echoFlagTriggerHigh = 80 * time.Millisecond | |||
| 37 | + | ||||
| 38 | + | echoSendIntervalMin = 20 * time.Millisecond | |||
| 39 | + | echoSendIntervalMax = 250 * time.Millisecond | |||
| 40 | + | ||||
| 41 | + | // echoGlitchThreshold shows predictions when one is pending this long. | |||
| 42 | + | echoGlitchThreshold = 250 * time.Millisecond | |||
| 43 | + | // echoGlitchFlagThreshold also underlines them. | |||
| 44 | + | echoGlitchFlagThreshold = 5000 * time.Millisecond | |||
| 45 | + | // echoGlitchRepairCount quick confirmations, each at least | |||
| 46 | + | // echoGlitchRepairMinInterval apart, cure a glitch. | |||
| 47 | + | echoGlitchRepairCount = 10 | |||
| 48 | + | echoGlitchRepairMinInterval = 150 * time.Millisecond | |||
| 49 | + | ||||
| 50 | + | // echoServerDelay is the daemon's echo acknowledgement delay | |||
| 51 | + | // (usecase/daemon echoAckDelay). It is removed from each round-trip | |||
| 52 | + | // sample so the thresholds compare network time, as mosh's SRTT does. | |||
| 53 | + | echoServerDelay = 50 * time.Millisecond | |||
| 54 | + | ||||
| 55 | + | // maxEchoSent bounds the send-time history used for round-trip samples. | |||
| 56 | + | maxEchoSent = 256 | |||
| 57 | + | // maxEchoOriginals bounds the overwritten contents kept per cell. | |||
| 58 | + | maxEchoOriginals = 8 | |||
| 59 | + | ) | |||
| 60 | + | ||||
| 61 | + | type echoValidity uint8 | |||
| 62 | + | ||||
| 63 | + | const ( | |||
| 64 | + | echoInactive echoValidity = iota | |||
| 65 | + | echoPending | |||
| 66 | + | echoCorrect | |||
| 67 | + | echoCorrectNoCredit | |||
| 68 | + | echoIncorrect | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 71 | + | // echoCell is one predicted cell of the prediction row. | |||
| 72 | + | type echoCell struct { | |||
| 73 | + | active bool | |||
| 74 | + | unknown bool | |||
| 75 | + | expires uint64 | |||
| 76 | + | epoch uint64 | |||
| 77 | + | predictedAt time.Time | |||
| 78 | + | replacement vt.Cell | |||
| 79 | + | originals []rune | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed } | |||
| 83 | + | ||||
| 84 | + | func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} } | |||
| 85 | + | ||||
| 86 | + | // resetWithOriginal keeps a known replacement as overwritten content, so a | |||
| 87 | + | // later guess that merely restores it earns no credit. | |||
| 88 | + | func (c *echoCell) resetWithOriginal() { | |||
| 89 | + | if !c.active || c.unknown { | |||
| 90 | + | c.reset() | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | originals := append(c.originals, c.replacement.Rune) | |||
| 94 | + | if len(originals) > maxEchoOriginals { | |||
| 95 | + | originals = originals[len(originals)-maxEchoOriginals:] | |||
| 96 | + | } | |||
| 97 | + | *c = echoCell{originals: originals} | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (c *echoCell) arm(seq, epoch uint64, now time.Time) { | |||
| 101 | + | c.active = true | |||
| 102 | + | c.epoch = epoch | |||
| 103 | + | c.expires = seq | |||
| 104 | + | c.predictedAt = now | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // echoCursor is the predicted cursor position. | |||
| 108 | + | type echoCursor struct { | |||
| 109 | + | active bool | |||
| 110 | + | row int | |||
| 111 | + | col int | |||
| 112 | + | expires uint64 | |||
| 113 | + | epoch uint64 | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | type echoSent struct { | |||
| 117 | + | seq uint64 | |||
| 118 | + | at time.Time | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // echoPredictor is the prediction engine of one attachment run. | |||
| 122 | + | type echoPredictor struct { | |||
| 123 | + | mode domain.EchoPredictMode | |||
| 124 | + | screen *vt.Screen | |||
| 125 | + | ||||
| 126 | + | sent []echoSent | |||
| 127 | + | echo uint64 | |||
| 128 | + | srtt time.Duration | |||
| 129 | + | srttKnown bool | |||
| 130 | + | srttOn bool | |||
| 131 | + | flagging bool | |||
| 132 | + | glitch int | |||
| 133 | + | lastQuick time.Time | |||
| 134 | + | predEpoch uint64 | |||
| 135 | + | confEpoch uint64 | |||
| 136 | + | holdUntil uint64 | |||
| 137 | + | row int | |||
| 138 | + | cells []echoCell | |||
| 139 | + | cursor echoCursor | |||
| 140 | + | drawnRow int | |||
| 141 | + | drawn []int | |||
| 142 | + | cursorMove bool | |||
| 143 | + | apc apcFilter | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor { | |||
| 147 | + | return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)} | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // applyOutput feeds one applied Output to the mirror, samples the round trip | |||
| 151 | + | // when Echo advances, and validates pending guesses. | |||
| 152 | + | func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) { | |||
| 153 | + | // Side effects (New == 0) may carry a placeholder size; only state | |||
| 154 | + | // frames describe the grid. | |||
| 155 | + | if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) { | |||
| 156 | + | p.screen.Resize(output.Size.Cols, output.Size.Rows) | |||
| 157 | + | // The resized frame repaints the whole terminal. | |||
| 158 | + | p.forget() | |||
| 159 | + | } | |||
| 160 | + | if output.Full { | |||
| 161 | + | // A full frame repainted every drawn guess. | |||
| 162 | + | p.drawn = p.drawn[:0] | |||
| 163 | + | p.cursorMove = false | |||
| 164 | + | } | |||
| 165 | + | if data := p.apc.strip(output.Data); len(data) > 0 { | |||
| 166 | + | p.screen.Write(data) | |||
| 167 | + | } | |||
| 168 | + | if output.Echo > p.echo { | |||
| 169 | + | p.sample(output.Echo, now) | |||
| 170 | + | p.echo = output.Echo | |||
| 171 | + | } | |||
| 172 | + | p.cull(now) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // sample updates the smoothed round trip from the send time of seq. | |||
| 176 | + | func (p *echoPredictor) sample(seq uint64, now time.Time) { | |||
| 177 | + | keep := p.sent[:0] | |||
| 178 | + | for _, sent := range p.sent { | |||
| 179 | + | if sent.seq == seq { | |||
| 180 | + | rtt := max(now.Sub(sent.at)-echoServerDelay, 0) | |||
| 181 | + | if p.srttKnown { | |||
| 182 | + | p.srtt = (7*p.srtt + rtt) / 8 | |||
| 183 | + | } else { | |||
| 184 | + | p.srtt, p.srttKnown = rtt, true | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | if sent.seq > seq { | |||
| 188 | + | keep = append(keep, sent) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | p.sent = keep | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (p *echoPredictor) sendInterval() time.Duration { | |||
| 195 | + | interval := (p.srtt + 1) / 2 | |||
| 196 | + | return min(max(interval, echoSendIntervalMin), echoSendIntervalMax) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // enabled reports whether the mirror describes a screen the engine can | |||
| 200 | + | // predict on: a visible cursor on a usable grid. The mirror holds the | |||
| 201 | + | // daemon's composed frame, which never enters the alternate screen, so | |||
| 202 | + | // full-screen pane programs rely on epoch validation, as in mosh. | |||
| 203 | + | func (p *echoPredictor) enabled() bool { | |||
| 204 | + | return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() && | |||
| 205 | + | p.screen.Columns() > 1 && p.screen.Rows() > 0 | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (p *echoPredictor) active() bool { | |||
| 209 | + | if p.cursor.active { | |||
| 210 | + | return true | |||
| 211 | + | } | |||
| 212 | + | for i := range p.cells { | |||
| 213 | + | if p.cells[i].active { | |||
| 214 | + | return true | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | return false | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (p *echoPredictor) displaying() bool { | |||
| 221 | + | switch p.mode { | |||
| 222 | + | case domain.EchoPredictNever: | |||
| 223 | + | return false | |||
| 224 | + | case domain.EchoPredictAlways: | |||
| 225 | + | return true | |||
| 226 | + | } | |||
| 227 | + | return p.srttOn || p.glitch > 0 | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (p *echoPredictor) becomeTentative() { p.predEpoch++ } | |||
| 231 | + | ||||
| 232 | + | // reset drops every guess. Drawn cells stay recorded so render repaints them. | |||
| 233 | + | func (p *echoPredictor) reset() { | |||
| 234 | + | for i := range p.cells { | |||
| 235 | + | p.cells[i].reset() | |||
| 236 | + | } | |||
| 237 | + | p.cursor = echoCursor{} | |||
| 238 | + | p.becomeTentative() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // forget drops every guess and the drawn record, for when something else | |||
| 242 | + | // repaints or owns the terminal (a full frame, an overlay). | |||
| 243 | + | func (p *echoPredictor) forget() { | |||
| 244 | + | p.reset() | |||
| 245 | + | p.drawn = p.drawn[:0] | |||
| 246 | + | p.cursorMove = false | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func (p *echoPredictor) killEpoch(epoch uint64) { | |||
| 250 | + | for i := range p.cells { | |||
| 251 | + | if p.cells[i].active && p.cells[i].epoch >= epoch { | |||
| 252 | + | p.cells[i].reset() | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | if p.cursor.active && p.cursor.epoch >= epoch { | |||
| 256 | + | p.cursor = echoCursor{} | |||
| 257 | + | } | |||
| 258 | + | p.becomeTentative() | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // cull validates guesses against the mirror and updates the display | |||
| 262 | + | // triggers, as mosh's PredictionEngine::cull. | |||
| 263 | + | func (p *echoPredictor) cull(now time.Time) { | |||
| 264 | + | if p.mode == domain.EchoPredictNever { | |||
| 265 | + | return | |||
| 266 | + | } | |||
| 267 | + | if !p.enabled() { | |||
| 268 | + | p.reset() | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | if p.srttKnown { | |||
| 272 | + | interval := p.sendInterval() | |||
| 273 | + | if interval > echoSRTTTriggerHigh { | |||
| 274 | + | p.srttOn = true | |||
| 275 | + | } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() { | |||
| 276 | + | p.srttOn = false | |||
| 277 | + | } | |||
| 278 | + | if interval > echoFlagTriggerHigh { | |||
| 279 | + | p.flagging = true | |||
| 280 | + | } else if interval <= echoFlagTriggerLow { | |||
| 281 | + | p.flagging = false | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | if p.glitch > echoGlitchRepairCount { | |||
| 285 | + | p.flagging = true | |||
| 286 | + | } | |||
| 287 | + | if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() { | |||
| 288 | + | p.cells = nil | |||
| 289 | + | } | |||
| 290 | + | for col := range p.cells { | |||
| 291 | + | cell := &p.cells[col] | |||
| 292 | + | switch p.cellValidity(col) { | |||
| 293 | + | case echoIncorrect: | |||
| 294 | + | if cell.tentative(p.confEpoch) { | |||
| 295 | + | p.killEpoch(cell.epoch) | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | p.reset() | |||
| 299 | + | return | |||
| 300 | + | case echoCorrect: | |||
| 301 | + | p.confEpoch = max(p.confEpoch, cell.epoch) | |||
| 302 | + | if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval { | |||
| 303 | + | p.glitch-- | |||
| 304 | + | p.lastQuick = now | |||
| 305 | + | } | |||
| 306 | + | // Later guesses take the confirmed style. | |||
| 307 | + | style := p.screen.Cell(col, p.row).Style | |||
| 308 | + | for k := col; k < len(p.cells); k++ { | |||
| 309 | + | p.cells[k].replacement.Style = style | |||
| 310 | + | } | |||
| 311 | + | cell.reset() | |||
| 312 | + | case echoCorrectNoCredit: | |||
| 313 | + | cell.reset() | |||
| 314 | + | case echoPending: | |||
| 315 | + | pending := now.Sub(cell.predictedAt) | |||
| 316 | + | if pending >= echoGlitchFlagThreshold { | |||
| 317 | + | p.glitch = 2 * echoGlitchRepairCount | |||
| 318 | + | } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount { | |||
| 319 | + | p.glitch = echoGlitchRepairCount | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | if p.cursor.active && p.echo >= p.cursor.expires { | |||
| 324 | + | if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col { | |||
| 325 | + | p.reset() | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 328 | + | p.cursor = echoCursor{} | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func (p *echoPredictor) cellValidity(col int) echoValidity { | |||
| 333 | + | cell := &p.cells[col] | |||
| 334 | + | if !cell.active { | |||
| 335 | + | return echoInactive | |||
| 336 | + | } | |||
| 337 | + | if p.echo < cell.expires { | |||
| 338 | + | return echoPending | |||
| 339 | + | } | |||
| 340 | + | if cell.unknown || isBlankRune(cell.replacement.Rune) { | |||
| 341 | + | return echoCorrectNoCredit | |||
| 342 | + | } | |||
| 343 | + | current := p.screen.Cell(col, p.row) | |||
| 344 | + | if current.Rune != cell.replacement.Rune { | |||
| 345 | + | return echoIncorrect | |||
| 346 | + | } | |||
| 347 | + | for _, original := range cell.originals { | |||
| 348 | + | if original == cell.replacement.Rune { | |||
| 349 | + | return echoCorrectNoCredit | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | return echoCorrect | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func isBlankRune(r rune) bool { return r == 0 || r == ' ' } | |||
| 356 | + | ||||
| 357 | + | // isBorderRune reports a box-drawing rune: a pane border or bar the shell | |||
| 358 | + | // line never shifts across. | |||
| 359 | + | func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f } | |||
| 360 | + | ||||
| 361 | + | // isPlainCell reports a cell the engine can move and repaint exactly: one | |||
| 362 | + | // column, no grapheme or hyperlink payload. | |||
| 363 | + | func isPlainCell(cell vt.Cell) bool { | |||
| 364 | + | return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // unknownKey gives up on the cursor row until the daemon answers this input. | |||
| 368 | + | func (p *echoPredictor) unknownKey(seq uint64) { | |||
| 369 | + | p.becomeTentative() | |||
| 370 | + | p.cursor = echoCursor{} | |||
| 371 | + | p.holdUntil = seq | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // input records the Input numbered seq and predicts its bytes. | |||
| 375 | + | func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |||
| 376 | + | if seq == 0 || p.mode == domain.EchoPredictNever { | |||
| 377 | + | return | |||
| 378 | + | } | |||
| 379 | + | if len(p.sent) == maxEchoSent { | |||
| 380 | + | p.sent = append(p.sent[:0], p.sent[1:]...) | |||
| 381 | + | } | |||
| 382 | + | p.sent = append(p.sent, echoSent{seq: seq, at: now}) | |||
| 383 | + | p.cull(now) | |||
| 384 | + | // A daemon that never acknowledges echo (Echo stays 0) could never | |||
| 385 | + | // confirm or refute a guess, so nothing is predicted before the first | |||
| 386 | + | // acknowledgement. | |||
| 387 | + | if !p.enabled() || !p.srttKnown { | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | // A bracketed paste arrives as one Input: it is not typing. | |||
| 391 | + | if bytes.Contains(data, []byte("\x1b[200~")) { | |||
| 392 | + | p.becomeTentative() | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | for len(data) > 0 { | |||
| 396 | + | if data[0] == 0x1b { | |||
| 397 | + | data = data[escapeLength(data):] | |||
| 398 | + | p.becomeTentative() | |||
| 399 | + | continue | |||
| 400 | + | } | |||
| 401 | + | r, size := utf8.DecodeRune(data) | |||
| 402 | + | data = data[size:] | |||
| 403 | + | switch { | |||
| 404 | + | case r == utf8.RuneError && size == 1: | |||
| 405 | + | p.becomeTentative() | |||
| 406 | + | case r == 0x7f: | |||
| 407 | + | p.backspace(seq, now) | |||
| 408 | + | case r == '\r': | |||
| 409 | + | // The next row's state is only known once the daemon answers. | |||
| 410 | + | p.unknownKey(seq) | |||
| 411 | + | case vt.RuneWidth(r) != 1: | |||
| 412 | + | p.becomeTentative() | |||
| 413 | + | default: | |||
| 414 | + | p.print(r, seq, now) | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | // escapeLength is the length of the escape sequence at the start of data, or | |||
| 420 | + | // all of data when it is incomplete. | |||
| 421 | + | func escapeLength(data []byte) int { | |||
| 422 | + | if len(data) < 2 { | |||
| 423 | + | return len(data) | |||
| 424 | + | } | |||
| 425 | + | switch data[1] { | |||
| 426 | + | case '[': | |||
| 427 | + | for i := 2; i < len(data); i++ { | |||
| 428 | + | if data[i] >= 0x40 && data[i] <= 0x7e { | |||
| 429 | + | return i + 1 | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | return len(data) | |||
| 433 | + | case 'O': | |||
| 434 | + | return min(3, len(data)) | |||
| 435 | + | case ']', 'P', '_', '^', 'X': | |||
| 436 | + | // OSC, DCS, APC, PM, SOS: up to BEL or ST. | |||
| 437 | + | for i := 2; i < len(data); i++ { | |||
| 438 | + | if data[i] == 0x07 { | |||
| 439 | + | return i + 1 | |||
| 440 | + | } | |||
| 441 | + | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |||
| 442 | + | return i + 2 | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | return len(data) | |||
| 446 | + | } | |||
| 447 | + | return 2 | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // initCursor starts the cursor prediction from the mirror, and the cell row | |||
| 451 | + | // with it. It reports false while the cursor row is not known yet. | |||
| 452 | + | func (p *echoPredictor) initCursor() bool { | |||
| 453 | + | if p.echo < p.holdUntil { | |||
| 454 | + | return false | |||
| 455 | + | } | |||
| 456 | + | if !p.cursor.active { | |||
| 457 | + | row, col := p.screen.CursorRow(), p.screen.CursorCol() | |||
| 458 | + | if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() { | |||
| 459 | + | return false | |||
| 460 | + | } | |||
| 461 | + | p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch} | |||
| 462 | + | } | |||
| 463 | + | if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() { | |||
| 464 | + | p.row = p.cursor.row | |||
| 465 | + | p.cells = make([]echoCell, p.screen.Columns()) | |||
| 466 | + | } | |||
| 467 | + | return true | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // effective is the cell the user will see at col: a known guess, else the | |||
| 471 | + | // mirror. | |||
| 472 | + | func (p *echoPredictor) effective(col int) (vt.Cell, bool) { | |||
| 473 | + | cell := &p.cells[col] | |||
| 474 | + | if cell.active { | |||
| 475 | + | if cell.unknown { | |||
| 476 | + | return vt.Cell{}, false | |||
| 477 | + | } | |||
| 478 | + | return cell.replacement, true | |||
| 479 | + | } | |||
| 480 | + | return p.screen.Cell(col, p.row), true | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func (p *echoPredictor) moveCursor(col int, seq uint64) { | |||
| 484 | + | p.cursor.col = col | |||
| 485 | + | p.cursor.expires = seq | |||
| 486 | + | p.cursor.epoch = p.predEpoch | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // print predicts one width-1 rune in insert mode. The shift stops at the | |||
| 490 | + | // first blank, so a split layout's borders to the right never move. | |||
| 491 | + | func (p *echoPredictor) print(r rune, seq uint64, now time.Time) { | |||
| 492 | + | if !p.initCursor() { | |||
| 493 | + | p.becomeTentative() | |||
| 494 | + | return | |||
| 495 | + | } | |||
| 496 | + | width := len(p.cells) | |||
| 497 | + | col := p.cursor.col | |||
| 498 | + | if col+1 >= width { | |||
| 499 | + | // mosh: the last column is tricky (wrap markers, shells differ). | |||
| 500 | + | p.becomeTentative() | |||
| 501 | + | } | |||
| 502 | + | end := col | |||
| 503 | + | for end < width-1 { | |||
| 504 | + | cell, known := p.effective(end) | |||
| 505 | + | if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) { | |||
| 506 | + | // Wide or payload text cannot be shifted exactly, and a border | |||
| 507 | + | // would move with the line. | |||
| 508 | + | p.unknownKey(seq) | |||
| 509 | + | return | |||
| 510 | + | } | |||
| 511 | + | if known && isBlankRune(cell.Rune) { | |||
| 512 | + | break | |||
| 513 | + | } | |||
| 514 | + | end++ | |||
| 515 | + | } | |||
| 516 | + | for i := end; i > col; i-- { | |||
| 517 | + | cell := &p.cells[i] | |||
| 518 | + | prev, known := p.effective(i - 1) | |||
| 519 | + | original := p.screen.Cell(i, p.row).Rune | |||
| 520 | + | cell.resetWithOriginal() | |||
| 521 | + | cell.arm(seq, p.predEpoch, now) | |||
| 522 | + | cell.originals = append(cell.originals, original) | |||
| 523 | + | if i == width-1 || !known { | |||
| 524 | + | cell.unknown = true | |||
| 525 | + | } else { | |||
| 526 | + | cell.replacement = prev | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | style := vt.DefaultStyle() | |||
| 530 | + | if col > 0 { | |||
| 531 | + | if left, known := p.effective(col - 1); known { | |||
| 532 | + | style = left.Style | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 535 | + | cell := &p.cells[col] | |||
| 536 | + | original := p.screen.Cell(col, p.row).Rune | |||
| 537 | + | cell.resetWithOriginal() | |||
| 538 | + | cell.arm(seq, p.predEpoch, now) | |||
| 539 | + | cell.replacement = vt.Cell{Rune: r, Style: style} | |||
| 540 | + | cell.originals = append(cell.originals, original) | |||
| 541 | + | if col < width-1 { | |||
| 542 | + | p.moveCursor(col+1, seq) | |||
| 543 | + | } else { | |||
| 544 | + | p.becomeTentative() | |||
| 545 | + | p.moveCursor(col, seq) | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // backspace predicts DEL: the cursor moves left and the text after it moves | |||
| 550 | + | // left up to the first blank. | |||
| 551 | + | func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |||
| 552 | + | if !p.initCursor() { | |||
| 553 | + | p.becomeTentative() | |||
| 554 | + | return | |||
| 555 | + | } | |||
| 556 | + | if p.cursor.col == 0 { | |||
| 557 | + | return | |||
| 558 | + | } | |||
| 559 | + | width := len(p.cells) | |||
| 560 | + | col := p.cursor.col - 1 | |||
| 561 | + | for i := col; i < width; i++ { | |||
| 562 | + | cell, known := p.effective(i) | |||
| 563 | + | if known && !isPlainCell(cell) { | |||
| 564 | + | p.unknownKey(seq) | |||
| 565 | + | return | |||
| 566 | + | } | |||
| 567 | + | if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) { | |||
| 568 | + | break | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | p.moveCursor(col, seq) | |||
| 572 | + | for i := col; i < width; i++ { | |||
| 573 | + | cell := &p.cells[i] | |||
| 574 | + | original := p.screen.Cell(i, p.row).Rune | |||
| 575 | + | var next vt.Cell | |||
| 576 | + | known := false | |||
| 577 | + | if i+2 < width { | |||
| 578 | + | next, known = p.effective(i + 1) | |||
| 579 | + | } | |||
| 580 | + | blank := known && isBlankRune(next.Rune) | |||
| 581 | + | if known && isBorderRune(next.Rune) { | |||
| 582 | + | // The text ends at a border: what fills this cell is unknown. | |||
| 583 | + | known, blank = false, true | |||
| 584 | + | } | |||
| 585 | + | cell.resetWithOriginal() | |||
| 586 | + | cell.arm(seq, p.predEpoch, now) | |||
| 587 | + | cell.originals = append(cell.originals, original) | |||
| 588 | + | if !known { | |||
| 589 | + | cell.unknown = true | |||
| 590 | + | } else { | |||
| 591 | + | cell.replacement = next | |||
| 592 | + | } | |||
| 593 | + | if blank { | |||
| 594 | + | break | |||
| 595 | + | } | |||
| 596 | + | } | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // needsTick reports whether a pending guess still waits on a timing trigger | |||
| 600 | + | // (mosh's timing_tests_necessary), so the caller re-culls without output. | |||
| 601 | + | func (p *echoPredictor) needsTick() bool { | |||
| 602 | + | return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging) | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | // render returns the terminal bytes that bring the screen from the last | |||
| 606 | + | // render to the current guesses: drawn cells that no longer show a guess are | |||
| 607 | + | // repainted from the mirror, shown guesses are drawn again (a daemon frame | |||
| 608 | + | // may have overwritten them), and the cursor goes to the predicted position | |||
| 609 | + | // or back to the real one. | |||
| 610 | + | func (p *echoPredictor) render() []byte { | |||
| 611 | + | var out bytes.Buffer | |||
| 612 | + | show := p.displaying() && p.enabled() | |||
| 613 | + | var want []int | |||
| 614 | + | if show && p.cells != nil && p.row < p.screen.Rows() { | |||
| 615 | + | for col := range p.cells { | |||
| 616 | + | cell := &p.cells[col] | |||
| 617 | + | if !cell.active || cell.unknown || cell.tentative(p.confEpoch) { | |||
| 618 | + | continue | |||
| 619 | + | } | |||
| 620 | + | if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging { | |||
| 621 | + | continue | |||
| 622 | + | } | |||
| 623 | + | want = append(want, col) | |||
| 624 | + | } | |||
| 625 | + | } | |||
| 626 | + | for _, col := range p.drawn { | |||
| 627 | + | if p.drawnRow == p.row && slices.Contains(want, col) { | |||
| 628 | + | continue | |||
| 629 | + | } | |||
| 630 | + | if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() { | |||
| 631 | + | p.restoreCell(&out, p.drawnRow, col) | |||
| 632 | + | } | |||
| 633 | + | } | |||
| 634 | + | for _, col := range want { | |||
| 635 | + | cell := p.cells[col].replacement | |||
| 636 | + | flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune)) | |||
| 637 | + | writeEchoCell(&out, p.row, col, cell, flag) | |||
| 638 | + | } | |||
| 639 | + | p.drawn = append(p.drawn[:0], want...) | |||
| 640 | + | p.drawnRow = p.row | |||
| 641 | + | ||||
| 642 | + | row, col := p.screen.CursorRow(), p.screen.CursorCol() | |||
| 643 | + | moved := false | |||
| 644 | + | if show && p.cursor.active && p.cursor.epoch <= p.confEpoch { | |||
| 645 | + | moved = p.cursor.row != row || p.cursor.col != col | |||
| 646 | + | row, col = p.cursor.row, p.cursor.col | |||
| 647 | + | } | |||
| 648 | + | if out.Len() > 0 || moved || p.cursorMove { | |||
| 649 | + | out.WriteString("\x1b[0m") | |||
| 650 | + | writeEchoCUP(&out, row, col) | |||
| 651 | + | } | |||
| 652 | + | p.cursorMove = moved | |||
| 653 | + | return out.Bytes() | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | func writeEchoCUP(out *bytes.Buffer, row, col int) { | |||
| 657 | + | out.WriteString("\x1b[") | |||
| 658 | + | out.WriteString(strconv.Itoa(row + 1)) | |||
| 659 | + | out.WriteByte(';') | |||
| 660 | + | out.WriteString(strconv.Itoa(col + 1)) | |||
| 661 | + | out.WriteByte('H') | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | // restoreCell repaints the mirror's cell at (row, col). The right half of a | |||
| 665 | + | // wide glyph is repainted from its left cell, which covers both columns. | |||
| 666 | + | func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) { | |||
| 667 | + | cell := p.screen.Cell(col, row) | |||
| 668 | + | if cell.Continuation && col > 0 { | |||
| 669 | + | col-- | |||
| 670 | + | cell = p.screen.Cell(col, row) | |||
| 671 | + | } | |||
| 672 | + | writeEchoCell(out, row, col, cell, false) | |||
| 673 | + | } | |||
| 674 | + | ||||
| 675 | + | // writeEchoCell draws one cell. Mirror styles come from daemon output already | |||
| 676 | + | // projected to this terminal's color profile, so they are written as is. | |||
| 677 | + | func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) { | |||
| 678 | + | writeEchoCUP(out, row, col) | |||
| 679 | + | out.WriteString(echoSGR(cell.Style, underline)) | |||
| 680 | + | switch { | |||
| 681 | + | case cell.Continuation || cell.Rune == 0: | |||
| 682 | + | out.WriteByte(' ') | |||
| 683 | + | case cell.Payload.Grapheme() != "": | |||
| 684 | + | out.WriteString(cell.Payload.Grapheme()) | |||
| 685 | + | default: | |||
| 686 | + | out.WriteRune(cell.Rune) | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | func echoSGR(style vt.Style, underline bool) string { | |||
| 691 | + | var b strings.Builder | |||
| 692 | + | b.WriteString("\x1b[0") | |||
| 693 | + | if style.Bold { | |||
| 694 | + | b.WriteString(";1") | |||
| 695 | + | } | |||
| 696 | + | if style.Attrs&vt.AttrDim != 0 { | |||
| 697 | + | b.WriteString(";2") | |||
| 698 | + | } | |||
| 699 | + | if style.Italic { | |||
| 700 | + | b.WriteString(";3") | |||
| 701 | + | } | |||
| 702 | + | switch { | |||
| 703 | + | case underline: | |||
| 704 | + | b.WriteString(";4") | |||
| 705 | + | case style.Attrs&vt.AttrUnderline != 0: | |||
| 706 | + | switch style.UnderlineStyle { | |||
| 707 | + | case vt.UnderlineDouble: | |||
| 708 | + | b.WriteString(";21") | |||
| 709 | + | case vt.UnderlineCurly: | |||
| 710 | + | b.WriteString(";4:3") | |||
| 711 | + | case vt.UnderlineDotted: | |||
| 712 | + | b.WriteString(";4:4") | |||
| 713 | + | case vt.UnderlineDashed: | |||
| 714 | + | b.WriteString(";4:5") | |||
| 715 | + | default: | |||
| 716 | + | b.WriteString(";4") | |||
| 717 | + | } | |||
| 718 | + | } | |||
| 719 | + | if style.Attrs&vt.AttrBlink != 0 { | |||
| 720 | + | b.WriteString(";5") | |||
| 721 | + | } | |||
| 722 | + | if style.Inverse { | |||
| 723 | + | b.WriteString(";7") | |||
| 724 | + | } | |||
| 725 | + | if style.Attrs&vt.AttrStrikethrough != 0 { | |||
| 726 | + | b.WriteString(";9") | |||
| 727 | + | } | |||
| 728 | + | writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90) | |||
| 729 | + | writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100) | |||
| 730 | + | switch { | |||
| 731 | + | case style.HasUnderlineColorRGB: | |||
| 732 | + | b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B))) | |||
| 733 | + | case style.HasUnderlineColor: | |||
| 734 | + | b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor)) | |||
| 735 | + | } | |||
| 736 | + | b.WriteByte('m') | |||
| 737 | + | return b.String() | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) { | |||
| 741 | + | switch { | |||
| 742 | + | case hasRGB: | |||
| 743 | + | b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B))) | |||
| 744 | + | case index < 0: | |||
| 745 | + | case index < 8: | |||
| 746 | + | b.WriteString(";" + strconv.Itoa(normal+index)) | |||
| 747 | + | case index < 16: | |||
| 748 | + | b.WriteString(";" + strconv.Itoa(bright+index-8)) | |||
| 749 | + | case index < 256: | |||
| 750 | + | b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index)) | |||
| 751 | + | } | |||
| 752 | + | } | |||
| 753 | + | ||||
| 754 | + | // apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from | |||
| 755 | + | // the mirror's input: the engine needs text only, and decoding images would | |||
| 756 | + | // cost the client CPU and memory. Its state spans Output boundaries. | |||
| 757 | + | type apcFilter struct { | |||
| 758 | + | state uint8 | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | const ( | |||
| 762 | + | apcText uint8 = iota | |||
| 763 | + | apcEscape | |||
| 764 | + | apcBody | |||
| 765 | + | apcBodyEscape | |||
| 766 | + | ) | |||
| 767 | + | ||||
| 768 | + | func (f *apcFilter) strip(data []byte) []byte { | |||
| 769 | + | if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 { | |||
| 770 | + | return data | |||
| 771 | + | } | |||
| 772 | + | out := make([]byte, 0, len(data)) | |||
| 773 | + | for _, b := range data { | |||
| 774 | + | switch f.state { | |||
| 775 | + | case apcText: | |||
| 776 | + | if b == 0x1b { | |||
| 777 | + | f.state = apcEscape | |||
| 778 | + | continue | |||
| 779 | + | } | |||
| 780 | + | out = append(out, b) | |||
| 781 | + | case apcEscape: | |||
| 782 | + | if b == '_' { | |||
| 783 | + | f.state = apcBody | |||
| 784 | + | continue | |||
| 785 | + | } | |||
| 786 | + | out = append(out, 0x1b, b) | |||
| 787 | + | f.state = apcText | |||
| 788 | + | if b == 0x1b { | |||
| 789 | + | out = out[:len(out)-1] | |||
| 790 | + | f.state = apcEscape | |||
| 791 | + | } | |||
| 792 | + | case apcBody: | |||
| 793 | + | if b == 0x1b { | |||
| 794 | + | f.state = apcBodyEscape | |||
| 795 | + | } | |||
| 796 | + | case apcBodyEscape: | |||
| 797 | + | switch b { | |||
| 798 | + | case '\\': | |||
| 799 | + | f.state = apcText | |||
| 800 | + | case 0x1b: | |||
| 801 | + | default: | |||
| 802 | + | f.state = apcBody | |||
| 803 | + | } | |||
| 804 | + | } | |||
| 805 | + | } | |||
| 806 | + | return out | |||
| 807 | + | } | |||
A internal/usecase/client/predictive_echo_test.go +413 -0
| @@ -0,0 +1,413 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 14 | + | ||
| 15 | + | // echoHarness drives one predictor against a 20x3 mirror whose prompt "$ " | |
| 16 | + | // leaves the cursor at row 0, column 2. | |
| 17 | + | type echoHarness struct { | |
| 18 | + | t *testing.T | |
| 19 | + | p *echoPredictor | |
| 20 | + | now time.Time | |
| 21 | + | seq uint64 | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness { | |
| 25 | + | t.Helper() | |
| 26 | + | h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart} | |
| 27 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now) | |
| 28 | + | return h | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // warm sends one empty input and acknowledges it after rtt of network time. | |
| 32 | + | func (h *echoHarness) warm(rtt time.Duration) { | |
| 33 | + | h.seq++ | |
| 34 | + | h.p.input(h.seq, nil, h.now) | |
| 35 | + | h.now = h.now.Add(rtt + echoServerDelay) | |
| 36 | + | h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (h *echoHarness) typed(data string) []byte { | |
| 40 | + | h.seq++ | |
| 41 | + | h.p.input(h.seq, []byte(data), h.now) | |
| 42 | + | return h.p.render() | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // daemon applies a state frame that writes data and acknowledges echo. | |
| 46 | + | func (h *echoHarness) daemon(data string, echo uint64) []byte { | |
| 47 | + | h.now = h.now.Add(time.Millisecond) | |
| 48 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now) | |
| 49 | + | return h.p.render() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // confirm types a and lets the daemon echo it, which confirms the first epoch. | |
| 53 | + | func (h *echoHarness) confirm() { | |
| 54 | + | h.t.Helper() | |
| 55 | + | if got := h.typed("a"); len(got) != 0 { | |
| 56 | + | h.t.Fatalf("first guess is tentative, rendered %q", got) | |
| 57 | + | } | |
| 58 | + | h.daemon("\x1b[1;3Ha", h.seq) | |
| 59 | + | if h.p.confEpoch == 0 { | |
| 60 | + | h.t.Fatal("echoed guess did not confirm its epoch") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestEchoPredictorDisplay(t *testing.T) { | |
| 65 | + | t.Parallel() | |
| 66 | + | tests := []struct { | |
| 67 | + | name string | |
| 68 | + | mode domain.EchoPredictMode | |
| 69 | + | rtt time.Duration | |
| 70 | + | want string | |
| 71 | + | }{ | |
| 72 | + | {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""}, | |
| 73 | + | {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"}, | |
| 74 | + | {name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"}, | |
| 75 | + | {name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"}, | |
| 76 | + | {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""}, | |
| 77 | + | } | |
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | h := newEchoHarness(t, tt.mode) | |
| 82 | + | h.warm(tt.rtt) | |
| 83 | + | if tt.mode != domain.EchoPredictNever { | |
| 84 | + | h.confirm() | |
| 85 | + | } | |
| 86 | + | if got := string(h.typed("b")); got != tt.want { | |
| 87 | + | t.Fatalf("render = %q, want %q", got, tt.want) | |
| 88 | + | } | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) { | |
| 94 | + | t.Parallel() | |
| 95 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 96 | + | h.warm(200 * time.Millisecond) | |
| 97 | + | h.confirm() | |
| 98 | + | if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") { | |
| 99 | + | t.Fatalf("pending guess is not underlined: %q", got) | |
| 100 | + | } | |
| 101 | + | got := string(h.daemon("\x1b[1;4Hb", h.seq)) | |
| 102 | + | if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want { | |
| 103 | + | t.Fatalf("confirmed render = %q, want %q", got, want) | |
| 104 | + | } | |
| 105 | + | if h.p.active() { | |
| 106 | + | t.Fatal("confirmed guess is still active") | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) { | |
| 111 | + | t.Parallel() | |
| 112 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 113 | + | h.warm(100 * time.Millisecond) | |
| 114 | + | h.confirm() | |
| 115 | + | if got := string(h.typed("p")); !strings.Contains(got, "p") { | |
| 116 | + | t.Fatalf("guess not drawn: %q", got) | |
| 117 | + | } | |
| 118 | + | // A password prompt echoes nothing. | |
| 119 | + | got := string(h.daemon("", h.seq)) | |
| 120 | + | if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |
| 121 | + | t.Fatalf("render after wrong echo = %q, want %q", got, want) | |
| 122 | + | } | |
| 123 | + | if h.p.active() { | |
| 124 | + | t.Fatal("wrong guess survived") | |
| 125 | + | } | |
| 126 | + | // The reset makes the next guess tentative again. | |
| 127 | + | if got := h.typed("q"); len(got) != 0 { | |
| 128 | + | t.Fatalf("guess after reset rendered %q", got) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestEchoPredictorBackspace(t *testing.T) { | |
| 133 | + | t.Parallel() | |
| 134 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 135 | + | h.warm(100 * time.Millisecond) | |
| 136 | + | h.confirm() | |
| 137 | + | got := string(h.typed("\x7f")) | |
| 138 | + | if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want { | |
| 139 | + | t.Fatalf("backspace render = %q, want %q", got, want) | |
| 140 | + | } | |
| 141 | + | got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq)) | |
| 142 | + | if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want { | |
| 143 | + | t.Fatalf("confirmed backspace render = %q, want %q", got, want) | |
| 144 | + | } | |
| 145 | + | if h.p.active() { | |
| 146 | + | t.Fatal("confirmed backspace is still active") | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestEchoPredictorInsertShiftsText(t *testing.T) { | |
| 151 | + | t.Parallel() | |
| 152 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 153 | + | h.warm(10 * time.Millisecond) | |
| 154 | + | // "$ ac" with the cursor on c. | |
| 155 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now) | |
| 156 | + | h.p.confEpoch = h.p.predEpoch | |
| 157 | + | got := string(h.typed("b")) | |
| 158 | + | if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want { | |
| 159 | + | t.Fatalf("insert render = %q, want %q", got, want) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestEchoPredictorUnknownInputIsTentative(t *testing.T) { | |
| 164 | + | t.Parallel() | |
| 165 | + | tests := []struct { | |
| 166 | + | name string | |
| 167 | + | input string | |
| 168 | + | }{ | |
| 169 | + | {name: "control byte", input: "\x03"}, | |
| 170 | + | {name: "escape sequence", input: "\x1b[A"}, | |
| 171 | + | {name: "kitty CSI-u key", input: "\x1b[97;5u"}, | |
| 172 | + | {name: "carriage return", input: "\r"}, | |
| 173 | + | {name: "wide rune", input: "界"}, | |
| 174 | + | {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"}, | |
| 175 | + | } | |
| 176 | + | for _, tt := range tests { | |
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |
| 178 | + | t.Parallel() | |
| 179 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 180 | + | h.warm(100 * time.Millisecond) | |
| 181 | + | h.confirm() | |
| 182 | + | h.typed(tt.input) | |
| 183 | + | if got := h.typed("z"); len(got) != 0 { | |
| 184 | + | t.Fatalf("guess after %s rendered %q", tt.name, got) | |
| 185 | + | } | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) { | |
| 191 | + | t.Parallel() | |
| 192 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 193 | + | h.warm(100 * time.Millisecond) | |
| 194 | + | h.confirm() | |
| 195 | + | confirmed := h.p.confEpoch | |
| 196 | + | h.typed("\x03z") | |
| 197 | + | // The daemon cleared the line instead of echoing z. | |
| 198 | + | h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq) | |
| 199 | + | if h.p.active() { | |
| 200 | + | t.Fatal("killed epoch left guesses") | |
| 201 | + | } | |
| 202 | + | if h.p.confEpoch != confirmed { | |
| 203 | + | t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestEchoPredictorHiddenCursorDisables(t *testing.T) { | |
| 208 | + | t.Parallel() | |
| 209 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 210 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now) | |
| 211 | + | h.p.confEpoch = h.p.predEpoch | |
| 212 | + | if got := h.typed("b"); len(got) != 0 { | |
| 213 | + | t.Fatalf("hidden cursor rendered %q", got) | |
| 214 | + | } | |
| 215 | + | if h.p.active() { | |
| 216 | + | t.Fatal("hidden cursor kept guesses") | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestEchoPredictorSRTTHysteresis(t *testing.T) { | |
| 221 | + | t.Parallel() | |
| 222 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 223 | + | h.warm(200 * time.Millisecond) | |
| 224 | + | if !h.p.srttOn || !h.p.flagging { | |
| 225 | + | t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging) | |
| 226 | + | } | |
| 227 | + | sawMiddle := false | |
| 228 | + | for range 64 { | |
| 229 | + | h.warm(0) | |
| 230 | + | interval := h.p.sendInterval() | |
| 231 | + | if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh { | |
| 232 | + | sawMiddle = true | |
| 233 | + | if !h.p.srttOn { | |
| 234 | + | t.Fatalf("display turned off at send interval %v", interval) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | if interval <= echoFlagTriggerLow && h.p.flagging { | |
| 238 | + | t.Fatalf("still flagging at send interval %v", interval) | |
| 239 | + | } | |
| 240 | + | if interval <= echoSRTTTriggerLow { | |
| 241 | + | break | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | if !sawMiddle { | |
| 245 | + | t.Fatal("never sampled the hysteresis band") | |
| 246 | + | } | |
| 247 | + | if h.p.srttOn { | |
| 248 | + | t.Fatal("display still on after the link recovered") | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) { | |
| 253 | + | t.Parallel() | |
| 254 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 255 | + | h.warm(100 * time.Millisecond) | |
| 256 | + | h.confirm() | |
| 257 | + | h.typed("b") | |
| 258 | + | // Fast acknowledgements of other input keep the b guess pending. | |
| 259 | + | h.p.srtt = 0 | |
| 260 | + | h.p.cull(h.now) | |
| 261 | + | if !h.p.srttOn { | |
| 262 | + | t.Fatal("display turned off while a guess is shown") | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) { | |
| 267 | + | t.Parallel() | |
| 268 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |
| 269 | + | h.warm(0) | |
| 270 | + | h.confirm() | |
| 271 | + | if got := h.typed("b"); len(got) != 0 { | |
| 272 | + | t.Fatalf("fast link rendered %q", got) | |
| 273 | + | } | |
| 274 | + | if !h.p.needsTick() { | |
| 275 | + | t.Fatal("pending guess needs no timing tick") | |
| 276 | + | } | |
| 277 | + | h.now = h.now.Add(echoGlitchThreshold) | |
| 278 | + | h.p.cull(h.now) | |
| 279 | + | if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" { | |
| 280 | + | t.Fatalf("glitch render = %q", got) | |
| 281 | + | } | |
| 282 | + | h.now = h.now.Add(echoGlitchFlagThreshold) | |
| 283 | + | h.p.cull(h.now) | |
| 284 | + | h.p.cull(h.now) | |
| 285 | + | if !h.p.flagging { | |
| 286 | + | t.Fatal("long glitch does not underline") | |
| 287 | + | } | |
| 288 | + | if h.p.needsTick() { | |
| 289 | + | t.Fatal("timing tick still armed once every trigger fired") | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestEchoPredictorResizeForgetsGuesses(t *testing.T) { | |
| 294 | + | t.Parallel() | |
| 295 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 296 | + | h.warm(100 * time.Millisecond) | |
| 297 | + | h.confirm() | |
| 298 | + | h.typed("b") | |
| 299 | + | h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now) | |
| 300 | + | if h.p.active() || len(h.p.drawn) != 0 { | |
| 301 | + | t.Fatal("resize kept guesses") | |
| 302 | + | } | |
| 303 | + | if got := h.p.render(); len(got) != 0 { | |
| 304 | + | t.Fatalf("render after resize = %q", got) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestEchoSGR(t *testing.T) { | |
| 309 | + | t.Parallel() | |
| 310 | + | style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style | |
| 311 | + | style.Bold = true | |
| 312 | + | style.Foreground = 9 | |
| 313 | + | style.Background = 200 | |
| 314 | + | if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want { | |
| 315 | + | t.Fatalf("echoSGR = %q, want %q", got, want) | |
| 316 | + | } | |
| 317 | + | style.HasForegroundRGB = true | |
| 318 | + | style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3 | |
| 319 | + | if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want { | |
| 320 | + | t.Fatalf("echoSGR rgb = %q, want %q", got, want) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) { | |
| 325 | + | t.Parallel() | |
| 326 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 327 | + | h.p.confEpoch = h.p.predEpoch | |
| 328 | + | if got := h.typed("b"); len(got) != 0 || h.p.active() { | |
| 329 | + | t.Fatalf("predicted before any echo acknowledgement: %q", got) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestEchoPredictorRefusesUnshiftableText(t *testing.T) { | |
| 334 | + | t.Parallel() | |
| 335 | + | tests := []struct { | |
| 336 | + | name string | |
| 337 | + | frame string | |
| 338 | + | }{ | |
| 339 | + | {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"}, | |
| 340 | + | {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"}, | |
| 341 | + | } | |
| 342 | + | for _, tt := range tests { | |
| 343 | + | t.Run(tt.name, func(t *testing.T) { | |
| 344 | + | t.Parallel() | |
| 345 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 346 | + | h.warm(10 * time.Millisecond) | |
| 347 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now) | |
| 348 | + | h.p.confEpoch = h.p.predEpoch | |
| 349 | + | if got := h.typed("x"); len(got) != 0 || h.p.active() { | |
| 350 | + | t.Fatalf("shifted unshiftable text: %q", got) | |
| 351 | + | } | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestEchoPredictorRestoresWideCellWhole(t *testing.T) { | |
| 357 | + | t.Parallel() | |
| 358 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 359 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now) | |
| 360 | + | var out bytes.Buffer | |
| 361 | + | h.p.restoreCell(&out, 0, 1) | |
| 362 | + | if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want { | |
| 363 | + | t.Fatalf("restoreCell = %q, want %q", got, want) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |
| 368 | + | t.Parallel() | |
| 369 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 370 | + | h.warm(10 * time.Millisecond) | |
| 371 | + | h.confirm() | |
| 372 | + | if got := h.typed("b"); len(got) == 0 { | |
| 373 | + | t.Fatal("guess not drawn") | |
| 374 | + | } | |
| 375 | + | h.p.reset() | |
| 376 | + | if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |
| 377 | + | t.Fatalf("render after reset = %q, want %q", got, want) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |
| 382 | + | t.Parallel() | |
| 383 | + | var f apcFilter | |
| 384 | + | chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"} | |
| 385 | + | var got []byte | |
| 386 | + | for _, chunk := range chunks { | |
| 387 | + | got = append(got, f.strip([]byte(chunk))...) | |
| 388 | + | } | |
| 389 | + | got = append(got, f.strip([]byte("[0m"))...) | |
| 390 | + | if want := "ab\x1b[1mc\x1b[0m"; string(got) != want { | |
| 391 | + | t.Fatalf("strip = %q, want %q", got, want) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestEscapeLength(t *testing.T) { | |
| 396 | + | t.Parallel() | |
| 397 | + | tests := []struct { | |
| 398 | + | in string | |
| 399 | + | want int | |
| 400 | + | }{ | |
| 401 | + | {in: "\x1b[A", want: 3}, | |
| 402 | + | {in: "\x1bOA", want: 3}, | |
| 403 | + | {in: "\x1b]11;?\x07x", want: 7}, | |
| 404 | + | {in: "\x1b]11;?\x1b\\x", want: 8}, | |
| 405 | + | {in: "\x1b]11;", want: 5}, | |
| 406 | + | {in: "\x1bx", want: 2}, | |
| 407 | + | } | |
| 408 | + | for _, tt := range tests { | |
| 409 | + | if got := escapeLength([]byte(tt.in)); got != tt.want { | |
| 410 | + | t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | } | |
| @@ -0,0 +1,413 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | |||
| 14 | + | ||||
| 15 | + | // echoHarness drives one predictor against a 20x3 mirror whose prompt "$ " | |||
| 16 | + | // leaves the cursor at row 0, column 2. | |||
| 17 | + | type echoHarness struct { | |||
| 18 | + | t *testing.T | |||
| 19 | + | p *echoPredictor | |||
| 20 | + | now time.Time | |||
| 21 | + | seq uint64 | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness { | |||
| 25 | + | t.Helper() | |||
| 26 | + | h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart} | |||
| 27 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now) | |||
| 28 | + | return h | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // warm sends one empty input and acknowledges it after rtt of network time. | |||
| 32 | + | func (h *echoHarness) warm(rtt time.Duration) { | |||
| 33 | + | h.seq++ | |||
| 34 | + | h.p.input(h.seq, nil, h.now) | |||
| 35 | + | h.now = h.now.Add(rtt + echoServerDelay) | |||
| 36 | + | h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (h *echoHarness) typed(data string) []byte { | |||
| 40 | + | h.seq++ | |||
| 41 | + | h.p.input(h.seq, []byte(data), h.now) | |||
| 42 | + | return h.p.render() | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // daemon applies a state frame that writes data and acknowledges echo. | |||
| 46 | + | func (h *echoHarness) daemon(data string, echo uint64) []byte { | |||
| 47 | + | h.now = h.now.Add(time.Millisecond) | |||
| 48 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now) | |||
| 49 | + | return h.p.render() | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // confirm types a and lets the daemon echo it, which confirms the first epoch. | |||
| 53 | + | func (h *echoHarness) confirm() { | |||
| 54 | + | h.t.Helper() | |||
| 55 | + | if got := h.typed("a"); len(got) != 0 { | |||
| 56 | + | h.t.Fatalf("first guess is tentative, rendered %q", got) | |||
| 57 | + | } | |||
| 58 | + | h.daemon("\x1b[1;3Ha", h.seq) | |||
| 59 | + | if h.p.confEpoch == 0 { | |||
| 60 | + | h.t.Fatal("echoed guess did not confirm its epoch") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestEchoPredictorDisplay(t *testing.T) { | |||
| 65 | + | t.Parallel() | |||
| 66 | + | tests := []struct { | |||
| 67 | + | name string | |||
| 68 | + | mode domain.EchoPredictMode | |||
| 69 | + | rtt time.Duration | |||
| 70 | + | want string | |||
| 71 | + | }{ | |||
| 72 | + | {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""}, | |||
| 73 | + | {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"}, | |||
| 74 | + | {name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"}, | |||
| 75 | + | {name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"}, | |||
| 76 | + | {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""}, | |||
| 77 | + | } | |||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | t.Parallel() | |||
| 81 | + | h := newEchoHarness(t, tt.mode) | |||
| 82 | + | h.warm(tt.rtt) | |||
| 83 | + | if tt.mode != domain.EchoPredictNever { | |||
| 84 | + | h.confirm() | |||
| 85 | + | } | |||
| 86 | + | if got := string(h.typed("b")); got != tt.want { | |||
| 87 | + | t.Fatalf("render = %q, want %q", got, tt.want) | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) { | |||
| 94 | + | t.Parallel() | |||
| 95 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 96 | + | h.warm(200 * time.Millisecond) | |||
| 97 | + | h.confirm() | |||
| 98 | + | if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") { | |||
| 99 | + | t.Fatalf("pending guess is not underlined: %q", got) | |||
| 100 | + | } | |||
| 101 | + | got := string(h.daemon("\x1b[1;4Hb", h.seq)) | |||
| 102 | + | if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want { | |||
| 103 | + | t.Fatalf("confirmed render = %q, want %q", got, want) | |||
| 104 | + | } | |||
| 105 | + | if h.p.active() { | |||
| 106 | + | t.Fatal("confirmed guess is still active") | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) { | |||
| 111 | + | t.Parallel() | |||
| 112 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 113 | + | h.warm(100 * time.Millisecond) | |||
| 114 | + | h.confirm() | |||
| 115 | + | if got := string(h.typed("p")); !strings.Contains(got, "p") { | |||
| 116 | + | t.Fatalf("guess not drawn: %q", got) | |||
| 117 | + | } | |||
| 118 | + | // A password prompt echoes nothing. | |||
| 119 | + | got := string(h.daemon("", h.seq)) | |||
| 120 | + | if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |||
| 121 | + | t.Fatalf("render after wrong echo = %q, want %q", got, want) | |||
| 122 | + | } | |||
| 123 | + | if h.p.active() { | |||
| 124 | + | t.Fatal("wrong guess survived") | |||
| 125 | + | } | |||
| 126 | + | // The reset makes the next guess tentative again. | |||
| 127 | + | if got := h.typed("q"); len(got) != 0 { | |||
| 128 | + | t.Fatalf("guess after reset rendered %q", got) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestEchoPredictorBackspace(t *testing.T) { | |||
| 133 | + | t.Parallel() | |||
| 134 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 135 | + | h.warm(100 * time.Millisecond) | |||
| 136 | + | h.confirm() | |||
| 137 | + | got := string(h.typed("\x7f")) | |||
| 138 | + | if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want { | |||
| 139 | + | t.Fatalf("backspace render = %q, want %q", got, want) | |||
| 140 | + | } | |||
| 141 | + | got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq)) | |||
| 142 | + | if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want { | |||
| 143 | + | t.Fatalf("confirmed backspace render = %q, want %q", got, want) | |||
| 144 | + | } | |||
| 145 | + | if h.p.active() { | |||
| 146 | + | t.Fatal("confirmed backspace is still active") | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestEchoPredictorInsertShiftsText(t *testing.T) { | |||
| 151 | + | t.Parallel() | |||
| 152 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 153 | + | h.warm(10 * time.Millisecond) | |||
| 154 | + | // "$ ac" with the cursor on c. | |||
| 155 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now) | |||
| 156 | + | h.p.confEpoch = h.p.predEpoch | |||
| 157 | + | got := string(h.typed("b")) | |||
| 158 | + | if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want { | |||
| 159 | + | t.Fatalf("insert render = %q, want %q", got, want) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestEchoPredictorUnknownInputIsTentative(t *testing.T) { | |||
| 164 | + | t.Parallel() | |||
| 165 | + | tests := []struct { | |||
| 166 | + | name string | |||
| 167 | + | input string | |||
| 168 | + | }{ | |||
| 169 | + | {name: "control byte", input: "\x03"}, | |||
| 170 | + | {name: "escape sequence", input: "\x1b[A"}, | |||
| 171 | + | {name: "kitty CSI-u key", input: "\x1b[97;5u"}, | |||
| 172 | + | {name: "carriage return", input: "\r"}, | |||
| 173 | + | {name: "wide rune", input: "界"}, | |||
| 174 | + | {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"}, | |||
| 175 | + | } | |||
| 176 | + | for _, tt := range tests { | |||
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 178 | + | t.Parallel() | |||
| 179 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 180 | + | h.warm(100 * time.Millisecond) | |||
| 181 | + | h.confirm() | |||
| 182 | + | h.typed(tt.input) | |||
| 183 | + | if got := h.typed("z"); len(got) != 0 { | |||
| 184 | + | t.Fatalf("guess after %s rendered %q", tt.name, got) | |||
| 185 | + | } | |||
| 186 | + | }) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) { | |||
| 191 | + | t.Parallel() | |||
| 192 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 193 | + | h.warm(100 * time.Millisecond) | |||
| 194 | + | h.confirm() | |||
| 195 | + | confirmed := h.p.confEpoch | |||
| 196 | + | h.typed("\x03z") | |||
| 197 | + | // The daemon cleared the line instead of echoing z. | |||
| 198 | + | h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq) | |||
| 199 | + | if h.p.active() { | |||
| 200 | + | t.Fatal("killed epoch left guesses") | |||
| 201 | + | } | |||
| 202 | + | if h.p.confEpoch != confirmed { | |||
| 203 | + | t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestEchoPredictorHiddenCursorDisables(t *testing.T) { | |||
| 208 | + | t.Parallel() | |||
| 209 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 210 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now) | |||
| 211 | + | h.p.confEpoch = h.p.predEpoch | |||
| 212 | + | if got := h.typed("b"); len(got) != 0 { | |||
| 213 | + | t.Fatalf("hidden cursor rendered %q", got) | |||
| 214 | + | } | |||
| 215 | + | if h.p.active() { | |||
| 216 | + | t.Fatal("hidden cursor kept guesses") | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestEchoPredictorSRTTHysteresis(t *testing.T) { | |||
| 221 | + | t.Parallel() | |||
| 222 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 223 | + | h.warm(200 * time.Millisecond) | |||
| 224 | + | if !h.p.srttOn || !h.p.flagging { | |||
| 225 | + | t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging) | |||
| 226 | + | } | |||
| 227 | + | sawMiddle := false | |||
| 228 | + | for range 64 { | |||
| 229 | + | h.warm(0) | |||
| 230 | + | interval := h.p.sendInterval() | |||
| 231 | + | if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh { | |||
| 232 | + | sawMiddle = true | |||
| 233 | + | if !h.p.srttOn { | |||
| 234 | + | t.Fatalf("display turned off at send interval %v", interval) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | if interval <= echoFlagTriggerLow && h.p.flagging { | |||
| 238 | + | t.Fatalf("still flagging at send interval %v", interval) | |||
| 239 | + | } | |||
| 240 | + | if interval <= echoSRTTTriggerLow { | |||
| 241 | + | break | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | if !sawMiddle { | |||
| 245 | + | t.Fatal("never sampled the hysteresis band") | |||
| 246 | + | } | |||
| 247 | + | if h.p.srttOn { | |||
| 248 | + | t.Fatal("display still on after the link recovered") | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) { | |||
| 253 | + | t.Parallel() | |||
| 254 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 255 | + | h.warm(100 * time.Millisecond) | |||
| 256 | + | h.confirm() | |||
| 257 | + | h.typed("b") | |||
| 258 | + | // Fast acknowledgements of other input keep the b guess pending. | |||
| 259 | + | h.p.srtt = 0 | |||
| 260 | + | h.p.cull(h.now) | |||
| 261 | + | if !h.p.srttOn { | |||
| 262 | + | t.Fatal("display turned off while a guess is shown") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) { | |||
| 267 | + | t.Parallel() | |||
| 268 | + | h := newEchoHarness(t, domain.EchoPredictAdaptive) | |||
| 269 | + | h.warm(0) | |||
| 270 | + | h.confirm() | |||
| 271 | + | if got := h.typed("b"); len(got) != 0 { | |||
| 272 | + | t.Fatalf("fast link rendered %q", got) | |||
| 273 | + | } | |||
| 274 | + | if !h.p.needsTick() { | |||
| 275 | + | t.Fatal("pending guess needs no timing tick") | |||
| 276 | + | } | |||
| 277 | + | h.now = h.now.Add(echoGlitchThreshold) | |||
| 278 | + | h.p.cull(h.now) | |||
| 279 | + | if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" { | |||
| 280 | + | t.Fatalf("glitch render = %q", got) | |||
| 281 | + | } | |||
| 282 | + | h.now = h.now.Add(echoGlitchFlagThreshold) | |||
| 283 | + | h.p.cull(h.now) | |||
| 284 | + | h.p.cull(h.now) | |||
| 285 | + | if !h.p.flagging { | |||
| 286 | + | t.Fatal("long glitch does not underline") | |||
| 287 | + | } | |||
| 288 | + | if h.p.needsTick() { | |||
| 289 | + | t.Fatal("timing tick still armed once every trigger fired") | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func TestEchoPredictorResizeForgetsGuesses(t *testing.T) { | |||
| 294 | + | t.Parallel() | |||
| 295 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 296 | + | h.warm(100 * time.Millisecond) | |||
| 297 | + | h.confirm() | |||
| 298 | + | h.typed("b") | |||
| 299 | + | h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now) | |||
| 300 | + | if h.p.active() || len(h.p.drawn) != 0 { | |||
| 301 | + | t.Fatal("resize kept guesses") | |||
| 302 | + | } | |||
| 303 | + | if got := h.p.render(); len(got) != 0 { | |||
| 304 | + | t.Fatalf("render after resize = %q", got) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestEchoSGR(t *testing.T) { | |||
| 309 | + | t.Parallel() | |||
| 310 | + | style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style | |||
| 311 | + | style.Bold = true | |||
| 312 | + | style.Foreground = 9 | |||
| 313 | + | style.Background = 200 | |||
| 314 | + | if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want { | |||
| 315 | + | t.Fatalf("echoSGR = %q, want %q", got, want) | |||
| 316 | + | } | |||
| 317 | + | style.HasForegroundRGB = true | |||
| 318 | + | style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3 | |||
| 319 | + | if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want { | |||
| 320 | + | t.Fatalf("echoSGR rgb = %q, want %q", got, want) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) { | |||
| 325 | + | t.Parallel() | |||
| 326 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 327 | + | h.p.confEpoch = h.p.predEpoch | |||
| 328 | + | if got := h.typed("b"); len(got) != 0 || h.p.active() { | |||
| 329 | + | t.Fatalf("predicted before any echo acknowledgement: %q", got) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestEchoPredictorRefusesUnshiftableText(t *testing.T) { | |||
| 334 | + | t.Parallel() | |||
| 335 | + | tests := []struct { | |||
| 336 | + | name string | |||
| 337 | + | frame string | |||
| 338 | + | }{ | |||
| 339 | + | {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"}, | |||
| 340 | + | {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"}, | |||
| 341 | + | } | |||
| 342 | + | for _, tt := range tests { | |||
| 343 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 344 | + | t.Parallel() | |||
| 345 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 346 | + | h.warm(10 * time.Millisecond) | |||
| 347 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now) | |||
| 348 | + | h.p.confEpoch = h.p.predEpoch | |||
| 349 | + | if got := h.typed("x"); len(got) != 0 || h.p.active() { | |||
| 350 | + | t.Fatalf("shifted unshiftable text: %q", got) | |||
| 351 | + | } | |||
| 352 | + | }) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestEchoPredictorRestoresWideCellWhole(t *testing.T) { | |||
| 357 | + | t.Parallel() | |||
| 358 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 359 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now) | |||
| 360 | + | var out bytes.Buffer | |||
| 361 | + | h.p.restoreCell(&out, 0, 1) | |||
| 362 | + | if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want { | |||
| 363 | + | t.Fatalf("restoreCell = %q, want %q", got, want) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |||
| 368 | + | t.Parallel() | |||
| 369 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 370 | + | h.warm(10 * time.Millisecond) | |||
| 371 | + | h.confirm() | |||
| 372 | + | if got := h.typed("b"); len(got) == 0 { | |||
| 373 | + | t.Fatal("guess not drawn") | |||
| 374 | + | } | |||
| 375 | + | h.p.reset() | |||
| 376 | + | if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |||
| 377 | + | t.Fatalf("render after reset = %q, want %q", got, want) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |||
| 382 | + | t.Parallel() | |||
| 383 | + | var f apcFilter | |||
| 384 | + | chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"} | |||
| 385 | + | var got []byte | |||
| 386 | + | for _, chunk := range chunks { | |||
| 387 | + | got = append(got, f.strip([]byte(chunk))...) | |||
| 388 | + | } | |||
| 389 | + | got = append(got, f.strip([]byte("[0m"))...) | |||
| 390 | + | if want := "ab\x1b[1mc\x1b[0m"; string(got) != want { | |||
| 391 | + | t.Fatalf("strip = %q, want %q", got, want) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestEscapeLength(t *testing.T) { | |||
| 396 | + | t.Parallel() | |||
| 397 | + | tests := []struct { | |||
| 398 | + | in string | |||
| 399 | + | want int | |||
| 400 | + | }{ | |||
| 401 | + | {in: "\x1b[A", want: 3}, | |||
| 402 | + | {in: "\x1bOA", want: 3}, | |||
| 403 | + | {in: "\x1b]11;?\x07x", want: 7}, | |||
| 404 | + | {in: "\x1b]11;?\x1b\\x", want: 8}, | |||
| 405 | + | {in: "\x1b]11;", want: 5}, | |||
| 406 | + | {in: "\x1bx", want: 2}, | |||
| 407 | + | } | |||
| 408 | + | for _, tt := range tests { | |||
| 409 | + | if got := escapeLength([]byte(tt.in)); got != tt.want { | |||
| 410 | + | t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | } | |||
M internal/usecase/client/supervisor.go +2 -0
| @@ -396,6 +396,8 @@ type AttachmentEnvironment struct { | |||
| 396 | 396 | // KittyKeyboard enables the kitty keyboard protocol when the probe finds | |
| 397 | 397 | // it, which makes Ctrl+1..9 recent-session switching available. | |
| 398 | 398 | KittyKeyboard bool | |
| 399 | + | // EchoPredict selects predictive local echo on remote attachments. | |
| 400 | + | EchoPredict domain.EchoPredictMode | |
| 399 | 401 | } | |
| 400 | 402 | ||
| 401 | 403 | // AttachmentLifecycleAction is an explicit process/attachment decision. | |
| @@ -396,6 +396,8 @@ type AttachmentEnvironment struct { | |||||
| 396 | // KittyKeyboard enables the kitty keyboard protocol when the probe finds | 396 | // KittyKeyboard enables the kitty keyboard protocol when the probe finds | ||
| 397 | // it, which makes Ctrl+1..9 recent-session switching available. | 397 | // it, which makes Ctrl+1..9 recent-session switching available. | ||
| 398 | KittyKeyboard bool | 398 | KittyKeyboard bool | ||
| 399 | + | // EchoPredict selects predictive local echo on remote attachments. | |||
| 400 | + | EchoPredict domain.EchoPredictMode | |||
| 399 | } | 401 | } | ||
| 400 | 402 | ||||
| 401 | // AttachmentLifecycleAction is an explicit process/attachment decision. | 403 | // AttachmentLifecycleAction is an explicit process/attachment decision. | ||
M internal/usecase/client/supervisor_attach.go +1 -0
| @@ -357,6 +357,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 357 | 357 | Clipboard: s.cfg.Clipboard, | |
| 358 | 358 | Tab: tab, | |
| 359 | 359 | Tabs: &s.tabs, | |
| 360 | + | EchoPredict: environment.EchoPredict, | |
| 360 | 361 | }) | |
| 361 | 362 | if err != nil { | |
| 362 | 363 | return nil, err | |
| @@ -357,6 +357,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 357 | Clipboard: s.cfg.Clipboard, | 357 | Clipboard: s.cfg.Clipboard, | ||
| 358 | Tab: tab, | 358 | Tab: tab, | ||
| 359 | Tabs: &s.tabs, | 359 | Tabs: &s.tabs, | ||
| 360 | + | EchoPredict: environment.EchoPredict, | |||
| 360 | }) | 361 | }) | ||
| 361 | if err != nil { | 362 | if err != nil { | ||
| 362 | return nil, err | 363 | return nil, err | ||
M internal/usecase/daemon/client.go +4 -1
| @@ -68,7 +68,10 @@ type attachedClient struct { | |||
| 68 | 68 | // credential instead of consuming it. | |
| 69 | 69 | resumeClaimToken uint64 | |
| 70 | 70 | parked bool | |
| 71 | - | echoAck atomic.Uint64 | |
| 71 | + | // echoAck is the newest client InputSeq applied at least echoAckDelay ago; | |
| 72 | + | // echo owns its updates. | |
| 73 | + | echoAck atomic.Uint64 | |
| 74 | + | echo echoAckTracker | |
| 72 | 75 | // focus holds the domain.TerminalFocus the client last reported. It is an | |
| 73 | 76 | // atomic so any daemon path reads it without taking a lock; see | |
| 74 | 77 | // terminalFocus. | |
| @@ -68,7 +68,10 @@ type attachedClient struct { | |||||
| 68 | // credential instead of consuming it. | 68 | // credential instead of consuming it. | ||
| 69 | resumeClaimToken uint64 | 69 | resumeClaimToken uint64 | ||
| 70 | parked bool | 70 | parked bool | ||
| 71 | - | echoAck atomic.Uint64 | 71 | + | // echoAck is the newest client InputSeq applied at least echoAckDelay ago; |
| 72 | + | // echo owns its updates. | |||
| 73 | + | echoAck atomic.Uint64 | |||
| 74 | + | echo echoAckTracker | |||
| 72 | // focus holds the domain.TerminalFocus the client last reported. It is an | 75 | // focus holds the domain.TerminalFocus the client last reported. It is an | ||
| 73 | // atomic so any daemon path reads it without taking a lock; see | 76 | // atomic so any daemon path reads it without taking a lock; see | ||
| 74 | // terminalFocus. | 77 | // terminalFocus. | ||
A internal/usecase/daemon/echo_ack.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // echoAckDelay is how long sequenced input must have been applied before the | |
| 11 | + | // daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the | |
| 12 | + | // PTY time to echo the input, so the screen published with the acknowledgement | |
| 13 | + | // already contains whatever echo the input produced. | |
| 14 | + | const echoAckDelay = 50 * time.Millisecond | |
| 15 | + | ||
| 16 | + | // maxPendingEchoes bounds the per-attachment input history. Acknowledgements | |
| 17 | + | // are cumulative, so dropping the oldest entry only delays its confirmation to | |
| 18 | + | // a newer one. | |
| 19 | + | const maxPendingEchoes = 256 | |
| 20 | + | ||
| 21 | + | type pendingEcho struct { | |
| 22 | + | seq uint64 | |
| 23 | + | at time.Time | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // echoAckTracker turns applied input sequence numbers into the cumulative | |
| 27 | + | // Output.Echo acknowledgement. echoAck is written only under mu. | |
| 28 | + | type echoAckTracker struct { | |
| 29 | + | mu sync.Mutex | |
| 30 | + | pending []pendingEcho | |
| 31 | + | timer pendingByteTimer | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // noteInputApplied records that the input numbered seq reached the session and | |
| 35 | + | // schedules its acknowledgement. Sequence zero is unsequenced input. | |
| 36 | + | func (d *Daemon) noteInputApplied(ac *attachedClient, seq uint64) { | |
| 37 | + | if ac == nil || seq == 0 { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | t := &ac.echo | |
| 41 | + | t.mu.Lock() | |
| 42 | + | defer t.mu.Unlock() | |
| 43 | + | if seq <= ac.echoAck.Load() || len(t.pending) > 0 && seq <= t.pending[len(t.pending)-1].seq { | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | if len(t.pending) == maxPendingEchoes { | |
| 47 | + | t.pending = append(t.pending[:0], t.pending[1:]...) | |
| 48 | + | } | |
| 49 | + | t.pending = append(t.pending, pendingEcho{seq: seq, at: d.clock.Now()}) | |
| 50 | + | if t.timer.timer == nil { | |
| 51 | + | d.armEchoAckLocked(ac, echoAckDelay) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // armEchoAckLocked starts the single echo timer. The caller holds echo.mu. | |
| 56 | + | func (d *Daemon) armEchoAckLocked(ac *attachedClient, delay time.Duration) { | |
| 57 | + | t := &ac.echo | |
| 58 | + | t.timer.retain(d.clock, delay, func(fired ports.Timer) { | |
| 59 | + | if d.advanceEchoAck(ac, fired) { | |
| 60 | + | d.sendEchoAck(ac) | |
| 61 | + | } | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // armEchoAckRetryLocked re-sends the current acknowledgement after one more | |
| 66 | + | // echo delay. The caller holds echo.mu. | |
| 67 | + | func (d *Daemon) armEchoAckRetryLocked(ac *attachedClient) { | |
| 68 | + | t := &ac.echo | |
| 69 | + | t.timer.retain(d.clock, echoAckDelay, func(fired ports.Timer) { | |
| 70 | + | t.mu.Lock() | |
| 71 | + | current := t.timer.timer == fired | |
| 72 | + | if current { | |
| 73 | + | t.timer.timer, t.timer.done = nil, nil | |
| 74 | + | if len(t.pending) > 0 { | |
| 75 | + | // New input owns the next timer and its ack covers this one. | |
| 76 | + | d.armEchoAckLocked(ac, max(echoAckDelay-d.clock.Now().Sub(t.pending[0].at), 0)) | |
| 77 | + | current = false | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | t.mu.Unlock() | |
| 81 | + | if current { | |
| 82 | + | d.sendEchoAck(ac) | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // advanceEchoAck moves echoAck to the newest input applied at least | |
| 88 | + | // echoAckDelay ago and re-arms for the rest. It reports whether echoAck moved. | |
| 89 | + | func (d *Daemon) advanceEchoAck(ac *attachedClient, fired ports.Timer) bool { | |
| 90 | + | t := &ac.echo | |
| 91 | + | t.mu.Lock() | |
| 92 | + | defer t.mu.Unlock() | |
| 93 | + | if t.timer.timer != fired { | |
| 94 | + | return false // stopped by a reset or superseded | |
| 95 | + | } | |
| 96 | + | t.timer.timer, t.timer.done = nil, nil | |
| 97 | + | now := d.clock.Now() | |
| 98 | + | acked := uint64(0) | |
| 99 | + | n := 0 | |
| 100 | + | for n < len(t.pending) && now.Sub(t.pending[n].at) >= echoAckDelay { | |
| 101 | + | acked = t.pending[n].seq | |
| 102 | + | n++ | |
| 103 | + | } | |
| 104 | + | t.pending = append(t.pending[:0], t.pending[n:]...) | |
| 105 | + | if len(t.pending) > 0 { | |
| 106 | + | d.armEchoAckLocked(ac, max(echoAckDelay-now.Sub(t.pending[0].at), 0)) | |
| 107 | + | } | |
| 108 | + | if acked <= ac.echoAck.Load() { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | ac.echoAck.Store(acked) | |
| 112 | + | return true | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // sendEchoAck publishes a new acknowledgement in an empty side-effect Output, | |
| 116 | + | // so the client learns about it even when the screen does not change. | |
| 117 | + | // | |
| 118 | + | // Side effects bypass the output window. While the window is full, the frame | |
| 119 | + | // holding the acknowledged echo may still be withheld, and an early ack would | |
| 120 | + | // make the client judge its guesses against an older screen. The ack then | |
| 121 | + | // waits: the next frame carries it, and a retry covers a screen that never | |
| 122 | + | // changes. | |
| 123 | + | func (d *Daemon) sendEchoAck(ac *attachedClient) { | |
| 124 | + | if ac.attachmentActivity() != attachmentActive { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | sess := ac.currentAttachmentSession() | |
| 128 | + | if sess == nil { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | if ac.output != nil && ac.output.atCapacity() { | |
| 132 | + | t := &ac.echo | |
| 133 | + | t.mu.Lock() | |
| 134 | + | if t.timer.timer == nil { | |
| 135 | + | d.armEchoAckRetryLocked(ac) | |
| 136 | + | } | |
| 137 | + | t.mu.Unlock() | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | failed, err := d.boundedSendOutputErrTransport(ac, nil) | |
| 141 | + | if err != nil && failed != nil { | |
| 142 | + | d.detachOnSendError(sess, ac, failed) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // resetEchoAck forgets every acknowledgement. A resumed client restarts its | |
| 147 | + | // input numbering, so the replacement link must start from zero too. | |
| 148 | + | func (ac *attachedClient) resetEchoAck() { | |
| 149 | + | t := &ac.echo | |
| 150 | + | t.mu.Lock() | |
| 151 | + | defer t.mu.Unlock() | |
| 152 | + | t.timer.stop() | |
| 153 | + | t.pending = nil | |
| 154 | + | ac.echoAck.Store(0) | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // echoAckDelay is how long sequenced input must have been applied before the | |||
| 11 | + | // daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the | |||
| 12 | + | // PTY time to echo the input, so the screen published with the acknowledgement | |||
| 13 | + | // already contains whatever echo the input produced. | |||
| 14 | + | const echoAckDelay = 50 * time.Millisecond | |||
| 15 | + | ||||
| 16 | + | // maxPendingEchoes bounds the per-attachment input history. Acknowledgements | |||
| 17 | + | // are cumulative, so dropping the oldest entry only delays its confirmation to | |||
| 18 | + | // a newer one. | |||
| 19 | + | const maxPendingEchoes = 256 | |||
| 20 | + | ||||
| 21 | + | type pendingEcho struct { | |||
| 22 | + | seq uint64 | |||
| 23 | + | at time.Time | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // echoAckTracker turns applied input sequence numbers into the cumulative | |||
| 27 | + | // Output.Echo acknowledgement. echoAck is written only under mu. | |||
| 28 | + | type echoAckTracker struct { | |||
| 29 | + | mu sync.Mutex | |||
| 30 | + | pending []pendingEcho | |||
| 31 | + | timer pendingByteTimer | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // noteInputApplied records that the input numbered seq reached the session and | |||
| 35 | + | // schedules its acknowledgement. Sequence zero is unsequenced input. | |||
| 36 | + | func (d *Daemon) noteInputApplied(ac *attachedClient, seq uint64) { | |||
| 37 | + | if ac == nil || seq == 0 { | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | t := &ac.echo | |||
| 41 | + | t.mu.Lock() | |||
| 42 | + | defer t.mu.Unlock() | |||
| 43 | + | if seq <= ac.echoAck.Load() || len(t.pending) > 0 && seq <= t.pending[len(t.pending)-1].seq { | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | if len(t.pending) == maxPendingEchoes { | |||
| 47 | + | t.pending = append(t.pending[:0], t.pending[1:]...) | |||
| 48 | + | } | |||
| 49 | + | t.pending = append(t.pending, pendingEcho{seq: seq, at: d.clock.Now()}) | |||
| 50 | + | if t.timer.timer == nil { | |||
| 51 | + | d.armEchoAckLocked(ac, echoAckDelay) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // armEchoAckLocked starts the single echo timer. The caller holds echo.mu. | |||
| 56 | + | func (d *Daemon) armEchoAckLocked(ac *attachedClient, delay time.Duration) { | |||
| 57 | + | t := &ac.echo | |||
| 58 | + | t.timer.retain(d.clock, delay, func(fired ports.Timer) { | |||
| 59 | + | if d.advanceEchoAck(ac, fired) { | |||
| 60 | + | d.sendEchoAck(ac) | |||
| 61 | + | } | |||
| 62 | + | }) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // armEchoAckRetryLocked re-sends the current acknowledgement after one more | |||
| 66 | + | // echo delay. The caller holds echo.mu. | |||
| 67 | + | func (d *Daemon) armEchoAckRetryLocked(ac *attachedClient) { | |||
| 68 | + | t := &ac.echo | |||
| 69 | + | t.timer.retain(d.clock, echoAckDelay, func(fired ports.Timer) { | |||
| 70 | + | t.mu.Lock() | |||
| 71 | + | current := t.timer.timer == fired | |||
| 72 | + | if current { | |||
| 73 | + | t.timer.timer, t.timer.done = nil, nil | |||
| 74 | + | if len(t.pending) > 0 { | |||
| 75 | + | // New input owns the next timer and its ack covers this one. | |||
| 76 | + | d.armEchoAckLocked(ac, max(echoAckDelay-d.clock.Now().Sub(t.pending[0].at), 0)) | |||
| 77 | + | current = false | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | t.mu.Unlock() | |||
| 81 | + | if current { | |||
| 82 | + | d.sendEchoAck(ac) | |||
| 83 | + | } | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // advanceEchoAck moves echoAck to the newest input applied at least | |||
| 88 | + | // echoAckDelay ago and re-arms for the rest. It reports whether echoAck moved. | |||
| 89 | + | func (d *Daemon) advanceEchoAck(ac *attachedClient, fired ports.Timer) bool { | |||
| 90 | + | t := &ac.echo | |||
| 91 | + | t.mu.Lock() | |||
| 92 | + | defer t.mu.Unlock() | |||
| 93 | + | if t.timer.timer != fired { | |||
| 94 | + | return false // stopped by a reset or superseded | |||
| 95 | + | } | |||
| 96 | + | t.timer.timer, t.timer.done = nil, nil | |||
| 97 | + | now := d.clock.Now() | |||
| 98 | + | acked := uint64(0) | |||
| 99 | + | n := 0 | |||
| 100 | + | for n < len(t.pending) && now.Sub(t.pending[n].at) >= echoAckDelay { | |||
| 101 | + | acked = t.pending[n].seq | |||
| 102 | + | n++ | |||
| 103 | + | } | |||
| 104 | + | t.pending = append(t.pending[:0], t.pending[n:]...) | |||
| 105 | + | if len(t.pending) > 0 { | |||
| 106 | + | d.armEchoAckLocked(ac, max(echoAckDelay-now.Sub(t.pending[0].at), 0)) | |||
| 107 | + | } | |||
| 108 | + | if acked <= ac.echoAck.Load() { | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | ac.echoAck.Store(acked) | |||
| 112 | + | return true | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // sendEchoAck publishes a new acknowledgement in an empty side-effect Output, | |||
| 116 | + | // so the client learns about it even when the screen does not change. | |||
| 117 | + | // | |||
| 118 | + | // Side effects bypass the output window. While the window is full, the frame | |||
| 119 | + | // holding the acknowledged echo may still be withheld, and an early ack would | |||
| 120 | + | // make the client judge its guesses against an older screen. The ack then | |||
| 121 | + | // waits: the next frame carries it, and a retry covers a screen that never | |||
| 122 | + | // changes. | |||
| 123 | + | func (d *Daemon) sendEchoAck(ac *attachedClient) { | |||
| 124 | + | if ac.attachmentActivity() != attachmentActive { | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | sess := ac.currentAttachmentSession() | |||
| 128 | + | if sess == nil { | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | if ac.output != nil && ac.output.atCapacity() { | |||
| 132 | + | t := &ac.echo | |||
| 133 | + | t.mu.Lock() | |||
| 134 | + | if t.timer.timer == nil { | |||
| 135 | + | d.armEchoAckRetryLocked(ac) | |||
| 136 | + | } | |||
| 137 | + | t.mu.Unlock() | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | failed, err := d.boundedSendOutputErrTransport(ac, nil) | |||
| 141 | + | if err != nil && failed != nil { | |||
| 142 | + | d.detachOnSendError(sess, ac, failed) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // resetEchoAck forgets every acknowledgement. A resumed client restarts its | |||
| 147 | + | // input numbering, so the replacement link must start from zero too. | |||
| 148 | + | func (ac *attachedClient) resetEchoAck() { | |||
| 149 | + | t := &ac.echo | |||
| 150 | + | t.mu.Lock() | |||
| 151 | + | defer t.mu.Unlock() | |||
| 152 | + | t.timer.stop() | |||
| 153 | + | t.pending = nil | |||
| 154 | + | ac.echoAck.Store(0) | |||
| 155 | + | } | |||
A internal/usecase/daemon/echo_ack_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // echoTestClock is a settable clock whose timers fire only when the test says. | |
| 15 | + | type echoTestClock struct { | |
| 16 | + | mu sync.Mutex | |
| 17 | + | now time.Time | |
| 18 | + | timers chan echoTestTimer | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type echoTestTimer struct { | |
| 22 | + | timer *manualAttentionTimer | |
| 23 | + | delay time.Duration | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func newEchoTestClock() *echoTestClock { | |
| 27 | + | return &echoTestClock{now: time.Unix(100, 0), timers: make(chan echoTestTimer, 16)} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (c *echoTestClock) Now() time.Time { | |
| 31 | + | c.mu.Lock() | |
| 32 | + | defer c.mu.Unlock() | |
| 33 | + | return c.now | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (c *echoTestClock) advance(d time.Duration) { | |
| 37 | + | c.mu.Lock() | |
| 38 | + | c.now = c.now.Add(d) | |
| 39 | + | c.mu.Unlock() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (c *echoTestClock) NewTimer(d time.Duration) ports.Timer { | |
| 43 | + | t := &manualAttentionTimer{ch: make(chan time.Time, 1)} | |
| 44 | + | c.timers <- echoTestTimer{timer: t, delay: d} | |
| 45 | + | return t | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // nextTimer returns the next echo timer, skipping the send-bound timers the | |
| 49 | + | // ack-only output path creates. | |
| 50 | + | func (c *echoTestClock) nextTimer(t *testing.T) (*manualAttentionTimer, time.Duration) { | |
| 51 | + | t.Helper() | |
| 52 | + | deadline := time.After(time.Second) | |
| 53 | + | for { | |
| 54 | + | select { | |
| 55 | + | case next := <-c.timers: | |
| 56 | + | if next.delay <= echoAckDelay { | |
| 57 | + | return next.timer, next.delay | |
| 58 | + | } | |
| 59 | + | case <-deadline: | |
| 60 | + | t.Fatal("timed out waiting for echo timer") | |
| 61 | + | return nil, 0 | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestEchoAckWaitsForEchoTimeoutAndSendsAckOnlyOutput(t *testing.T) { | |
| 67 | + | clock := newEchoTestClock() | |
| 68 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |
| 69 | + | ||
| 70 | + | d.noteInputApplied(ac, 1) | |
| 71 | + | clock.advance(20 * time.Millisecond) | |
| 72 | + | d.noteInputApplied(ac, 2) | |
| 73 | + | timer, delay := clock.nextTimer(t) | |
| 74 | + | require.Equal(t, echoAckDelay, delay) | |
| 75 | + | require.Zero(t, ac.echoAck.Load(), "input is not echoed when applied") | |
| 76 | + | ||
| 77 | + | clock.advance(30 * time.Millisecond) | |
| 78 | + | timer.fire() | |
| 79 | + | output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |
| 80 | + | require.Equal(t, uint64(1), output.Echo) | |
| 81 | + | require.Zero(t, output.New, "an ack-only output is a side effect") | |
| 82 | + | require.Empty(t, output.Data) | |
| 83 | + | ||
| 84 | + | // Input 2 is still younger than the timeout: the tracker re-arms for its | |
| 85 | + | // remaining age. | |
| 86 | + | timer, delay = clock.nextTimer(t) | |
| 87 | + | require.Equal(t, 20*time.Millisecond, delay) | |
| 88 | + | clock.advance(20 * time.Millisecond) | |
| 89 | + | timer.fire() | |
| 90 | + | output = decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |
| 91 | + | require.Equal(t, uint64(2), output.Echo) | |
| 92 | + | require.Equal(t, uint64(2), ac.echoAck.Load()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestEchoAckIgnoresUnsequencedAndStaleInput(t *testing.T) { | |
| 96 | + | clock := newEchoTestClock() | |
| 97 | + | d, _, ac, _ := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |
| 98 | + | ac.echoAck.Store(5) | |
| 99 | + | ||
| 100 | + | d.noteInputApplied(ac, 0) | |
| 101 | + | d.noteInputApplied(ac, 5) | |
| 102 | + | require.Empty(t, ac.echo.pending) | |
| 103 | + | require.Empty(t, clock.timers) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestResetEchoAckDropsPendingInput(t *testing.T) { | |
| 107 | + | clock := newEchoTestClock() | |
| 108 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |
| 109 | + | ||
| 110 | + | d.noteInputApplied(ac, 7) | |
| 111 | + | timer, _ := clock.nextTimer(t) | |
| 112 | + | ac.resetEchoAck() | |
| 113 | + | clock.advance(echoAckDelay) | |
| 114 | + | timer.fire() | |
| 115 | + | ||
| 116 | + | require.Zero(t, ac.echoAck.Load()) | |
| 117 | + | require.Empty(t, drainAllFrames(sends), "a reset tracker must not publish the old link's input") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestEchoAckWaitsWhileOutputWindowIsFull(t *testing.T) { | |
| 121 | + | clock := newEchoTestClock() | |
| 122 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |
| 123 | + | ac.output.maxOutstandingAtomic.Store(1) | |
| 124 | + | ac.output.outstandingAtomic.Store(1) | |
| 125 | + | ||
| 126 | + | d.noteInputApplied(ac, 1) | |
| 127 | + | timer, _ := clock.nextTimer(t) | |
| 128 | + | clock.advance(echoAckDelay) | |
| 129 | + | timer.fire() | |
| 130 | + | ||
| 131 | + | retry, delay := clock.nextTimer(t) | |
| 132 | + | require.Equal(t, echoAckDelay, delay, "a full window defers the ack") | |
| 133 | + | require.Equal(t, uint64(1), ac.echoAck.Load(), "the next frame may still carry it") | |
| 134 | + | require.Empty(t, drainAllFrames(sends), "no ack-only output may overtake withheld frames") | |
| 135 | + | ||
| 136 | + | ac.output.outstandingAtomic.Store(0) | |
| 137 | + | clock.advance(echoAckDelay) | |
| 138 | + | retry.fire() | |
| 139 | + | output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |
| 140 | + | require.Equal(t, uint64(1), output.Echo) | |
| 141 | + | require.Zero(t, output.New) | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // echoTestClock is a settable clock whose timers fire only when the test says. | |||
| 15 | + | type echoTestClock struct { | |||
| 16 | + | mu sync.Mutex | |||
| 17 | + | now time.Time | |||
| 18 | + | timers chan echoTestTimer | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type echoTestTimer struct { | |||
| 22 | + | timer *manualAttentionTimer | |||
| 23 | + | delay time.Duration | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func newEchoTestClock() *echoTestClock { | |||
| 27 | + | return &echoTestClock{now: time.Unix(100, 0), timers: make(chan echoTestTimer, 16)} | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (c *echoTestClock) Now() time.Time { | |||
| 31 | + | c.mu.Lock() | |||
| 32 | + | defer c.mu.Unlock() | |||
| 33 | + | return c.now | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (c *echoTestClock) advance(d time.Duration) { | |||
| 37 | + | c.mu.Lock() | |||
| 38 | + | c.now = c.now.Add(d) | |||
| 39 | + | c.mu.Unlock() | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (c *echoTestClock) NewTimer(d time.Duration) ports.Timer { | |||
| 43 | + | t := &manualAttentionTimer{ch: make(chan time.Time, 1)} | |||
| 44 | + | c.timers <- echoTestTimer{timer: t, delay: d} | |||
| 45 | + | return t | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // nextTimer returns the next echo timer, skipping the send-bound timers the | |||
| 49 | + | // ack-only output path creates. | |||
| 50 | + | func (c *echoTestClock) nextTimer(t *testing.T) (*manualAttentionTimer, time.Duration) { | |||
| 51 | + | t.Helper() | |||
| 52 | + | deadline := time.After(time.Second) | |||
| 53 | + | for { | |||
| 54 | + | select { | |||
| 55 | + | case next := <-c.timers: | |||
| 56 | + | if next.delay <= echoAckDelay { | |||
| 57 | + | return next.timer, next.delay | |||
| 58 | + | } | |||
| 59 | + | case <-deadline: | |||
| 60 | + | t.Fatal("timed out waiting for echo timer") | |||
| 61 | + | return nil, 0 | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestEchoAckWaitsForEchoTimeoutAndSendsAckOnlyOutput(t *testing.T) { | |||
| 67 | + | clock := newEchoTestClock() | |||
| 68 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |||
| 69 | + | ||||
| 70 | + | d.noteInputApplied(ac, 1) | |||
| 71 | + | clock.advance(20 * time.Millisecond) | |||
| 72 | + | d.noteInputApplied(ac, 2) | |||
| 73 | + | timer, delay := clock.nextTimer(t) | |||
| 74 | + | require.Equal(t, echoAckDelay, delay) | |||
| 75 | + | require.Zero(t, ac.echoAck.Load(), "input is not echoed when applied") | |||
| 76 | + | ||||
| 77 | + | clock.advance(30 * time.Millisecond) | |||
| 78 | + | timer.fire() | |||
| 79 | + | output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |||
| 80 | + | require.Equal(t, uint64(1), output.Echo) | |||
| 81 | + | require.Zero(t, output.New, "an ack-only output is a side effect") | |||
| 82 | + | require.Empty(t, output.Data) | |||
| 83 | + | ||||
| 84 | + | // Input 2 is still younger than the timeout: the tracker re-arms for its | |||
| 85 | + | // remaining age. | |||
| 86 | + | timer, delay = clock.nextTimer(t) | |||
| 87 | + | require.Equal(t, 20*time.Millisecond, delay) | |||
| 88 | + | clock.advance(20 * time.Millisecond) | |||
| 89 | + | timer.fire() | |||
| 90 | + | output = decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |||
| 91 | + | require.Equal(t, uint64(2), output.Echo) | |||
| 92 | + | require.Equal(t, uint64(2), ac.echoAck.Load()) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestEchoAckIgnoresUnsequencedAndStaleInput(t *testing.T) { | |||
| 96 | + | clock := newEchoTestClock() | |||
| 97 | + | d, _, ac, _ := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |||
| 98 | + | ac.echoAck.Store(5) | |||
| 99 | + | ||||
| 100 | + | d.noteInputApplied(ac, 0) | |||
| 101 | + | d.noteInputApplied(ac, 5) | |||
| 102 | + | require.Empty(t, ac.echo.pending) | |||
| 103 | + | require.Empty(t, clock.timers) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestResetEchoAckDropsPendingInput(t *testing.T) { | |||
| 107 | + | clock := newEchoTestClock() | |||
| 108 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |||
| 109 | + | ||||
| 110 | + | d.noteInputApplied(ac, 7) | |||
| 111 | + | timer, _ := clock.nextTimer(t) | |||
| 112 | + | ac.resetEchoAck() | |||
| 113 | + | clock.advance(echoAckDelay) | |||
| 114 | + | timer.fire() | |||
| 115 | + | ||||
| 116 | + | require.Zero(t, ac.echoAck.Load()) | |||
| 117 | + | require.Empty(t, drainAllFrames(sends), "a reset tracker must not publish the old link's input") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestEchoAckWaitsWhileOutputWindowIsFull(t *testing.T) { | |||
| 121 | + | clock := newEchoTestClock() | |||
| 122 | + | d, _, ac, sends := newManualSessionWithPTYsClock(t, clock, newQuietPTY()) | |||
| 123 | + | ac.output.maxOutstandingAtomic.Store(1) | |||
| 124 | + | ac.output.outstandingAtomic.Store(1) | |||
| 125 | + | ||||
| 126 | + | d.noteInputApplied(ac, 1) | |||
| 127 | + | timer, _ := clock.nextTimer(t) | |||
| 128 | + | clock.advance(echoAckDelay) | |||
| 129 | + | timer.fire() | |||
| 130 | + | ||||
| 131 | + | retry, delay := clock.nextTimer(t) | |||
| 132 | + | require.Equal(t, echoAckDelay, delay, "a full window defers the ack") | |||
| 133 | + | require.Equal(t, uint64(1), ac.echoAck.Load(), "the next frame may still carry it") | |||
| 134 | + | require.Empty(t, drainAllFrames(sends), "no ack-only output may overtake withheld frames") | |||
| 135 | + | ||||
| 136 | + | ac.output.outstandingAtomic.Store(0) | |||
| 137 | + | clock.advance(echoAckDelay) | |||
| 138 | + | retry.fire() | |||
| 139 | + | output := decodeServerMessage(t, awaitFrame(t, sends, "Output")).(protocol.Output) | |||
| 140 | + | require.Equal(t, uint64(1), output.Echo) | |||
| 141 | + | require.Zero(t, output.New) | |||
| 142 | + | } | |||
M internal/usecase/daemon/input.go +7 -6
| @@ -14,19 +14,20 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) { | |
| 18 | - | // Do not acknowledge client-side echo prediction here: input has only been | |
| 19 | - | // accepted/routed, not necessarily echoed by the PTY and incorporated into a | |
| 20 | - | // rendered screen state. Until prediction is implemented against rendered | |
| 21 | - | // output state, Echo must remain conservative. | |
| 17 | + | // handleSequencedInput applies input and schedules its echo acknowledgement. | |
| 18 | + | // Echo is not acknowledged immediately: the PTY needs time to echo the input | |
| 19 | + | // into a rendered screen state (see echoAckDelay). | |
| 20 | + | func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) { | |
| 22 | 21 | d.handleInput(sess, ac, data) | |
| 22 | + | d.noteInputApplied(ac, seq) | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) { | |
| 25 | + | func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, seq uint64, data []byte) { | |
| 26 | 26 | if !effect.current() { | |
| 27 | 27 | return | |
| 28 | 28 | } | |
| 29 | 29 | d.handleInputForAttachment(effect, data) | |
| 30 | + | d.noteInputApplied(effect.ac, seq) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| @@ -14,19 +14,20 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/usecase/mouse" | 14 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) { | 17 | + | // handleSequencedInput applies input and schedules its echo acknowledgement. |
| 18 | - | // Do not acknowledge client-side echo prediction here: input has only been | 18 | + | // Echo is not acknowledged immediately: the PTY needs time to echo the input |
| 19 | - | // accepted/routed, not necessarily echoed by the PTY and incorporated into a | 19 | + | // into a rendered screen state (see echoAckDelay). |
| 20 | - | // rendered screen state. Until prediction is implemented against rendered | 20 | + | func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) { |
| 21 | - | // output state, Echo must remain conservative. | |||
| 22 | d.handleInput(sess, ac, data) | 21 | d.handleInput(sess, ac, data) | ||
| 22 | + | d.noteInputApplied(ac, seq) | |||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) { | 25 | + | func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, seq uint64, data []byte) { |
| 26 | if !effect.current() { | 26 | if !effect.current() { | ||
| 27 | return | 27 | return | ||
| 28 | } | 28 | } | ||
| 29 | d.handleInputForAttachment(effect, data) | 29 | d.handleInputForAttachment(effect, data) | ||
| 30 | + | d.noteInputApplied(effect.ac, seq) | |||
| 30 | } | 31 | } | ||
| 31 | 32 | ||||
| 32 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | 33 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | ||
M internal/usecase/daemon/resume.go +1 -0
| @@ -627,6 +627,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||
| 627 | 627 | // abandoned transport's output chain before binding the replacement so the | |
| 628 | 628 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 629 | 629 | ac.rebaseOutput() | |
| 630 | + | ac.resetEchoAck() | |
| 630 | 631 | ac.output.setWindow(h.MaxOutputInFlight) | |
| 631 | 632 | ac.replaceTransport(tr) | |
| 632 | 633 | // A parked attachment can resume through any route. Reapply the declared | |
| @@ -627,6 +627,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||||
| 627 | // abandoned transport's output chain before binding the replacement so the | 627 | // abandoned transport's output chain before binding the replacement so the | ||
| 628 | // mandatory first paint cannot be blocked by ACKs that died with the link. | 628 | // mandatory first paint cannot be blocked by ACKs that died with the link. | ||
| 629 | ac.rebaseOutput() | 629 | ac.rebaseOutput() | ||
| 630 | + | ac.resetEchoAck() | |||
| 630 | ac.output.setWindow(h.MaxOutputInFlight) | 631 | ac.output.setWindow(h.MaxOutputInFlight) | ||
| 631 | ac.replaceTransport(tr) | 632 | ac.replaceTransport(tr) | ||
| 632 | // A parked attachment can resume through any route. Reapply the declared | 633 | // A parked attachment can resume through any route. Reapply the declared | ||