Skip to content

Commit 6ba41bd

Browse files
rgarciacursoragent
andauthored
Disable CDP proxy compression (#256)
## Summary - Disable websocket permessage-deflate for both legs of the CDP proxy. - Add an e2e benchmark for repeated `Page.captureScreenshot` calls through the built headless image. ## Benchmark results Command: ```sh go test -run '^$' -bench '^BenchmarkCDPCaptureScreenshot$' -benchtime=20x -count=1 -v ./e2e ``` Image was rebuilt before each benchmark: ```sh DOCKER_BUILDKIT=1 docker build -f images/chromium-headless/image/Dockerfile -t onkernel/chromium-headless-test:latest . ``` | Proxy compression | Result | Throughput | Payload | | --- | ---: | ---: | ---: | | Enabled (`CompressionContextTakeover`) | `302.5ms/op` | `4.674 screenshot_MiB/s` | `1,482,564 screenshot_bytes/op` | | Disabled (`CompressionDisabled`) | `236.7ms/op` | `5.974 screenshot_MiB/s` | `1,482,564 screenshot_bytes/op` | Disabling compression improved this screenshot benchmark by roughly 22% latency / 1.28x throughput. ## Test plan - `go test -run '^$' -bench '^$' ./e2e` - `go test ./lib/devtoolsproxy` - Rebuilt `onkernel/chromium-headless-test:latest` - Ran `BenchmarkCDPCaptureScreenshot` before and after disabling proxy compression Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Proxy-only transport change with no auth or API surface changes; tradeoff is higher wire size on compressible traffic in exchange for lower CPU/latency on large CDP messages. > > **Overview** > Turns off **permessage-deflate** on both the client-facing and upstream WebSocket legs in `devtoolsproxy` (`CompressionDisabled` instead of `CompressionContextTakeover`), so CDP traffic—including large base64 screenshot payloads—is no longer compressed in the proxy path. > > Adds **`BenchmarkCDPCaptureScreenshot`** in e2e: Docker headless container, CDP target setup, repeated `Page.captureScreenshot`, and custom metrics (`screenshot_bytes/op`, `screenshot_MiB/s`) to track proxy screenshot performance over time. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ccc0fc6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2b16ce0 commit 6ba41bd

2 files changed

Lines changed: 223 additions & 2 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"net/url"
9+
"os/exec"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// BenchmarkCDPCaptureScreenshot measures Page.captureScreenshot latency through
18+
// the CDP proxy. Run with:
19+
// go test -run '^$' -bench BenchmarkCDPCaptureScreenshot -benchtime=25x -count=1 -v ./e2e
20+
func BenchmarkCDPCaptureScreenshot(b *testing.B) {
21+
if _, err := exec.LookPath("docker"); err != nil {
22+
b.Skipf("docker not available: %v", err)
23+
}
24+
25+
runCDPCaptureScreenshotBenchmark(b, headlessImage)
26+
}
27+
28+
func runCDPCaptureScreenshotBenchmark(b *testing.B, image string) {
29+
b.Helper()
30+
31+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
32+
defer cancel()
33+
34+
env := map[string]string{
35+
"WIDTH": "1024",
36+
"HEIGHT": "768",
37+
}
38+
39+
c := NewTestContainer(b, image)
40+
require.NoError(b, c.Start(ctx, ContainerConfig{Env: env}), "failed to start container")
41+
defer c.Stop(ctx)
42+
43+
require.NoError(b, c.WaitReady(ctx), "api not ready")
44+
require.NoError(b, c.WaitDevTools(ctx), "devtools not ready")
45+
46+
client, targetID, sessionID, err := setupScreenshotTarget(ctx, c.CDPURL())
47+
require.NoError(b, err, "failed to set up CDP screenshot target")
48+
defer client.Close()
49+
defer func() {
50+
closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second)
51+
defer closeCancel()
52+
_, _ = client.Call(closeCtx, "Target.closeTarget", map[string]any{"targetId": targetID}, "")
53+
}()
54+
55+
warmupBytes, err := captureScreenshotBytes(ctx, client, sessionID)
56+
require.NoError(b, err, "warmup screenshot failed")
57+
require.Greater(b, warmupBytes, 0, "warmup screenshot returned no data")
58+
59+
b.ReportAllocs()
60+
b.ResetTimer()
61+
62+
var totalPayloadBytes int64
63+
for i := 0; i < b.N; i++ {
64+
iterCtx, iterCancel := context.WithTimeout(ctx, 15*time.Second)
65+
screenshotBytes, err := captureScreenshotBytes(iterCtx, client, sessionID)
66+
iterCancel()
67+
if err != nil {
68+
b.Fatalf("capture screenshot %d failed: %v", i, err)
69+
}
70+
totalPayloadBytes += int64(screenshotBytes)
71+
}
72+
73+
b.StopTimer()
74+
75+
if b.N > 0 {
76+
avgBytes := float64(totalPayloadBytes) / float64(b.N)
77+
b.ReportMetric(avgBytes, "screenshot_bytes/op")
78+
b.ReportMetric(avgBytes/(1024*1024)/b.Elapsed().Seconds()*float64(b.N), "screenshot_MiB/s")
79+
b.Logf("[summary] image=%s iterations=%d avg_screenshot_bytes=%.0f", image, b.N, avgBytes)
80+
}
81+
}
82+
83+
func setupScreenshotTarget(ctx context.Context, wsURL string) (*cdpClient, string, string, error) {
84+
client, err := newCDPClient(ctx, wsURL)
85+
if err != nil {
86+
return nil, "", "", err
87+
}
88+
89+
targetRaw, err := client.Call(ctx, "Target.createTarget", map[string]any{"url": "about:blank"}, "")
90+
if err != nil {
91+
client.Close()
92+
return nil, "", "", fmt.Errorf("Target.createTarget: %w", err)
93+
}
94+
targetID, err := decodeJSONStringField(targetRaw, "targetId")
95+
if err != nil {
96+
client.Close()
97+
return nil, "", "", err
98+
}
99+
100+
attachRaw, err := client.Call(ctx, "Target.attachToTarget", map[string]any{
101+
"targetId": targetID,
102+
"flatten": true,
103+
}, "")
104+
if err != nil {
105+
client.Close()
106+
return nil, "", "", fmt.Errorf("Target.attachToTarget: %w", err)
107+
}
108+
sessionID, err := decodeJSONStringField(attachRaw, "sessionId")
109+
if err != nil {
110+
client.Close()
111+
return nil, "", "", err
112+
}
113+
114+
if _, err := client.Call(ctx, "Page.enable", map[string]any{}, sessionID); err != nil {
115+
client.Close()
116+
return nil, "", "", fmt.Errorf("Page.enable: %w", err)
117+
}
118+
if _, err := client.Call(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{
119+
"width": 1024,
120+
"height": 768,
121+
"deviceScaleFactor": 1,
122+
"mobile": false,
123+
}, sessionID); err != nil {
124+
client.Close()
125+
return nil, "", "", fmt.Errorf("Emulation.setDeviceMetricsOverride: %w", err)
126+
}
127+
128+
loadCtx, loadCancel := context.WithTimeout(ctx, 15*time.Second)
129+
defer loadCancel()
130+
loadDone := make(chan error, 1)
131+
go func() {
132+
loadDone <- client.WaitForEvent(loadCtx, "Page.loadEventFired", sessionID)
133+
}()
134+
135+
if _, err := client.Call(ctx, "Page.navigate", map[string]any{
136+
"url": "data:text/html," + url.PathEscape(screenshotBenchmarkHTML()),
137+
}, sessionID); err != nil {
138+
client.Close()
139+
return nil, "", "", fmt.Errorf("Page.navigate: %w", err)
140+
}
141+
if err := <-loadDone; err != nil {
142+
client.Close()
143+
return nil, "", "", fmt.Errorf("Page.loadEventFired: %w", err)
144+
}
145+
146+
_, err = client.Call(ctx, "Runtime.evaluate", map[string]any{
147+
"expression": `document.fonts ? document.fonts.ready.then(() => true) : true`,
148+
"awaitPromise": true,
149+
}, sessionID)
150+
if err != nil {
151+
client.Close()
152+
return nil, "", "", fmt.Errorf("Runtime.evaluate: %w", err)
153+
}
154+
155+
return client, targetID, sessionID, nil
156+
}
157+
158+
func captureScreenshotBytes(ctx context.Context, client *cdpClient, sessionID string) (int, error) {
159+
screenshotRaw, err := client.Call(ctx, "Page.captureScreenshot", map[string]any{
160+
"format": "png",
161+
"fromSurface": true,
162+
"captureBeyondViewport": false,
163+
}, sessionID)
164+
if err != nil {
165+
return 0, err
166+
}
167+
168+
var screenshotEnvelope struct {
169+
Data string `json:"data"`
170+
}
171+
if err := json.Unmarshal(screenshotRaw, &screenshotEnvelope); err != nil {
172+
return 0, err
173+
}
174+
if screenshotEnvelope.Data == "" {
175+
return 0, fmt.Errorf("empty screenshot data")
176+
}
177+
178+
return base64DecodedSize(screenshotEnvelope.Data), nil
179+
}
180+
181+
func base64DecodedSize(s string) int {
182+
decodedLen := base64.StdEncoding.DecodedLen(len(s))
183+
switch {
184+
case strings.HasSuffix(s, "=="):
185+
return decodedLen - 2
186+
case strings.HasSuffix(s, "="):
187+
return decodedLen - 1
188+
default:
189+
return decodedLen
190+
}
191+
}
192+
193+
func screenshotBenchmarkHTML() string {
194+
return `<!doctype html>
195+
<html>
196+
<head>
197+
<meta charset="utf-8">
198+
<style>
199+
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
200+
canvas { display: block; width: 1024px; height: 768px; }
201+
</style>
202+
</head>
203+
<body>
204+
<canvas id="c" width="1024" height="768"></canvas>
205+
<script>
206+
const canvas = document.getElementById("c");
207+
const ctx = canvas.getContext("2d");
208+
const image = ctx.createImageData(canvas.width, canvas.height);
209+
let state = 0x12345678;
210+
for (let i = 0; i < image.data.length; i += 4) {
211+
state = (1664525 * state + 1013904223) >>> 0;
212+
image.data[i] = state & 255;
213+
image.data[i + 1] = (state >>> 8) & 255;
214+
image.data[i + 2] = (state >>> 16) & 255;
215+
image.data[i + 3] = 255;
216+
}
217+
ctx.putImageData(image, 0, 0);
218+
</script>
219+
</body>
220+
</html>`
221+
}

server/lib/devtoolsproxy/proxy.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,10 +322,10 @@ func WebSocketProxyHandler(mgr *UpstreamManager, logger *slog.Logger, logCDPMess
322322

323323
acceptOpts := &websocket.AcceptOptions{
324324
OriginPatterns: []string{"*"},
325-
CompressionMode: websocket.CompressionContextTakeover,
325+
CompressionMode: websocket.CompressionDisabled,
326326
}
327327
dialOpts := &websocket.DialOptions{
328-
CompressionMode: websocket.CompressionContextTakeover,
328+
CompressionMode: websocket.CompressionDisabled,
329329
}
330330

331331
// Subscribe to upstream URL changes so we can tear down stale sessions

0 commit comments

Comments
 (0)