perf(web): update vev-vt to v0.6.0 and coalesce browser updates (#3)
## Summary Update vev-vt to v0.6.0 and adapt the browser terminal to it. - **vev-vt v0.6.0**: HTML update schema v2 sends rows as text runs instead of one entry per cell, plus an allocation pass across the VT pipeline. - **webterm**: queued updates are merged with `applyAll`, so each row is rebuilt once per frame. Presents are capped at about 30 per second; the first update after a pause still goes out on the next frame, so typing latency is unchanged. - **Benchmark**: `scripts/web-scroll-bench.cjs` measures a fast history scroll in Chromium (WebSocket bytes and main-thread time). Usage is in `docs/web-terminal.md`. ## Before / after `main` (f449bae6, vev-vt `6ae9bc5f`) against this branch. Median of 3 runs each. ### Browser: fast history scroll 10,000 colored lines, 120 wheel notches (half up, half down), headless Chromium. | | 189×57 before | 189×57 after | 243×71 at 125% before | 243×71 at 125% after | |---|---:|---:|---:|---:| | Data sent over WebSocket | 64.1 MiB | 11.5 MiB (**−82%**) | 94.9 MiB | 16.2 MiB (**−83%**) | | Average update size | 472 KiB | 46 KiB (**10×**) | 759 KiB | 61 KiB (**12×**) | | Main-thread busy time | 8.35 s | 2.28 s (**3.7×**) | 11.3 s | 3.6 s (**3.1×**) | | — of which JavaScript | 3.86 s | 0.81 s | 5.13 s | 1.24 s | | — style + layout + paint | 4.16 s | 1.32 s | 5.14 s | 2.20 s | | Busy time per wheel notch | 69.6 ms | 19.0 ms | 94.3 ms | 30.0 ms | Sizes count JSON characters received by the page. The new build sends about twice as many updates, each 10–12× smaller: the browser now keeps up and presents more intermediate frames instead of falling behind. Headless Chromium has no GPU compositor, so paint is underestimated. On a real GPU at 243×96, the 30 fps cap alone cut main-thread time by a further 11–17%. ### Go: vev-vt renderers and VT (`benchstat`, n=8 and n=6) | Benchmark | Before | After | Change | |---|---:|---:|---:| | HTML snapshot 240×80 | 5.68 ms, 10.2 MB, 19,511 allocs | 0.90 ms, 483 KB, 38 allocs | **−84% time** | | HTML one changed row 240×80 | 2.07 ms, 6.9 MB | 0.48 ms, 38 KB | **−77% time, −99% bytes** | | HTML renderer, geomean of 6 sizes | 939 µs | 196 µs | **−79%** | | ANSI one-cell incremental draw | 11.3 µs, 83 KB | 1.35 µs, 80 B | **−88%** | | Shell redraw burst | 878 µs, 83 KB | 525 µs, 0 B | **−40%** | | History build 10K×120 (styled) | 138 ms, 142 MB | 49 ms, 22 MB | **−65%** | | VT and history, geomean | 4.41 ms | 2.57 ms | **−42%** | ANSI output bytes are identical before and after. ## Checks - `make lint`, `go test ./... -race` - vev-vt v0.6.0 CI: Go tests, lint, and the browser harness on Chromium, Firefox and WebKit ## Reproduce ```sh go build -o /tmp/vev-new . PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=/tmp/vev-new \ node scripts/web-scroll-bench.cjs --viewport 2048x1234 --scale 1.25 ``` Build `main` the same way and compare. Go numbers come from `go test -bench` in vev-vt; see its `docs/performance.md`. Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/3
- commit
- c37833be6fb98d52dfa83a25cea552254f4dce9f
- author
- brice · · 2026-10-02 18:49 UTC
- committer
- bnema · · 2026-10-02 18:49 UTC
- parent
- f449bae
- signature
- unsigned
- refs
- tag: v0.5.0
7 files · +192 -15
M docs/web-terminal.md +25 -0
| @@ -81,3 +81,28 @@ VEV_ENV=web-visual .dev/vev-web ls | |||
| 81 | 81 | Keep the printed link out of screenshots. | |
| 82 | 82 | ||
| 83 | 83 | `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access. | |
| 84 | + | ||
| 85 | + | ## Measuring scroll performance | |
| 86 | + | ||
| 87 | + | `scripts/web-scroll-bench.cjs` measures how much browser work a fast history scroll costs. It starts its own gateway in a temporary profile, prints 10,000 colored lines, scrolls up and back down with the mouse wheel, and sums the page's main-thread time from a Chromium trace. It stops the gateway and deletes the profile when it ends. Linux only. | |
| 88 | + | ||
| 89 | + | ```sh | |
| 90 | + | go build -o .dev/vev-web . | |
| 91 | + | PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=$PWD/.dev/vev-web \ | |
| 92 | + | node scripts/web-scroll-bench.cjs --viewport 1638x987 --scale 1.25 | |
| 93 | + | ``` | |
| 94 | + | ||
| 95 | + | | Option | Default | Meaning | | |
| 96 | + | |---|---|---| | |
| 97 | + | | `--lines` | 10000 | History lines printed before scrolling | | |
| 98 | + | | `--notches` | 120 | Wheel notches: half up, half down, one every 16 ms | | |
| 99 | + | | `--port` | 8799 | Gateway port on 127.0.0.1 | | |
| 100 | + | | `--viewport` | 1600x1000 | Page size in CSS pixels (headless only) | | |
| 101 | + | | `--scale` | 1 | Device pixel ratio, for example `1.25` for 125% zoom (headless only) | | |
| 102 | + | | `--cdp` | | Use a running Chromium instead of headless, for example `http://127.0.0.1:9222` | | |
| 103 | + | ||
| 104 | + | The output is JSON. `wireFrames`, `wireKB` and `avgFrameKB` count the WebSocket updates the page received during the scroll. `totalMs` is the main thread's busy time; `scriptMs`, `styleMs`, `layoutMs` and `paintMs` split it. `totalMsPerNotch` above 16 means the browser cannot keep up with a 60 Hz wheel. Compare two builds with the same options and run each a few times: the numbers vary by about 10%. | |
| 105 | + | ||
| 106 | + | Headless Chromium has no GPU compositor, so it underestimates paint. For numbers that match what users see, start Chromium with `--remote-debugging-port=9222`, keep the benchmark tab in the foreground, and pass `--cdp`. The window size and zoom then come from that browser. Close it afterwards: the debugging port lets any local process control it. | |
| 107 | + | ||
| 108 | + | To try a frontend change without rebuilding, set `WEB_APP_JS` or `WEB_RUNTIME_JS` to a local `app.js` or `terminal.js`. | |
| @@ -81,3 +81,28 @@ VEV_ENV=web-visual .dev/vev-web ls | |||||
| 81 | Keep the printed link out of screenshots. | 81 | Keep the printed link out of screenshots. | ||
| 82 | 82 | ||||
| 83 | `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access. | 83 | `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access. | ||
| 84 | + | ||||
| 85 | + | ## Measuring scroll performance | |||
| 86 | + | ||||
| 87 | + | `scripts/web-scroll-bench.cjs` measures how much browser work a fast history scroll costs. It starts its own gateway in a temporary profile, prints 10,000 colored lines, scrolls up and back down with the mouse wheel, and sums the page's main-thread time from a Chromium trace. It stops the gateway and deletes the profile when it ends. Linux only. | |||
| 88 | + | ||||
| 89 | + | ```sh | |||
| 90 | + | go build -o .dev/vev-web . | |||
| 91 | + | PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=$PWD/.dev/vev-web \ | |||
| 92 | + | node scripts/web-scroll-bench.cjs --viewport 1638x987 --scale 1.25 | |||
| 93 | + | ``` | |||
| 94 | + | ||||
| 95 | + | | Option | Default | Meaning | | |||
| 96 | + | |---|---|---| | |||
| 97 | + | | `--lines` | 10000 | History lines printed before scrolling | | |||
| 98 | + | | `--notches` | 120 | Wheel notches: half up, half down, one every 16 ms | | |||
| 99 | + | | `--port` | 8799 | Gateway port on 127.0.0.1 | | |||
| 100 | + | | `--viewport` | 1600x1000 | Page size in CSS pixels (headless only) | | |||
| 101 | + | | `--scale` | 1 | Device pixel ratio, for example `1.25` for 125% zoom (headless only) | | |||
| 102 | + | | `--cdp` | | Use a running Chromium instead of headless, for example `http://127.0.0.1:9222` | | |||
| 103 | + | ||||
| 104 | + | The output is JSON. `wireFrames`, `wireKB` and `avgFrameKB` count the WebSocket updates the page received during the scroll. `totalMs` is the main thread's busy time; `scriptMs`, `styleMs`, `layoutMs` and `paintMs` split it. `totalMsPerNotch` above 16 means the browser cannot keep up with a 60 Hz wheel. Compare two builds with the same options and run each a few times: the numbers vary by about 10%. | |||
| 105 | + | ||||
| 106 | + | Headless Chromium has no GPU compositor, so it underestimates paint. For numbers that match what users see, start Chromium with `--remote-debugging-port=9222`, keep the benchmark tab in the foreground, and pass `--cdp`. The window size and zoom then come from that browser. Close it afterwards: the debugging port lets any local process control it. | |||
| 107 | + | ||||
| 108 | + | To try a frontend change without rebuilding, set `WEB_APP_JS` or `WEB_RUNTIME_JS` to a local `app.js` or `terminal.js`. | |||
M go.mod +1 -1
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91 | |
| 6 | + | github.com/bnema/vev-vt v0.6.0 | |
| 7 | 7 | github.com/coder/websocket v1.8.15 | |
| 8 | 8 | github.com/quic-go/quic-go v0.62.0 | |
| 9 | 9 | github.com/stretchr/testify v1.12.1 | |
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91 | 6 | + | github.com/bnema/vev-vt v0.6.0 |
| 7 | github.com/coder/websocket v1.8.15 | 7 | github.com/coder/websocket v1.8.15 | ||
| 8 | github.com/quic-go/quic-go v0.62.0 | 8 | github.com/quic-go/quic-go v0.62.0 | ||
| 9 | github.com/stretchr/testify v1.12.1 | 9 | github.com/stretchr/testify v1.12.1 | ||
M go.sum +2 -4 generated file: collapsed
M internal/adapters/webterm/app.js +18 -6
| @@ -134,18 +134,30 @@ | |||
| 134 | 134 | status.textContent = 'Connecting…'; | |
| 135 | 135 | reconnect.hidden = true; | |
| 136 | 136 | // Presentation coalescing: at most one scheduled present task per | |
| 137 | - | // animation frame, preserving every incremental update in order. Each | |
| 138 | - | // queued delta is still applied in order; never drops intermediate | |
| 139 | - | // deltas. requestAnimationFrame is throttled to zero in hidden pages, | |
| 140 | - | // so a timeout fallback keeps the terminal live there. | |
| 137 | + | // animation frame. applyAll validates every queued update, then merges | |
| 138 | + | // them so each changed row is rebuilt once per frame. requestAnimationFrame | |
| 139 | + | // is throttled to zero in hidden pages, so a timeout fallback keeps the | |
| 140 | + | // terminal live there. | |
| 141 | + | // Presents are capped at about 30 per second: a full-screen redraw (fast | |
| 142 | + | // history scroll) costs the browser more style, layout and paint than one | |
| 143 | + | // 60 Hz frame allows. The first update after a pause is presented on the | |
| 144 | + | // next frame, so typing latency is unchanged. | |
| 145 | + | const minPresentMs = 32; | |
| 141 | 146 | let queuedUpdates = []; | |
| 142 | 147 | let presentScheduled = false; | |
| 143 | - | function flushUpdates() { | |
| 148 | + | let lastPresent = -Infinity; | |
| 149 | + | function flushUpdates(now = performance.now()) { | |
| 150 | + | if (now - lastPresent < minPresentMs) { | |
| 151 | + | if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates); | |
| 152 | + | else setTimeout(flushUpdates, minPresentMs - (now - lastPresent)); | |
| 153 | + | return; | |
| 154 | + | } | |
| 155 | + | lastPresent = now; | |
| 144 | 156 | presentScheduled = false; | |
| 145 | 157 | const batch = queuedUpdates; | |
| 146 | 158 | queuedUpdates = []; | |
| 147 | 159 | try { | |
| 148 | - | for (const update of batch) terminal.apply(update); | |
| 160 | + | terminal.applyAll(batch); | |
| 149 | 161 | } catch { | |
| 150 | 162 | status.textContent = 'Invalid terminal update — connection stopped.'; | |
| 151 | 163 | socket.close(); | |
| @@ -134,18 +134,30 @@ | |||||
| 134 | status.textContent = 'Connecting…'; | 134 | status.textContent = 'Connecting…'; | ||
| 135 | reconnect.hidden = true; | 135 | reconnect.hidden = true; | ||
| 136 | // Presentation coalescing: at most one scheduled present task per | 136 | // Presentation coalescing: at most one scheduled present task per | ||
| 137 | - | // animation frame, preserving every incremental update in order. Each | 137 | + | // animation frame. applyAll validates every queued update, then merges |
| 138 | - | // queued delta is still applied in order; never drops intermediate | 138 | + | // them so each changed row is rebuilt once per frame. requestAnimationFrame |
| 139 | - | // deltas. requestAnimationFrame is throttled to zero in hidden pages, | 139 | + | // is throttled to zero in hidden pages, so a timeout fallback keeps the |
| 140 | - | // so a timeout fallback keeps the terminal live there. | 140 | + | // terminal live there. |
| 141 | + | // Presents are capped at about 30 per second: a full-screen redraw (fast | |||
| 142 | + | // history scroll) costs the browser more style, layout and paint than one | |||
| 143 | + | // 60 Hz frame allows. The first update after a pause is presented on the | |||
| 144 | + | // next frame, so typing latency is unchanged. | |||
| 145 | + | const minPresentMs = 32; | |||
| 141 | let queuedUpdates = []; | 146 | let queuedUpdates = []; | ||
| 142 | let presentScheduled = false; | 147 | let presentScheduled = false; | ||
| 143 | - | function flushUpdates() { | 148 | + | let lastPresent = -Infinity; |
| 149 | + | function flushUpdates(now = performance.now()) { | |||
| 150 | + | if (now - lastPresent < minPresentMs) { | |||
| 151 | + | if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates); | |||
| 152 | + | else setTimeout(flushUpdates, minPresentMs - (now - lastPresent)); | |||
| 153 | + | return; | |||
| 154 | + | } | |||
| 155 | + | lastPresent = now; | |||
| 144 | presentScheduled = false; | 156 | presentScheduled = false; | ||
| 145 | const batch = queuedUpdates; | 157 | const batch = queuedUpdates; | ||
| 146 | queuedUpdates = []; | 158 | queuedUpdates = []; | ||
| 147 | try { | 159 | try { | ||
| 148 | - | for (const update of batch) terminal.apply(update); | 160 | + | terminal.applyAll(batch); |
| 149 | } catch { | 161 | } catch { | ||
| 150 | status.textContent = 'Invalid terminal update — connection stopped.'; | 162 | status.textContent = 'Invalid terminal update — connection stopped.'; | ||
| 151 | socket.close(); | 163 | socket.close(); | ||
M internal/adapters/webterm/server_test.go +5 -2
| @@ -3,6 +3,7 @@ package webterm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/http/httptest" | |
| @@ -11,6 +12,7 @@ import ( | |||
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 15 | + | "github.com/bnema/vev-vt/html/browser" | |
| 14 | 16 | "github.com/coder/websocket" | |
| 15 | 17 | "github.com/stretchr/testify/require" | |
| 16 | 18 | ) | |
| @@ -115,11 +117,12 @@ func TestWebSocketRoundTripAndDetach(t *testing.T) { | |||
| 115 | 117 | _, data, err := conn.Read(ctx) | |
| 116 | 118 | require.NoError(t, err) | |
| 117 | 119 | require.True(t, json.Valid(data)) | |
| 118 | - | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`))) | |
| 120 | + | event := fmt.Sprintf(`{"schemaVersion":%d,"type":"text","text":"Z"}`, browser.EventSchemaVersion) | |
| 121 | + | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(event))) | |
| 119 | 122 | for { | |
| 120 | 123 | _, data, err = conn.Read(ctx) | |
| 121 | 124 | require.NoError(t, err) | |
| 122 | - | if strings.Contains(string(data), `"text":"Z"`) { | |
| 125 | + | if strings.Contains(string(data), `"text":"Z`) { | |
| 123 | 126 | break | |
| 124 | 127 | } | |
| 125 | 128 | } | |
| @@ -3,6 +3,7 @@ package webterm | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| @@ -11,6 +12,7 @@ import ( | |||||
| 11 | "testing" | 12 | "testing" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | 14 | ||||
| 15 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 14 | "github.com/coder/websocket" | 16 | "github.com/coder/websocket" | ||
| 15 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 18 | ) | ||
| @@ -115,11 +117,12 @@ func TestWebSocketRoundTripAndDetach(t *testing.T) { | |||||
| 115 | _, data, err := conn.Read(ctx) | 117 | _, data, err := conn.Read(ctx) | ||
| 116 | require.NoError(t, err) | 118 | require.NoError(t, err) | ||
| 117 | require.True(t, json.Valid(data)) | 119 | require.True(t, json.Valid(data)) | ||
| 118 | - | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`))) | 120 | + | event := fmt.Sprintf(`{"schemaVersion":%d,"type":"text","text":"Z"}`, browser.EventSchemaVersion) |
| 121 | + | require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(event))) | |||
| 119 | for { | 122 | for { | ||
| 120 | _, data, err = conn.Read(ctx) | 123 | _, data, err = conn.Read(ctx) | ||
| 121 | require.NoError(t, err) | 124 | require.NoError(t, err) | ||
| 122 | - | if strings.Contains(string(data), `"text":"Z"`) { | 125 | + | if strings.Contains(string(data), `"text":"Z`) { |
| 123 | break | 126 | break | ||
| 124 | } | 127 | } | ||
| 125 | } | 128 | } | ||
M internal/app/web_daemon_broker_test.go +3 -2
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "testing" | |
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | + | "github.com/bnema/vev-vt/html/browser" | |
| 18 | 19 | "github.com/coder/websocket" | |
| 19 | 20 | "github.com/stretchr/testify/require" | |
| 20 | 21 | ||
| @@ -190,7 +191,7 @@ func (tab *webBrowserTab) close() { | |||
| 190 | 191 | // sendText types one browser text event at the tab. | |
| 191 | 192 | func (tab *webBrowserTab) sendText(t *testing.T, text string) { | |
| 192 | 193 | t.Helper() | |
| 193 | - | payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "text", "text": text}) | |
| 194 | + | payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "text", "text": text}) | |
| 194 | 195 | require.NoError(t, err) | |
| 195 | 196 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | |
| 196 | 197 | } | |
| @@ -199,7 +200,7 @@ func (tab *webBrowserTab) sendText(t *testing.T, text string) { | |||
| 199 | 200 | // A text event deliberately cannot carry a control byte. | |
| 200 | 201 | func (tab *webBrowserTab) sendEnter(t *testing.T) { | |
| 201 | 202 | t.Helper() | |
| 202 | - | payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "key", "key": "Enter", "code": "Enter"}) | |
| 203 | + | payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "key", "key": "Enter", "code": "Enter"}) | |
| 203 | 204 | require.NoError(t, err) | |
| 204 | 205 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | |
| 205 | 206 | } | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "testing" | 15 | "testing" | ||
| 16 | "time" | 16 | "time" | ||
| 17 | 17 | ||||
| 18 | + | "github.com/bnema/vev-vt/html/browser" | |||
| 18 | "github.com/coder/websocket" | 19 | "github.com/coder/websocket" | ||
| 19 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 20 | 21 | ||||
| @@ -190,7 +191,7 @@ func (tab *webBrowserTab) close() { | |||||
| 190 | // sendText types one browser text event at the tab. | 191 | // sendText types one browser text event at the tab. | ||
| 191 | func (tab *webBrowserTab) sendText(t *testing.T, text string) { | 192 | func (tab *webBrowserTab) sendText(t *testing.T, text string) { | ||
| 192 | t.Helper() | 193 | t.Helper() | ||
| 193 | - | payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "text", "text": text}) | 194 | + | payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "text", "text": text}) |
| 194 | require.NoError(t, err) | 195 | require.NoError(t, err) | ||
| 195 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | 196 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | ||
| 196 | } | 197 | } | ||
| @@ -199,7 +200,7 @@ func (tab *webBrowserTab) sendText(t *testing.T, text string) { | |||||
| 199 | // A text event deliberately cannot carry a control byte. | 200 | // A text event deliberately cannot carry a control byte. | ||
| 200 | func (tab *webBrowserTab) sendEnter(t *testing.T) { | 201 | func (tab *webBrowserTab) sendEnter(t *testing.T) { | ||
| 201 | t.Helper() | 202 | t.Helper() | ||
| 202 | - | payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "key", "key": "Enter", "code": "Enter"}) | 203 | + | payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "key", "key": "Enter", "code": "Enter"}) |
| 203 | require.NoError(t, err) | 204 | require.NoError(t, err) | ||
| 204 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | 205 | require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload)) | ||
| 205 | } | 206 | } | ||
A scripts/web-scroll-bench.cjs +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | // Browser scroll benchmark for the web terminal. Starts an isolated gateway, | |
| 2 | + | // fills history, scrolls it fast with the wheel and reports where the | |
| 3 | + | // browser's main thread spent its time. Linux only. See docs/web-terminal.md. | |
| 4 | + | const { spawnSync } = require('node:child_process'); | |
| 5 | + | const fs = require('node:fs'); | |
| 6 | + | const os = require('node:os'); | |
| 7 | + | const path = require('node:path'); | |
| 8 | + | ||
| 9 | + | const options = { lines: 10000, notches: 120, port: 8799, viewport: '1600x1000', scale: 1, cdp: '' }; | |
| 10 | + | for (let i = 2; i < process.argv.length; i += 2) { | |
| 11 | + | const key = process.argv[i].replace(/^--/, ''); | |
| 12 | + | if (!(key in options) || process.argv[i + 1] === undefined) { | |
| 13 | + | console.error('usage: web-scroll-bench.cjs [--lines N] [--notches N] [--port N] [--viewport WxH] [--scale F] [--cdp URL]'); | |
| 14 | + | process.exit(2); | |
| 15 | + | } | |
| 16 | + | options[key] = typeof options[key] === 'number' ? Number(process.argv[i + 1]) : process.argv[i + 1]; | |
| 17 | + | } | |
| 18 | + | if (!process.env.VEV_BINARY) { | |
| 19 | + | console.error('VEV_BINARY is required'); | |
| 20 | + | process.exit(2); | |
| 21 | + | } | |
| 22 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |
| 23 | + | ||
| 24 | + | // Main-thread trace events, summed per phase. | |
| 25 | + | const PHASES = { | |
| 26 | + | total: ['RunTask'], | |
| 27 | + | script: ['FunctionCall', 'EvaluateScript', 'v8.callFunction'], | |
| 28 | + | style: ['UpdateLayoutTree'], | |
| 29 | + | layout: ['Layout'], | |
| 30 | + | paint: ['Paint', 'PaintImage', 'RasterTask'], | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | // Kill every process started under root (session daemon and gateway). | |
| 34 | + | function stopProcesses(root) { | |
| 35 | + | for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) { | |
| 36 | + | try { | |
| 37 | + | const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0'); | |
| 38 | + | if (environ.includes(`VEV_ENV_ROOT=${root}`)) process.kill(Number(pid), 'SIGTERM'); | |
| 39 | + | } catch {} | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | (async () => { | |
| 44 | + | const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vev-scroll-bench-')); | |
| 45 | + | const env = { ...process.env, VEV_ENV: 'scroll-bench', VEV_ENV_ROOT: root, SHELL: '/bin/bash' }; | |
| 46 | + | const origin = `http://127.0.0.1:${options.port}`; | |
| 47 | + | let browser; | |
| 48 | + | try { | |
| 49 | + | const started = spawnSync(process.env.VEV_BINARY, ['--web-daemon', '--web-listen', `127.0.0.1:${options.port}`, '--web-origin', origin], { cwd: root, env, encoding: 'utf8', timeout: 20000 }); | |
| 50 | + | const token = started.stdout?.match(/#token=([A-Za-z0-9_-]+)/)?.[1]; | |
| 51 | + | if (!token) throw new Error(`gateway did not start: ${started.stderr || started.error}`); | |
| 52 | + | ||
| 53 | + | const [width, height] = options.viewport.split('x').map(Number); | |
| 54 | + | let page; | |
| 55 | + | if (options.cdp) { | |
| 56 | + | browser = await chromium.connectOverCDP(options.cdp); | |
| 57 | + | page = await browser.contexts()[0].newPage(); | |
| 58 | + | await page.bringToFront(); | |
| 59 | + | } else { | |
| 60 | + | browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |
| 61 | + | page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: options.scale }); | |
| 62 | + | } | |
| 63 | + | if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({ path: process.env.WEB_APP_JS, contentType: 'text/javascript' })); | |
| 64 | + | if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({ path: process.env.WEB_RUNTIME_JS, contentType: 'text/javascript' })); | |
| 65 | + | ||
| 66 | + | // Count WebSocket frames and bytes received by the page. | |
| 67 | + | await page.addInitScript(() => { | |
| 68 | + | const NativeWebSocket = window.WebSocket; | |
| 69 | + | window.wire = { frames: 0, bytes: 0 }; | |
| 70 | + | window.WebSocket = class extends NativeWebSocket { | |
| 71 | + | constructor(...args) { | |
| 72 | + | super(...args); | |
| 73 | + | this.addEventListener('message', event => { | |
| 74 | + | window.wire.frames += 1; | |
| 75 | + | window.wire.bytes += typeof event.data === 'string' ? event.data.length : event.data.byteLength ?? event.data.size; | |
| 76 | + | }); | |
| 77 | + | } | |
| 78 | + | }; | |
| 79 | + | }); | |
| 80 | + | await page.goto(`${origin}/#token=${token}`); | |
| 81 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |
| 82 | + | await page.waitForTimeout(1000); | |
| 83 | + | await page.locator('#terminal').click(); | |
| 84 | + | // Colored, styled and wide content, close to a real TUI. | |
| 85 | + | await page.keyboard.type(`for i in $(seq 1 ${options.lines}); do printf '\\e[3%dm%05d\\e[0m │ \\e[1mlorem\\e[0m ipsum \\e[48;5;236mdolor sit\\e[0m amet ✓ consectetur … │ %s\\n' $((i%7+1)) $i "$(printf '%*s' $((i%40)) x)"; done; echo bench-$((6*7))-ready\n`, { delay: 2 }); | |
| 86 | + | await page.waitForFunction(() => document.querySelector('.vev-terminal__accessible-output').textContent.includes('bench-42-ready'), null, { timeout: 120000 }); | |
| 87 | + | const geometry = await page.evaluate(() => { | |
| 88 | + | const style = getComputedStyle(document.querySelector('.vev-terminal')); | |
| 89 | + | return { columns: Number(style.getPropertyValue('--vev-cols')), rows: Number(style.getPropertyValue('--vev-rows')), viewport: [innerWidth, innerHeight], scale: devicePixelRatio }; | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | const box = await page.locator('#terminal').boundingBox(); | |
| 93 | + | await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); | |
| 94 | + | const cdp = await page.context().newCDPSession(page); | |
| 95 | + | const events = []; | |
| 96 | + | cdp.on('Tracing.dataCollected', event => events.push(...event.value)); | |
| 97 | + | const done = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve)); | |
| 98 | + | await cdp.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8'] } }); | |
| 99 | + | const wireBefore = await page.evaluate(() => ({ ...window.wire })); | |
| 100 | + | const start = Date.now(); | |
| 101 | + | // Half the notches scroll up into history, half come back down. | |
| 102 | + | for (let i = 0; i < options.notches; i++) { | |
| 103 | + | await page.mouse.wheel(0, i < options.notches / 2 ? -100 : 100); | |
| 104 | + | await page.waitForTimeout(16); | |
| 105 | + | } | |
| 106 | + | await page.waitForTimeout(800); | |
| 107 | + | const wall = (Date.now() - start) / 1000; | |
| 108 | + | const wireAfter = await page.evaluate(() => ({ ...window.wire })); | |
| 109 | + | await cdp.send('Tracing.end'); | |
| 110 | + | await done; | |
| 111 | + | ||
| 112 | + | // The page's main thread is the one that ran Layout. | |
| 113 | + | const layout = events.find(event => event.name === 'Layout' && event.ph === 'X'); | |
| 114 | + | const frames = wireAfter.frames - wireBefore.frames; | |
| 115 | + | const bytes = wireAfter.bytes - wireBefore.bytes; | |
| 116 | + | const result = { | |
| 117 | + | ...geometry, notches: options.notches, wallSeconds: wall, | |
| 118 | + | wireFrames: frames, wireKB: Math.round(bytes / 1024), avgFrameKB: frames ? Number((bytes / frames / 1024).toFixed(1)) : 0 | |
| 119 | + | }; | |
| 120 | + | for (const [phase, names] of Object.entries(PHASES)) { | |
| 121 | + | let ms = 0; | |
| 122 | + | for (const event of events) { | |
| 123 | + | if (event.ph === 'X' && event.dur && event.pid === layout?.pid && event.tid === layout?.tid && names.includes(event.name)) ms += event.dur / 1000; | |
| 124 | + | } | |
| 125 | + | result[`${phase}Ms`] = Math.round(ms); | |
| 126 | + | } | |
| 127 | + | result.totalMsPerNotch = Number((result.totalMs / options.notches).toFixed(1)); | |
| 128 | + | console.log(JSON.stringify(result, null, 2)); | |
| 129 | + | await page.close(); | |
| 130 | + | } finally { | |
| 131 | + | if (browser) await browser.close().catch(() => {}); | |
| 132 | + | stopProcesses(root); | |
| 133 | + | fs.rmSync(root, { recursive: true, force: true }); | |
| 134 | + | } | |
| 135 | + | })().catch(error => { | |
| 136 | + | console.error(error); | |
| 137 | + | process.exit(1); | |
| 138 | + | }); | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | // Browser scroll benchmark for the web terminal. Starts an isolated gateway, | |||
| 2 | + | // fills history, scrolls it fast with the wheel and reports where the | |||
| 3 | + | // browser's main thread spent its time. Linux only. See docs/web-terminal.md. | |||
| 4 | + | const { spawnSync } = require('node:child_process'); | |||
| 5 | + | const fs = require('node:fs'); | |||
| 6 | + | const os = require('node:os'); | |||
| 7 | + | const path = require('node:path'); | |||
| 8 | + | ||||
| 9 | + | const options = { lines: 10000, notches: 120, port: 8799, viewport: '1600x1000', scale: 1, cdp: '' }; | |||
| 10 | + | for (let i = 2; i < process.argv.length; i += 2) { | |||
| 11 | + | const key = process.argv[i].replace(/^--/, ''); | |||
| 12 | + | if (!(key in options) || process.argv[i + 1] === undefined) { | |||
| 13 | + | console.error('usage: web-scroll-bench.cjs [--lines N] [--notches N] [--port N] [--viewport WxH] [--scale F] [--cdp URL]'); | |||
| 14 | + | process.exit(2); | |||
| 15 | + | } | |||
| 16 | + | options[key] = typeof options[key] === 'number' ? Number(process.argv[i + 1]) : process.argv[i + 1]; | |||
| 17 | + | } | |||
| 18 | + | if (!process.env.VEV_BINARY) { | |||
| 19 | + | console.error('VEV_BINARY is required'); | |||
| 20 | + | process.exit(2); | |||
| 21 | + | } | |||
| 22 | + | const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); | |||
| 23 | + | ||||
| 24 | + | // Main-thread trace events, summed per phase. | |||
| 25 | + | const PHASES = { | |||
| 26 | + | total: ['RunTask'], | |||
| 27 | + | script: ['FunctionCall', 'EvaluateScript', 'v8.callFunction'], | |||
| 28 | + | style: ['UpdateLayoutTree'], | |||
| 29 | + | layout: ['Layout'], | |||
| 30 | + | paint: ['Paint', 'PaintImage', 'RasterTask'], | |||
| 31 | + | }; | |||
| 32 | + | ||||
| 33 | + | // Kill every process started under root (session daemon and gateway). | |||
| 34 | + | function stopProcesses(root) { | |||
| 35 | + | for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) { | |||
| 36 | + | try { | |||
| 37 | + | const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0'); | |||
| 38 | + | if (environ.includes(`VEV_ENV_ROOT=${root}`)) process.kill(Number(pid), 'SIGTERM'); | |||
| 39 | + | } catch {} | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | (async () => { | |||
| 44 | + | const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vev-scroll-bench-')); | |||
| 45 | + | const env = { ...process.env, VEV_ENV: 'scroll-bench', VEV_ENV_ROOT: root, SHELL: '/bin/bash' }; | |||
| 46 | + | const origin = `http://127.0.0.1:${options.port}`; | |||
| 47 | + | let browser; | |||
| 48 | + | try { | |||
| 49 | + | const started = spawnSync(process.env.VEV_BINARY, ['--web-daemon', '--web-listen', `127.0.0.1:${options.port}`, '--web-origin', origin], { cwd: root, env, encoding: 'utf8', timeout: 20000 }); | |||
| 50 | + | const token = started.stdout?.match(/#token=([A-Za-z0-9_-]+)/)?.[1]; | |||
| 51 | + | if (!token) throw new Error(`gateway did not start: ${started.stderr || started.error}`); | |||
| 52 | + | ||||
| 53 | + | const [width, height] = options.viewport.split('x').map(Number); | |||
| 54 | + | let page; | |||
| 55 | + | if (options.cdp) { | |||
| 56 | + | browser = await chromium.connectOverCDP(options.cdp); | |||
| 57 | + | page = await browser.contexts()[0].newPage(); | |||
| 58 | + | await page.bringToFront(); | |||
| 59 | + | } else { | |||
| 60 | + | browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); | |||
| 61 | + | page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: options.scale }); | |||
| 62 | + | } | |||
| 63 | + | if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({ path: process.env.WEB_APP_JS, contentType: 'text/javascript' })); | |||
| 64 | + | if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({ path: process.env.WEB_RUNTIME_JS, contentType: 'text/javascript' })); | |||
| 65 | + | ||||
| 66 | + | // Count WebSocket frames and bytes received by the page. | |||
| 67 | + | await page.addInitScript(() => { | |||
| 68 | + | const NativeWebSocket = window.WebSocket; | |||
| 69 | + | window.wire = { frames: 0, bytes: 0 }; | |||
| 70 | + | window.WebSocket = class extends NativeWebSocket { | |||
| 71 | + | constructor(...args) { | |||
| 72 | + | super(...args); | |||
| 73 | + | this.addEventListener('message', event => { | |||
| 74 | + | window.wire.frames += 1; | |||
| 75 | + | window.wire.bytes += typeof event.data === 'string' ? event.data.length : event.data.byteLength ?? event.data.size; | |||
| 76 | + | }); | |||
| 77 | + | } | |||
| 78 | + | }; | |||
| 79 | + | }); | |||
| 80 | + | await page.goto(`${origin}/#token=${token}`); | |||
| 81 | + | await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected'); | |||
| 82 | + | await page.waitForTimeout(1000); | |||
| 83 | + | await page.locator('#terminal').click(); | |||
| 84 | + | // Colored, styled and wide content, close to a real TUI. | |||
| 85 | + | await page.keyboard.type(`for i in $(seq 1 ${options.lines}); do printf '\\e[3%dm%05d\\e[0m │ \\e[1mlorem\\e[0m ipsum \\e[48;5;236mdolor sit\\e[0m amet ✓ consectetur … │ %s\\n' $((i%7+1)) $i "$(printf '%*s' $((i%40)) x)"; done; echo bench-$((6*7))-ready\n`, { delay: 2 }); | |||
| 86 | + | await page.waitForFunction(() => document.querySelector('.vev-terminal__accessible-output').textContent.includes('bench-42-ready'), null, { timeout: 120000 }); | |||
| 87 | + | const geometry = await page.evaluate(() => { | |||
| 88 | + | const style = getComputedStyle(document.querySelector('.vev-terminal')); | |||
| 89 | + | return { columns: Number(style.getPropertyValue('--vev-cols')), rows: Number(style.getPropertyValue('--vev-rows')), viewport: [innerWidth, innerHeight], scale: devicePixelRatio }; | |||
| 90 | + | }); | |||
| 91 | + | ||||
| 92 | + | const box = await page.locator('#terminal').boundingBox(); | |||
| 93 | + | await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); | |||
| 94 | + | const cdp = await page.context().newCDPSession(page); | |||
| 95 | + | const events = []; | |||
| 96 | + | cdp.on('Tracing.dataCollected', event => events.push(...event.value)); | |||
| 97 | + | const done = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve)); | |||
| 98 | + | await cdp.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8'] } }); | |||
| 99 | + | const wireBefore = await page.evaluate(() => ({ ...window.wire })); | |||
| 100 | + | const start = Date.now(); | |||
| 101 | + | // Half the notches scroll up into history, half come back down. | |||
| 102 | + | for (let i = 0; i < options.notches; i++) { | |||
| 103 | + | await page.mouse.wheel(0, i < options.notches / 2 ? -100 : 100); | |||
| 104 | + | await page.waitForTimeout(16); | |||
| 105 | + | } | |||
| 106 | + | await page.waitForTimeout(800); | |||
| 107 | + | const wall = (Date.now() - start) / 1000; | |||
| 108 | + | const wireAfter = await page.evaluate(() => ({ ...window.wire })); | |||
| 109 | + | await cdp.send('Tracing.end'); | |||
| 110 | + | await done; | |||
| 111 | + | ||||
| 112 | + | // The page's main thread is the one that ran Layout. | |||
| 113 | + | const layout = events.find(event => event.name === 'Layout' && event.ph === 'X'); | |||
| 114 | + | const frames = wireAfter.frames - wireBefore.frames; | |||
| 115 | + | const bytes = wireAfter.bytes - wireBefore.bytes; | |||
| 116 | + | const result = { | |||
| 117 | + | ...geometry, notches: options.notches, wallSeconds: wall, | |||
| 118 | + | wireFrames: frames, wireKB: Math.round(bytes / 1024), avgFrameKB: frames ? Number((bytes / frames / 1024).toFixed(1)) : 0 | |||
| 119 | + | }; | |||
| 120 | + | for (const [phase, names] of Object.entries(PHASES)) { | |||
| 121 | + | let ms = 0; | |||
| 122 | + | for (const event of events) { | |||
| 123 | + | if (event.ph === 'X' && event.dur && event.pid === layout?.pid && event.tid === layout?.tid && names.includes(event.name)) ms += event.dur / 1000; | |||
| 124 | + | } | |||
| 125 | + | result[`${phase}Ms`] = Math.round(ms); | |||
| 126 | + | } | |||
| 127 | + | result.totalMsPerNotch = Number((result.totalMs / options.notches).toFixed(1)); | |||
| 128 | + | console.log(JSON.stringify(result, null, 2)); | |||
| 129 | + | await page.close(); | |||
| 130 | + | } finally { | |||
| 131 | + | if (browser) await browser.close().catch(() => {}); | |||
| 132 | + | stopProcesses(root); | |||
| 133 | + | fs.rmSync(root, { recursive: true, force: true }); | |||
| 134 | + | } | |||
| 135 | + | })().catch(error => { | |||
| 136 | + | console.error(error); | |||
| 137 | + | process.exit(1); | |||
| 138 | + | }); | |||