From 75a50395e7eb9a8b4a1b524d99c74065c1df3518 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Mon, 9 Mar 2026 18:52:59 -0400 Subject: [PATCH 1/9] feat: add CDP-based interactive live view for headless Chromium Add a lightweight Go service (cdp-live-view) that streams browser frames via CDP Page.startScreencast and forwards mouse/keyboard input via Input.dispatchMouseEvent/dispatchKeyEvent. This replaces the previous noVNC/Xvfb approach with near-zero overhead (~40 MB memory under load, negligible CPU impact) while keeping Chromium in true headless mode (--headless=new --ozone-platform=headless). The service is opt-in via ENABLE_LIVE_VIEW=true and serves an HTML5 canvas-based viewer on port 8080. Made-with: Cursor --- images/chromium-headless/image/Dockerfile | 7 + .../supervisor/services/cdp-live-view.conf | 7 + images/chromium-headless/image/wrapper.sh | 7 + images/chromium-headless/run-docker.sh | 2 + images/chromium-headless/run-unikernel.sh | 4 + server/cmd/cdp-live-view/main.go | 637 ++++++++++++++++++ server/cmd/cdp-live-view/viewer.html | 243 +++++++ 7 files changed, 907 insertions(+) create mode 100644 images/chromium-headless/image/supervisor/services/cdp-live-view.conf create mode 100644 server/cmd/cdp-live-view/main.go create mode 100644 server/cmd/cdp-live-view/viewer.html diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 7be9610e..1835e51e 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -28,6 +28,12 @@ RUN --mount=type=cache,target=/root/.cache/go-build,id=$CACHEIDPREFIX-go-build \ GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ go build -ldflags="-s -w" -o /out/chromium-launcher ./cmd/chromium-launcher +# Build CDP live view server +RUN --mount=type=cache,target=/root/.cache/go-build,id=$CACHEIDPREFIX-go-build \ + --mount=type=cache,target=/go/pkg/mod,id=$CACHEIDPREFIX-go-pkg-mod \ + GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -ldflags="-s -w" -o /out/cdp-live-view ./cmd/cdp-live-view + FROM docker.io/ubuntu:22.04 AS ffmpeg-downloader # Allow cross-compilation when building with BuildKit platforms @@ -237,6 +243,7 @@ RUN chmod +x /usr/local/bin/init-envoy.sh # Copy the kernel-images API binary built in the builder stage COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher +COPY --from=server-builder /out/cdp-live-view /usr/local/bin/cdp-live-view # Copy and compile the Playwright daemon COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts diff --git a/images/chromium-headless/image/supervisor/services/cdp-live-view.conf b/images/chromium-headless/image/supervisor/services/cdp-live-view.conf new file mode 100644 index 00000000..86346a29 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/cdp-live-view.conf @@ -0,0 +1,7 @@ +[program:cdp-live-view] +command=/usr/local/bin/cdp-live-view +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/cdp-live-view +redirect_stderr=true diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 7faff130..1b866900 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -216,6 +216,7 @@ cleanup () { echo "[wrapper] Cleaning up..." # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero + supervisorctl -c /etc/supervisor/supervisord.conf stop cdp-live-view || true supervisorctl -c /etc/supervisor/supervisord.conf stop chromedriver || true supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true @@ -274,6 +275,12 @@ echo "[wrapper] Starting ChromeDriver via supervisord" supervisorctl -c /etc/supervisor/supervisord.conf start chromedriver wait_for_tcp_port 127.0.0.1 9225 "ChromeDriver" 50 0.2 "10s" || true +if [[ "${ENABLE_LIVE_VIEW:-}" == "true" ]]; then + echo "[wrapper] Starting CDP live view via supervisord" + supervisorctl -c /etc/supervisor/supervisord.conf start cdp-live-view + wait_for_tcp_port 127.0.0.1 8080 "cdp-live-view" 50 0.2 "10s" || true +fi + echo "[wrapper] startup complete!" # Re-enable scale-to-zero once startup has completed (when not under Docker) if [[ -z "${WITHDOCKER:-}" ]]; then diff --git a/images/chromium-headless/run-docker.sh b/images/chromium-headless/run-docker.sh index 56f582bf..303ea32d 100755 --- a/images/chromium-headless/run-docker.sh +++ b/images/chromium-headless/run-docker.sh @@ -17,7 +17,9 @@ RUN_ARGS=( -p 9222:9222 -p 9224:9224 -p 444:10001 + -p 8080:8080 -v "$HOST_RECORDINGS_DIR:/recordings" + -e ENABLE_LIVE_VIEW="${ENABLE_LIVE_VIEW:-true}" ) if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then diff --git a/images/chromium-headless/run-unikernel.sh b/images/chromium-headless/run-unikernel.sh index b899ba4e..e7b3733b 100755 --- a/images/chromium-headless/run-unikernel.sh +++ b/images/chromium-headless/run-unikernel.sh @@ -25,4 +25,8 @@ deploy_args=( -n "$NAME" ) +if [[ "${ENABLE_LIVE_VIEW:-}" == "true" ]]; then + deploy_args+=( -e ENABLE_LIVE_VIEW=true -p 443:8080/http+tls ) +fi + kraft cloud inst create "${deploy_args[@]}" "$IMAGE" diff --git a/server/cmd/cdp-live-view/main.go b/server/cmd/cdp-live-view/main.go new file mode 100644 index 00000000..5fada7f1 --- /dev/null +++ b/server/cmd/cdp-live-view/main.go @@ -0,0 +1,637 @@ +package main + +import ( + "context" + "embed" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" +) + +//go:embed viewer.html +var viewerFS embed.FS + +type cdpMessage struct { + ID int `json:"id"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` +} + +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type cdpClient struct { + ws *websocket.Conn + nextID atomic.Int64 + mu sync.Mutex + + pending map[int]chan json.RawMessage + pendingMu sync.Mutex + + handlers map[string]func(json.RawMessage) + handlersMu sync.RWMutex + + log *slog.Logger +} + +func newCDPClient(ctx context.Context, url string, log *slog.Logger) (*cdpClient, error) { + ws, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + return nil, fmt.Errorf("dial CDP: %w", err) + } + ws.SetReadLimit(64 * 1024 * 1024) + + c := &cdpClient{ + ws: ws, + pending: make(map[int]chan json.RawMessage), + handlers: make(map[string]func(json.RawMessage)), + log: log, + } + c.nextID.Store(1) + go c.readLoop(ctx) + return c, nil +} + +func (c *cdpClient) readLoop(ctx context.Context) { + for { + _, data, err := c.ws.Read(ctx) + if err != nil { + c.log.Error("CDP read error", "error", err) + return + } + var msg cdpMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + + if msg.Method != "" { + c.handlersMu.RLock() + h, ok := c.handlers[msg.Method] + c.handlersMu.RUnlock() + if ok { + go h(msg.Params) + } + } + + if msg.ID > 0 { + c.pendingMu.Lock() + ch, ok := c.pending[msg.ID] + if ok { + delete(c.pending, msg.ID) + } + c.pendingMu.Unlock() + if ok { + if msg.Error != nil { + ch <- nil + } else { + ch <- msg.Result + } + } + } + } +} + +func (c *cdpClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { + id := int(c.nextID.Add(1)) + var rawParams json.RawMessage + if params != nil { + b, err := json.Marshal(params) + if err != nil { + return nil, err + } + rawParams = b + } + + msg, _ := json.Marshal(cdpMessage{ID: id, Method: method, Params: rawParams}) + + ch := make(chan json.RawMessage, 1) + c.pendingMu.Lock() + c.pending[id] = ch + c.pendingMu.Unlock() + + c.mu.Lock() + err := c.ws.Write(ctx, websocket.MessageText, msg) + c.mu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, err + } + + select { + case result := <-ch: + return result, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *cdpClient) callSession(ctx context.Context, sessionID, method string, params any) (json.RawMessage, error) { + id := int(c.nextID.Add(1)) + var rawParams json.RawMessage + if params != nil { + b, err := json.Marshal(params) + if err != nil { + return nil, err + } + rawParams = b + } + + type sessionMsg struct { + ID int `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + SessionID string `json:"sessionId"` + } + msg, _ := json.Marshal(sessionMsg{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) + + ch := make(chan json.RawMessage, 1) + c.pendingMu.Lock() + c.pending[id] = ch + c.pendingMu.Unlock() + + c.mu.Lock() + err := c.ws.Write(ctx, websocket.MessageText, msg) + c.mu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, err + } + + select { + case result := <-ch: + return result, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *cdpClient) onEvent(method string, handler func(json.RawMessage)) { + c.handlersMu.Lock() + c.handlers[method] = handler + c.handlersMu.Unlock() +} + +func (c *cdpClient) close() { + c.ws.Close(websocket.StatusNormalClosure, "") +} + +// viewer tracks a connected browser viewer. +type viewer struct { + ws *websocket.Conn + mu sync.Mutex + log *slog.Logger +} + +func (v *viewer) sendBinary(ctx context.Context, data []byte) error { + v.mu.Lock() + defer v.mu.Unlock() + return v.ws.Write(ctx, websocket.MessageBinary, data) +} + +// server orchestrates CDP connection and viewer connections. +type server struct { + cdpPort string + listenAddr string + quality int + width int + height int + log *slog.Logger + + ctx context.Context + viewers sync.Map + cdp *cdpClient + sessionID string + targetID string + cdpMu sync.Mutex + + // sessions tracks targetID -> sessionID for attached targets + sessions map[string]string + sessionsMu sync.Mutex +} + +func (s *server) discoverBrowserWSURL(ctx context.Context) (string, error) { + url := fmt.Sprintf("http://127.0.0.1:%s/json/version", s.cdpPort) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + var info struct { + WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return "", fmt.Errorf("decode /json/version: %w", err) + } + return info.WebSocketDebuggerUrl, nil +} + +func (s *server) connectCDP(ctx context.Context) error { + s.cdpMu.Lock() + defer s.cdpMu.Unlock() + + if s.cdp != nil { + return nil + } + + wsURL, err := s.discoverBrowserWSURL(ctx) + if err != nil { + return fmt.Errorf("discover browser WS URL: %w", err) + } + + s.log.Info("connecting to CDP", "url", wsURL) + cdp, err := newCDPClient(s.ctx, wsURL, s.log) + if err != nil { + return err + } + s.cdp = cdp + + // Handle target attachment responses + cdp.onEvent("Target.attachedToTarget", func(params json.RawMessage) { + var ev struct { + SessionID string `json:"sessionId"` + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfo"` + } + json.Unmarshal(params, &ev) + if ev.TargetInfo.Type == "page" { + s.sessionsMu.Lock() + s.sessions[ev.TargetInfo.TargetID] = ev.SessionID + s.sessionsMu.Unlock() + s.log.Info("target attached", "targetId", ev.TargetInfo.TargetID, "sessionId", ev.SessionID) + } + }) + + // Register screencast frame handler + cdp.onEvent("Page.screencastFrame", func(params json.RawMessage) { + s.handleScreencastFrame(s.ctx, params) + }) + + // When a new page target is created, auto-switch to it + cdp.onEvent("Target.targetCreated", func(params json.RawMessage) { + var ev struct { + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` + } `json:"targetInfo"` + } + json.Unmarshal(params, &ev) + if ev.TargetInfo.Type == "page" { + s.log.Info("new page target created", "targetId", ev.TargetInfo.TargetID, "url", ev.TargetInfo.URL) + go s.switchToTarget(s.ctx, ev.TargetInfo.TargetID) + } + }) + + // When a page navigates to a real URL, switch to it + cdp.onEvent("Target.targetInfoChanged", func(params json.RawMessage) { + var ev struct { + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` + } `json:"targetInfo"` + } + json.Unmarshal(params, &ev) + if ev.TargetInfo.Type == "page" && ev.TargetInfo.URL != "" && + ev.TargetInfo.URL != "about:blank" && ev.TargetInfo.URL != "chrome://newtab/" { + if ev.TargetInfo.TargetID != s.targetID { + s.log.Info("page navigated, switching", "targetId", ev.TargetInfo.TargetID, "url", ev.TargetInfo.URL) + go s.switchToTarget(s.ctx, ev.TargetInfo.TargetID) + } + } + }) + + // Enable target discovery + _, err = cdp.call(s.ctx, "Target.setDiscoverTargets", map[string]bool{"discover": true}) + if err != nil { + cdp.close() + s.cdp = nil + return fmt.Errorf("set discover targets: %w", err) + } + + // Find an initial page to attach to + result, err := cdp.call(s.ctx, "Target.getTargets", nil) + if err != nil { + cdp.close() + s.cdp = nil + return fmt.Errorf("get targets: %w", err) + } + + var targets struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` + } `json:"targetInfos"` + } + json.Unmarshal(result, &targets) + + for _, t := range targets.TargetInfos { + if t.Type == "page" { + s.log.Info("attaching to initial page", "targetId", t.TargetID, "url", t.URL) + if err := s.switchToTarget(s.ctx, t.TargetID); err != nil { + s.log.Error("failed to attach to initial page", "error", err) + } + break + } + } + + return nil +} + +func (s *server) switchToTarget(ctx context.Context, targetID string) error { + s.sessionsMu.Lock() + existingSession, alreadyAttached := s.sessions[targetID] + s.sessionsMu.Unlock() + + var sessionID string + + if alreadyAttached { + sessionID = existingSession + } else { + // Attach to the target + _, err := s.cdp.call(ctx, "Target.attachToTarget", map[string]any{ + "targetId": targetID, + "flatten": true, + }) + if err != nil { + return fmt.Errorf("attach to target %s: %w", targetID, err) + } + + // Wait for the session to appear + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + s.sessionsMu.Lock() + sid, ok := s.sessions[targetID] + s.sessionsMu.Unlock() + if ok { + sessionID = sid + break + } + } + if sessionID == "" { + return fmt.Errorf("timed out waiting for session for target %s", targetID) + } + } + + // Stop old screencast if running + if s.sessionID != "" && s.sessionID != sessionID { + s.cdp.callSession(ctx, s.sessionID, "Page.stopScreencast", nil) + } + + s.sessionID = sessionID + s.targetID = targetID + + // Enable Page domain + s.cdp.callSession(ctx, sessionID, "Page.enable", nil) + + // Start screencast (maxWidth/maxHeight control frame resolution without modifying the page viewport) + _, err := s.cdp.callSession(ctx, sessionID, "Page.startScreencast", map[string]any{ + "format": "jpeg", + "quality": s.quality, + "maxWidth": s.width, + "maxHeight": s.height, + "everyNthFrame": 1, + }) + if err != nil { + return fmt.Errorf("start screencast on %s: %w", targetID, err) + } + + s.log.Info("screencast switched", "targetId", targetID, "sessionId", sessionID) + return nil +} + +func (s *server) handleScreencastFrame(ctx context.Context, params json.RawMessage) { + var frame struct { + Data string `json:"data"` + Metadata struct { + OffsetTop float64 `json:"offsetTop"` + PageScaleFactor float64 `json:"pageScaleFactor"` + DeviceWidth float64 `json:"deviceWidth"` + DeviceHeight float64 `json:"deviceHeight"` + ScrollOffsetX float64 `json:"scrollOffsetX"` + ScrollOffsetY float64 `json:"scrollOffsetY"` + } `json:"metadata"` + SessionID int `json:"sessionId"` + } + if err := json.Unmarshal(params, &frame); err != nil { + return + } + + // Ack the frame to get the next one + go func() { + s.cdp.callSession(ctx, s.sessionID, "Page.screencastFrameAck", map[string]int{ + "sessionId": frame.SessionID, + }) + }() + + jpegData, err := base64.StdEncoding.DecodeString(frame.Data) + if err != nil { + return + } + + meta := map[string]any{ + "type": "frame_meta", + "deviceWidth": frame.Metadata.DeviceWidth, + "deviceHeight": frame.Metadata.DeviceHeight, + "offsetTop": frame.Metadata.OffsetTop, + "scrollX": frame.Metadata.ScrollOffsetX, + "scrollY": frame.Metadata.ScrollOffsetY, + } + metaJSON, _ := json.Marshal(meta) + + s.viewers.Range(func(key, value any) bool { + v := value.(*viewer) + writeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + v.ws.Write(writeCtx, websocket.MessageText, metaJSON) + v.sendBinary(writeCtx, jpegData) + return true + }) +} + +type inputEvent struct { + Type string `json:"type"` + + MouseType string `json:"mouseType,omitempty"` + X float64 `json:"x"` + Y float64 `json:"y"` + Button string `json:"button,omitempty"` + ClickCount int `json:"clickCount,omitempty"` + DeltaX float64 `json:"deltaX,omitempty"` + DeltaY float64 `json:"deltaY,omitempty"` + Modifiers int `json:"modifiers,omitempty"` + + KeyType string `json:"keyType,omitempty"` + Key string `json:"key,omitempty"` + Code string `json:"code,omitempty"` + Text string `json:"text,omitempty"` + KeyCode int `json:"keyCode,omitempty"` +} + +func (s *server) handleInput(ctx context.Context, ev inputEvent) { + if s.cdp == nil || s.sessionID == "" { + return + } + + switch ev.Type { + case "mouse": + params := map[string]any{ + "type": ev.MouseType, + "x": ev.X, + "y": ev.Y, + "modifiers": ev.Modifiers, + } + if ev.MouseType == "mousePressed" || ev.MouseType == "mouseReleased" { + params["button"] = ev.Button + params["clickCount"] = ev.ClickCount + if ev.ClickCount == 0 { + params["clickCount"] = 1 + } + } + if ev.MouseType == "mouseWheel" { + params["type"] = "mouseWheel" + params["deltaX"] = ev.DeltaX + params["deltaY"] = ev.DeltaY + } + s.cdp.callSession(ctx, s.sessionID, "Input.dispatchMouseEvent", params) + + case "key": + params := map[string]any{ + "type": ev.KeyType, + "modifiers": ev.Modifiers, + "key": ev.Key, + "code": ev.Code, + "windowsVirtualKeyCode": ev.KeyCode, + "nativeVirtualKeyCode": ev.KeyCode, + } + if ev.Text != "" { + params["text"] = ev.Text + } + s.cdp.callSession(ctx, s.sessionID, "Input.dispatchKeyEvent", params) + } +} + +func (s *server) handleViewer(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + s.log.Error("accept viewer ws", "error", err) + return + } + ws.SetReadLimit(64 * 1024) + + v := &viewer{ws: ws, log: s.log} + viewerID := fmt.Sprintf("%p", v) + s.viewers.Store(viewerID, v) + s.log.Info("viewer connected", "id", viewerID) + + defer func() { + s.viewers.Delete(viewerID) + ws.Close(websocket.StatusNormalClosure, "") + s.log.Info("viewer disconnected", "id", viewerID) + }() + + if err := s.connectCDP(s.ctx); err != nil { + s.log.Error("connect CDP for viewer", "error", err) + return + } + + for { + _, data, err := ws.Read(s.ctx) + if err != nil { + return + } + var ev inputEvent + if err := json.Unmarshal(data, &ev); err != nil { + continue + } + s.handleInput(s.ctx, ev) + } +} + +func (s *server) serveViewer(w http.ResponseWriter, r *http.Request) { + data, err := viewerFS.ReadFile("viewer.html") + if err != nil { + http.Error(w, "viewer not found", 500) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +func (s *server) healthHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + io.WriteString(w, "ok") +} + +func envInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n := def + fmt.Sscanf(v, "%d", &n) + return n +} + +func main() { + cdpPort := flag.String("cdp-port", "", "CDP port (default: INTERNAL_PORT env or 9223)") + listen := flag.String("listen", ":8080", "HTTP listen address") + quality := flag.Int("quality", 80, "JPEG quality (1-100)") + flag.Parse() + + log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + port := *cdpPort + if port == "" { + port = os.Getenv("INTERNAL_PORT") + } + if port == "" { + port = "9223" + } + + s := &server{ + cdpPort: port, + listenAddr: *listen, + quality: *quality, + width: envInt("WIDTH", 1920), + height: envInt("HEIGHT", 1080), + ctx: context.Background(), + sessions: make(map[string]string), + log: log, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", s.serveViewer) + mux.HandleFunc("/ws", s.handleViewer) + mux.HandleFunc("/health", s.healthHandler) + + log.Info("starting cdp-live-view", "listen", s.listenAddr, "cdpPort", s.cdpPort) + if err := http.ListenAndServe(s.listenAddr, mux); err != nil { + log.Error("server error", "error", err) + os.Exit(1) + } +} diff --git a/server/cmd/cdp-live-view/viewer.html b/server/cmd/cdp-live-view/viewer.html new file mode 100644 index 00000000..c4267256 --- /dev/null +++ b/server/cmd/cdp-live-view/viewer.html @@ -0,0 +1,243 @@ + + + + Live View + + + + +
+ +
+
connecting...
+ + + From 540ece557c8fd715e35c4b0dbfcda6879b0d4586 Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Tue, 10 Mar 2026 15:01:12 -0400 Subject: [PATCH 2/9] fix: resolve data races in cdp-live-view and improve viewer UI - Protect sessionID/targetID/currentURL/pageTitle with sync.RWMutex to prevent data races between concurrent goroutines - Capture sessionID before goroutine in screencast frame ack to avoid sending ack to wrong CDP session after target switch - Add Chrome-like browser toolbar with back/forward/reload and address bar - Fix double-typing by using rawKeyDown + char instead of keyDown + char - Set Emulation.setDeviceMetricsOverride for full 1920x1080 viewport Made-with: Cursor --- benchmark-cdp.mjs | 376 ++++++++++++++++++++++++ benchmark-cdp.py | 377 ++++++++++++++++++++++++ benchmark-local.mjs | 375 ++++++++++++++++++++++++ benchmark-unikernel.mjs | 423 +++++++++++++++++++++++++++ package-lock.json | 74 +++++ server/cmd/cdp-live-view/main.go | 206 ++++++++++++- server/cmd/cdp-live-view/viewer.html | 403 ++++++++++++++++++------- 7 files changed, 2114 insertions(+), 120 deletions(-) create mode 100644 benchmark-cdp.mjs create mode 100644 benchmark-cdp.py create mode 100644 benchmark-local.mjs create mode 100644 benchmark-unikernel.mjs create mode 100644 package-lock.json diff --git a/benchmark-cdp.mjs b/benchmark-cdp.mjs new file mode 100644 index 00000000..6ca9fc31 --- /dev/null +++ b/benchmark-cdp.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +/** + * CDP benchmark: measures latency of common browser operations. + * Uses Node.js ws library (same as Playwright/Puppeteer). + */ + +import { WebSocket } from 'ws'; +import https from 'https'; + +const ITERATIONS = 3; +const URLS = [ + ['Wikipedia', 'https://en.wikipedia.org/wiki/Main_Page'], + ['Apple', 'https://www.apple.com'], + ['GitHub', 'https://github.com'], + ['CNN', 'https://www.cnn.com'], + ['Hacker News', 'https://news.ycombinator.com'], +]; + +const agent = new https.Agent({ rejectUnauthorized: false }); + +function httpsGet(url) { + return new Promise((resolve, reject) => { + https.get(url, { agent }, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ status: res.statusCode, data })); + }).on('error', reject); + }); +} + +class CDPBench { + constructor(host, label) { + this.host = host; + this.label = label; + this.ws = null; + this.msgId = 0; + this.pending = new Map(); + this.events = []; + } + + async connect() { + const url = `wss://${this.host}:9222/devtools/browser`; + return new Promise((resolve, reject) => { + this.ws = new WebSocket(url, { rejectUnauthorized: false }); + this.ws.on('open', () => resolve()); + this.ws.on('error', (e) => reject(e)); + this.ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id && this.pending.has(msg.id)) { + this.pending.get(msg.id)(msg); + this.pending.delete(msg.id); + } else { + this.events.push(msg); + } + }); + }); + } + + send(method, params, sessionId) { + return new Promise((resolve) => { + this.msgId++; + const msg = { id: this.msgId, method }; + if (params) msg.params = params; + if (sessionId) msg.sessionId = sessionId; + this.pending.set(this.msgId, resolve); + this.ws.send(JSON.stringify(msg)); + }); + } + + waitForEvent(name, sessionId, timeoutMs = 30000) { + // Check buffered events first + const idx = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (idx >= 0) { + return Promise.resolve(this.events.splice(idx, 1)[0]); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${name}`)), timeoutMs); + const check = setInterval(() => { + const i = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (i >= 0) { + clearInterval(check); + clearTimeout(timer); + resolve(this.events.splice(i, 1)[0]); + } + }, 10); + }); + } + + async createTarget() { + const resp = await this.send('Target.createTarget', { url: 'about:blank' }); + const targetId = resp.result.targetId; + const attach = await this.send('Target.attachToTarget', { targetId, flatten: true }); + return { targetId, sessionId: attach.result.sessionId }; + } + + async benchNavigate(sessionId, name, url) { + await this.send('Page.enable', null, sessionId); + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + this.events = []; + const start = performance.now(); + await this.send('Page.navigate', { url }, sessionId); + try { + await this.waitForEvent('Page.loadEventFired', sessionId, 30000); + } catch { /* timeout is ok, still measure */ } + times.push((performance.now() - start) / 1000); + await sleep(500); + } + return times; + } + + async benchScreenshot(sessionId) { + const times = []; + const sizes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const resp = await this.send('Page.captureScreenshot', { format: 'png' }, sessionId); + times.push((performance.now() - start) / 1000); + const data = resp.result?.data || ''; + sizes.push(Buffer.from(data, 'base64').length); + await sleep(200); + } + return { times, sizes }; + } + + async benchEvaluate(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Runtime.evaluate', { expression: 'document.title' }, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchClick(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Input.dispatchMouseEvent', + { type: 'mousePressed', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + await this.send('Input.dispatchMouseEvent', + { type: 'mouseReleased', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchType(sessionId) { + const text = 'hello world'; + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + for (const ch of text) { + await this.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch }, sessionId); + await this.send('Input.dispatchKeyEvent', { type: 'keyUp' }, sessionId); + } + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchLayoutMetrics(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Page.getLayoutMetrics', null, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async getMemory() { + try { + const resp = await this.send('SystemInfo.getProcessInfo'); + const procs = resp.result?.processInfo || []; + const totalMem = procs.reduce((s, p) => s + (p.privateMemory || 0), 0); + const totalCpu = procs.reduce((s, p) => s + (p.cpuTime || 0), 0); + return { memMB: totalMem / 1024 / 1024, cpuTime: totalCpu, nProcs: procs.length }; + } catch { + return { memMB: 0, cpuTime: 0, nProcs: 0 }; + } + } + + async run() { + console.log(`\n${'='.repeat(60)}`); + console.log(` BENCHMARK: ${this.label}`); + console.log(` Host: ${this.host}`); + console.log(` Iterations per test: ${ITERATIONS}`); + console.log(`${'='.repeat(60)}\n`); + + await this.connect(); + const { targetId, sessionId } = await this.createTarget(); + await this.send('Page.enable', null, sessionId); + await this.send('Runtime.enable', null, sessionId); + + const results = {}; + + // Navigation benchmarks + console.log('--- Navigation Latency ---'); + for (const [name, url] of URLS) { + const times = await this.benchNavigate(sessionId, name, url); + const med = median(times); + console.log(` ${name.padEnd(20)} median=${med.toFixed(3)}s min=${Math.min(...times).toFixed(3)}s max=${Math.max(...times).toFixed(3)}s`); + results[`nav_${name}`] = { median: med, min: Math.min(...times), max: Math.max(...times), raw: times }; + } + + // Navigate to Wikipedia for remaining tests + await this.send('Page.navigate', { url: 'https://en.wikipedia.org/wiki/Main_Page' }, sessionId); + await sleep(3000); + this.events = []; + + console.log('\n--- CDP Operation Latency ---'); + + // Screenshot + const ss = await this.benchScreenshot(sessionId); + const ssMed = median(ss.times); + const avgSize = ss.sizes.reduce((a, b) => a + b, 0) / ss.sizes.length; + console.log(` ${'Screenshot'.padEnd(20)} median=${ssMed.toFixed(3)}s size=${(avgSize/1024).toFixed(0)}KB`); + results.screenshot = { median: ssMed, raw: ss.times, avgSizeKB: avgSize / 1024 }; + + // JS Evaluate + const evalTimes = await this.benchEvaluate(sessionId); + const evalMed = median(evalTimes); + console.log(` ${'JS Evaluate'.padEnd(20)} median=${(evalMed * 1000).toFixed(1)}ms`); + results.js_evaluate = { medianMs: evalMed * 1000, raw: evalTimes }; + + // Mouse Click + const clickTimes = await this.benchClick(sessionId); + const clickMed = median(clickTimes); + console.log(` ${'Mouse Click'.padEnd(20)} median=${(clickMed * 1000).toFixed(1)}ms`); + results.mouse_click = { medianMs: clickMed * 1000, raw: clickTimes }; + + // Keyboard Type + const typeTimes = await this.benchType(sessionId); + const typeMed = median(typeTimes); + console.log(` ${'Type 11 chars'.padEnd(20)} median=${(typeMed * 1000).toFixed(1)}ms`); + results.keyboard_type = { medianMs: typeMed * 1000, raw: typeTimes }; + + // Layout Metrics + const lmTimes = await this.benchLayoutMetrics(sessionId); + const lmMed = median(lmTimes); + console.log(` ${'Layout Metrics'.padEnd(20)} median=${(lmMed * 1000).toFixed(1)}ms`); + results.layout_metrics = { medianMs: lmMed * 1000, raw: lmTimes }; + + // Memory + console.log('\n--- Resource Usage ---'); + const mem = await this.getMemory(); + console.log(` Browser processes: ${mem.nProcs}`); + console.log(` Private memory: ${mem.memMB.toFixed(0)} MB`); + console.log(` CPU time: ${mem.cpuTime.toFixed(1)}s`); + results.memoryMB = mem.memMB; + results.cpuTimeS = mem.cpuTime; + results.processCount = mem.nProcs; + + await this.send('Target.closeTarget', { targetId }); + this.ws.close(); + return results; + } +} + +function median(arr) { + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +async function warmup(host, label) { + for (let i = 0; i < 30; i++) { + try { + await httpsGet(`https://${host}:444/spec.json`); + console.log(` ${label}: ready`); + return true; + } catch { + await sleep(5000); + } + } + console.log(` ${label}: FAILED to warm up`); + return false; +} + +async function main() { + const instances = [ + ['BASELINE (v29 headless, 1vCPU/3GB)', 'winter-mountain-2k9xdihk.dev-iad-unikraft-3.onkernel.app'], + ['NEW (headless+live view, 1vCPU/3GB)', 'silent-thunder-42i78h9l.dev-iad-unikraft-3.onkernel.app'], + ['HEADFUL (kernel-cu-v33, 4vCPU/4GB)', 'misty-cherry-74utb712.dev-iad-unikraft-3.onkernel.app'], + ]; + + console.log('Warming up instances...'); + for (const [label, host] of instances) { + await warmup(host, label); + } + + const allResults = {}; + const labels = []; + + for (const [label, host] of instances) { + const bench = new CDPBench(host, label); + try { + allResults[label] = await bench.run(); + labels.push(label); + } catch (e) { + console.log(`\n ERROR benchmarking ${label}: ${e.message}\n`); + } + } + + if (labels.length < 2) { + console.log('Not enough successful benchmarks to compare.'); + return; + } + + // Summary + console.log(`\n${'='.repeat(100)}`); + console.log(' COMPARISON SUMMARY'); + console.log(`${'='.repeat(100)}`); + + const shortLabels = labels.map(l => { + if (l.includes('BASELINE')) return 'Baseline'; + if (l.includes('NEW')) return 'New+LiveView'; + return 'Headful'; + }); + + let header = 'Operation'.padEnd(25); + for (const sl of shortLabels) header += sl.padStart(15); + console.log(`\n${header}`); + console.log('-'.repeat(25) + (' ' + '-'.repeat(14)).repeat(labels.length)); + + for (const [name] of URLS) { + const key = `nav_${name}`; + let row = `Nav ${name}`.padEnd(25); + for (const l of labels) { + row += `${allResults[l][key].median.toFixed(3)}s`.padStart(15); + } + console.log(row); + } + console.log(); + + for (const [op, key, unit] of [ + ['Screenshot', 'screenshot', 's'], + ['JS Evaluate', 'js_evaluate', 'ms'], + ['Mouse Click', 'mouse_click', 'ms'], + ['Type 11 chars', 'keyboard_type', 'ms'], + ['Layout Metrics', 'layout_metrics', 'ms'], + ]) { + let row = op.padEnd(25); + for (const l of labels) { + if (unit === 's') { + row += `${allResults[l][key].median.toFixed(3)}s`.padStart(15); + } else { + row += `${allResults[l][key].medianMs.toFixed(1)}ms`.padStart(15); + } + } + console.log(row); + } + console.log(); + + let memRow = 'Memory'.padEnd(25); + let cpuRow = 'CPU time'.padEnd(25); + let procRow = 'Processes'.padEnd(25); + for (const l of labels) { + memRow += `${allResults[l].memoryMB.toFixed(0)}MB`.padStart(15); + cpuRow += `${allResults[l].cpuTimeS.toFixed(1)}s`.padStart(15); + procRow += `${allResults[l].processCount}`.padStart(15); + } + console.log(memRow); + console.log(cpuRow); + console.log(procRow); +} + +main().catch(console.error); diff --git a/benchmark-cdp.py b/benchmark-cdp.py new file mode 100644 index 00000000..8895093c --- /dev/null +++ b/benchmark-cdp.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""CDP benchmark: measures latency of common browser operations.""" + +import asyncio +import json +import ssl +import sys +import time +import statistics +import base64 + +try: + import websockets +except ImportError: + print("Installing websockets...") + import subprocess + subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"]) + import websockets + +URLS = [ + ("Wikipedia", "https://en.wikipedia.org/wiki/Main_Page"), + ("Apple", "https://www.apple.com"), + ("GitHub", "https://github.com"), + ("CNN", "https://www.cnn.com"), + ("Hacker News", "https://news.ycombinator.com"), +] + +ITERATIONS = 3 + + +class CDPBenchmark: + def __init__(self, host, label): + self.host = host + self.label = label + self.ws = None + self.msg_id = 0 + self.results = {} + + async def connect(self): + self.ssl_ctx = ssl.create_default_context() + self.ssl_ctx.check_hostname = False + self.ssl_ctx.verify_mode = ssl.CERT_NONE + + # CDP proxy routes all WS to browser-level endpoint regardless of path + ws_url = f"wss://{self.host}:9222/devtools/browser" + self.ws = await websockets.connect(ws_url, ssl=self.ssl_ctx, max_size=50 * 1024 * 1024, + additional_headers={"Host": self.host}) + + async def send(self, method, params=None): + self.msg_id += 1 + msg = {"id": self.msg_id, "method": method} + if params: + msg["params"] = params + await self.ws.send(json.dumps(msg)) + while True: + resp = json.loads(await self.ws.recv()) + if resp.get("id") == self.msg_id: + return resp + # skip events + + async def create_target(self): + resp = await self.send("Target.createTarget", {"url": "about:blank"}) + target_id = resp["result"]["targetId"] + resp = await self.send("Target.attachToTarget", {"targetId": target_id, "flatten": True}) + session_id = resp["result"]["sessionId"] + return target_id, session_id + + async def send_session(self, session_id, method, params=None): + self.msg_id += 1 + msg = {"id": self.msg_id, "method": method, "sessionId": session_id} + if params: + msg["params"] = params + await self.ws.send(json.dumps(msg)) + while True: + resp = json.loads(await self.ws.recv()) + if resp.get("id") == self.msg_id: + return resp + + async def bench_navigate(self, session_id, name, url): + """Navigate and wait for load event.""" + await self.send_session(session_id, "Page.enable") + + times = [] + for i in range(ITERATIONS): + start = time.perf_counter() + await self.send_session(session_id, "Page.navigate", {"url": url}) + # Wait for loadEventFired + deadline = time.perf_counter() + 30 + while time.perf_counter() < deadline: + resp = json.loads(await self.ws.recv()) + if resp.get("method") == "Page.loadEventFired": + break + elapsed = time.perf_counter() - start + times.append(elapsed) + # small pause between iterations + await asyncio.sleep(0.5) + + return times + + async def bench_screenshot(self, session_id): + """Take a full-page screenshot.""" + times = [] + sizes = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + resp = await self.send_session(session_id, "Page.captureScreenshot", + {"format": "png"}) + elapsed = time.perf_counter() - start + times.append(elapsed) + data = resp.get("result", {}).get("data", "") + sizes.append(len(base64.b64decode(data)) if data else 0) + await asyncio.sleep(0.2) + return times, sizes + + async def bench_evaluate(self, session_id): + """Evaluate JS expression.""" + times = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + await self.send_session(session_id, "Runtime.evaluate", + {"expression": "document.title"}) + elapsed = time.perf_counter() - start + times.append(elapsed) + return times + + async def bench_click(self, session_id): + """Dispatch mouse click at (100, 100).""" + times = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + await self.send_session(session_id, "Input.dispatchMouseEvent", + {"type": "mousePressed", "x": 100, "y": 100, + "button": "left", "clickCount": 1}) + await self.send_session(session_id, "Input.dispatchMouseEvent", + {"type": "mouseReleased", "x": 100, "y": 100, + "button": "left", "clickCount": 1}) + elapsed = time.perf_counter() - start + times.append(elapsed) + return times + + async def bench_type(self, session_id): + """Type a string character by character.""" + text = "hello world" + times = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + for ch in text: + await self.send_session(session_id, "Input.dispatchKeyEvent", + {"type": "keyDown", "text": ch}) + await self.send_session(session_id, "Input.dispatchKeyEvent", + {"type": "keyUp"}) + elapsed = time.perf_counter() - start + times.append(elapsed) + return times + + async def bench_get_layout_metrics(self, session_id): + """Get layout metrics (viewport info).""" + times = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + await self.send_session(session_id, "Page.getLayoutMetrics") + elapsed = time.perf_counter() - start + times.append(elapsed) + return times + + async def get_memory(self): + """Get browser memory usage via systeminfo.""" + resp = await self.send("SystemInfo.getProcessInfo") + if "result" in resp: + procs = resp["result"].get("processInfo", []) + total_mem = sum(p.get("privateMemory", 0) for p in procs) + total_cpu = sum(p.get("cpuTime", 0) for p in procs) + return total_mem, total_cpu, len(procs) + return 0, 0, 0 + + async def get_memory_via_api(self): + """Get memory via kernel-images API.""" + import urllib.request + try: + url = f"https://{self.host}:444/health" + req = urllib.request.Request(url) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(req, context=ctx, timeout=5) as r: + return json.loads(r.read()) + except Exception: + return None + + async def run(self): + print(f"\n{'='*60}") + print(f" BENCHMARK: {self.label}") + print(f" Host: {self.host}") + print(f" Iterations per test: {ITERATIONS}") + print(f"{'='*60}\n") + + await self.connect() + + target_id, session_id = await self.create_target() + await self.send_session(session_id, "Page.enable") + await self.send_session(session_id, "Runtime.enable") + + all_results = {} + + # Navigation benchmarks + print("--- Navigation Latency ---") + for name, url in URLS: + times = await self.bench_navigate(session_id, name, url) + med = statistics.median(times) + mn = min(times) + mx = max(times) + print(f" {name:20s} median={med:.3f}s min={mn:.3f}s max={mx:.3f}s") + all_results[f"nav_{name}"] = {"median": med, "min": mn, "max": mx, "raw": times} + + # Navigate to Wikipedia for the remaining tests + await self.send_session(session_id, "Page.navigate", + {"url": "https://en.wikipedia.org/wiki/Main_Page"}) + await asyncio.sleep(2) + # drain events + try: + while True: + await asyncio.wait_for(self.ws.recv(), timeout=0.5) + except asyncio.TimeoutError: + pass + + print("\n--- CDP Operation Latency ---") + + # Screenshot + times, sizes = await self.bench_screenshot(session_id) + med = statistics.median(times) + avg_size = statistics.mean(sizes) + print(f" {'Screenshot':20s} median={med:.3f}s size={avg_size/1024:.0f}KB") + all_results["screenshot"] = {"median": med, "raw": times, "avg_size_kb": avg_size/1024} + + # JS Evaluate + times = await self.bench_evaluate(session_id) + med = statistics.median(times) + print(f" {'JS Evaluate':20s} median={med*1000:.1f}ms") + all_results["js_evaluate"] = {"median_ms": med*1000, "raw": times} + + # Mouse Click + times = await self.bench_click(session_id) + med = statistics.median(times) + print(f" {'Mouse Click':20s} median={med*1000:.1f}ms") + all_results["mouse_click"] = {"median_ms": med*1000, "raw": times} + + # Keyboard Type + times = await self.bench_type(session_id) + med = statistics.median(times) + print(f" {'Type 11 chars':20s} median={med*1000:.1f}ms") + all_results["keyboard_type"] = {"median_ms": med*1000, "raw": times} + + # Layout Metrics + times = await self.bench_get_layout_metrics(session_id) + med = statistics.median(times) + print(f" {'Layout Metrics':20s} median={med*1000:.1f}ms") + all_results["layout_metrics"] = {"median_ms": med*1000, "raw": times} + + # Memory + print("\n--- Resource Usage ---") + mem, cpu, nprocs = await self.get_memory() + print(f" Browser processes: {nprocs}") + print(f" Private memory: {mem/1024/1024:.0f} MB") + print(f" CPU time: {cpu:.1f}s") + all_results["memory_mb"] = mem/1024/1024 + all_results["cpu_time_s"] = cpu + all_results["process_count"] = nprocs + + await self.send("Target.closeTarget", {"targetId": target_id}) + await self.ws.close() + + return all_results + + +async def main(): + instances = [ + ("BASELINE (v29 headless, 1vCPU/3GB)", + "winter-mountain-2k9xdihk.dev-iad-unikraft-3.onkernel.app"), + ("NEW (headless + live view, 1vCPU/3GB)", + "silent-thunder-42i78h9l.dev-iad-unikraft-3.onkernel.app"), + ("HEADFUL (kernel-cu-v33, 4vCPU/4GB)", + "snowy-grass-r2apwx6u.dev-iad-unikraft-3.onkernel.app"), + ] + + # Warm up all instances (they might be scaled to zero) + print("Warming up instances...") + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + import urllib.request + for label, host in instances: + for attempt in range(30): + try: + req = urllib.request.Request(f"https://{host}:444/spec.json") + with urllib.request.urlopen(req, context=ssl_ctx, timeout=15) as r: + r.read() + print(f" {label}: ready") + break + except Exception as e: + if attempt < 29: + await asyncio.sleep(5) + else: + print(f" {label}: FAILED ({e})") + + # Run benchmarks + all_results = {} + labels = [] + for label, host in instances: + bench = CDPBenchmark(host, label) + try: + all_results[label] = await bench.run() + labels.append(label) + except Exception as e: + print(f"\n ERROR benchmarking {label}: {e}\n") + + if len(labels) < 2: + print("Not enough successful benchmarks to compare.") + return + + # Summary comparison table + print(f"\n{'='*100}") + print(f" COMPARISON SUMMARY") + print(f"{'='*100}") + + header = f"{'Operation':25s}" + for l in labels: + short = l.split("(")[1].split(",")[0] if "(" in l else l[:15] + header += f" {short:>15s}" + print(f"\n{header}") + print(f"{'-'*25}" + f" {'-'*15}" * len(labels)) + + # Navigation + for name, url in URLS: + key = f"nav_{name}" + row = f"Nav {name:20s}" + for l in labels: + v = all_results[l][key]["median"] + row += f" {v:>13.3f}s" + print(row) + + print() + + # CDP ops + for op, key, unit in [ + ("Screenshot", "screenshot", "s"), + ("JS Evaluate", "js_evaluate", "ms"), + ("Mouse Click", "mouse_click", "ms"), + ("Type 11 chars", "keyboard_type", "ms"), + ("Layout Metrics", "layout_metrics", "ms"), + ]: + row = f"{op:25s}" + for l in labels: + if unit == "s": + v = all_results[l][key]["median"] + row += f" {v:>13.3f}s" + else: + v = all_results[l][key]["median_ms"] + row += f" {v:>12.1f}ms" + print(row) + + print() + + # Resources + row_mem = f"{'Memory':25s}" + row_cpu = f"{'CPU time':25s}" + row_proc = f"{'Processes':25s}" + for l in labels: + row_mem += f" {all_results[l]['memory_mb']:>12.0f}MB" + row_cpu += f" {all_results[l]['cpu_time_s']:>13.1f}s" + row_proc += f" {all_results[l]['process_count']:>15d}" + print(row_mem) + print(row_cpu) + print(row_proc) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/benchmark-local.mjs b/benchmark-local.mjs new file mode 100644 index 00000000..b438d688 --- /dev/null +++ b/benchmark-local.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node +/** + * Local CDP benchmark: baseline (no live view) vs CDP live view. + * Both run the same Docker image, only difference is ENABLE_LIVE_VIEW. + */ + +import { WebSocket } from 'ws'; +import http from 'http'; + +const ITERATIONS = 5; +const URLS = [ + ['Wikipedia', 'https://en.wikipedia.org/wiki/Main_Page'], + ['Apple', 'https://www.apple.com'], + ['GitHub', 'https://github.com'], + ['Hacker News', 'https://news.ycombinator.com'], +]; + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ status: res.statusCode, data })); + }).on('error', reject); + }); +} + +class CDPBench { + constructor(wsPort, apiPort, label) { + this.wsPort = wsPort; + this.apiPort = apiPort; + this.label = label; + this.ws = null; + this.msgId = 0; + this.pending = new Map(); + this.events = []; + } + + async connect() { + const url = `ws://127.0.0.1:${this.wsPort}`; + return new Promise((resolve, reject) => { + this.ws = new WebSocket(url); + this.ws.on('open', () => resolve()); + this.ws.on('error', (e) => reject(e)); + this.ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id && this.pending.has(msg.id)) { + this.pending.get(msg.id)(msg); + this.pending.delete(msg.id); + } else { + this.events.push(msg); + } + }); + }); + } + + send(method, params, sessionId) { + return new Promise((resolve, reject) => { + this.msgId++; + const msg = { id: this.msgId, method }; + if (params) msg.params = params; + if (sessionId) msg.sessionId = sessionId; + this.pending.set(this.msgId, resolve); + this.ws.send(JSON.stringify(msg)); + setTimeout(() => { + if (this.pending.has(this.msgId)) { + this.pending.delete(this.msgId); + reject(new Error(`Timeout: ${method}`)); + } + }, 30000); + }); + } + + waitForEvent(name, sessionId, timeoutMs = 30000) { + const idx = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (idx >= 0) return Promise.resolve(this.events.splice(idx, 1)[0]); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${name}`)), timeoutMs); + const check = setInterval(() => { + const i = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (i >= 0) { + clearInterval(check); + clearTimeout(timer); + resolve(this.events.splice(i, 1)[0]); + } + }, 10); + }); + } + + async createTarget() { + const resp = await this.send('Target.createTarget', { url: 'about:blank' }); + const targetId = resp.result.targetId; + const attach = await this.send('Target.attachToTarget', { targetId, flatten: true }); + return { targetId, sessionId: attach.result.sessionId }; + } + + async benchNavigate(sessionId, name, url) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + this.events = []; + const start = performance.now(); + await this.send('Page.navigate', { url }, sessionId); + try { + await this.waitForEvent('Page.loadEventFired', sessionId, 30000); + } catch { /* timeout ok */ } + times.push((performance.now() - start) / 1000); + await sleep(300); + } + return times; + } + + async benchScreenshot(sessionId) { + const times = []; + const sizes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const resp = await this.send('Page.captureScreenshot', { format: 'png' }, sessionId); + times.push((performance.now() - start) / 1000); + const data = resp.result?.data || ''; + sizes.push(Buffer.from(data, 'base64').length); + await sleep(100); + } + return { times, sizes }; + } + + async benchEvaluate(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Runtime.evaluate', { expression: 'document.title' }, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchClick(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Input.dispatchMouseEvent', + { type: 'mousePressed', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + await this.send('Input.dispatchMouseEvent', + { type: 'mouseReleased', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchType(sessionId) { + const text = 'hello world'; + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + for (const ch of text) { + await this.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch }, sessionId); + await this.send('Input.dispatchKeyEvent', { type: 'keyUp' }, sessionId); + } + times.push((performance.now() - start) / 1000); + } + return times; + } + + async benchLayoutMetrics(sessionId) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await this.send('Page.getLayoutMetrics', null, sessionId); + times.push((performance.now() - start) / 1000); + } + return times; + } + + async getProcessMemory() { + try { + const resp = await httpGet(`http://127.0.0.1:${this.apiPort}/process/exec`); + return null; + } catch { return null; } + } + + async getMemInfo() { + // Read /proc/meminfo via the API + try { + const resp = await new Promise((resolve, reject) => { + const postData = JSON.stringify({ command: 'cat', args: ['/proc/meminfo'] }); + const req = http.request({ + hostname: '127.0.0.1', port: this.apiPort, + path: '/process/exec', method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': postData.length } + }, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }); + req.on('error', reject); + req.write(postData); + req.end(); + }); + const stdout = Buffer.from(resp.stdout_b64 || '', 'base64').toString(); + const memTotal = parseInt(stdout.match(/MemTotal:\s+(\d+)/)?.[1] || '0'); + const memFree = parseInt(stdout.match(/MemFree:\s+(\d+)/)?.[1] || '0'); + const memAvail = parseInt(stdout.match(/MemAvailable:\s+(\d+)/)?.[1] || '0'); + const cached = parseInt(stdout.match(/Cached:\s+(\d+)/)?.[1] || '0'); + return { totalMB: memTotal / 1024, freeMB: memFree / 1024, usedMB: (memTotal - memFree) / 1024, availMB: memAvail / 1024 }; + } catch (e) { + return { totalMB: 0, freeMB: 0, usedMB: 0, availMB: 0 }; + } + } + + async run() { + console.log(`\n${'='.repeat(60)}`); + console.log(` BENCHMARK: ${this.label}`); + console.log(` CDP: ws://127.0.0.1:${this.wsPort} API: :${this.apiPort}`); + console.log(` Iterations per test: ${ITERATIONS}`); + console.log(`${'='.repeat(60)}\n`); + + // Memory before + const memBefore = await this.getMemInfo(); + console.log(` Memory before: ${memBefore.usedMB.toFixed(0)} MB used / ${memBefore.totalMB.toFixed(0)} MB total\n`); + + await this.connect(); + const { targetId, sessionId } = await this.createTarget(); + await this.send('Page.enable', null, sessionId); + await this.send('Runtime.enable', null, sessionId); + + const results = { memBefore }; + + // Navigation benchmarks + console.log('--- Navigation Latency ---'); + for (const [name, url] of URLS) { + const times = await this.benchNavigate(sessionId, name, url); + const med = median(times); + console.log(` ${name.padEnd(20)} median=${med.toFixed(3)}s min=${Math.min(...times).toFixed(3)}s max=${Math.max(...times).toFixed(3)}s`); + results[`nav_${name}`] = { median: med, min: Math.min(...times), max: Math.max(...times) }; + } + + // Navigate to Wikipedia for remaining tests + await this.send('Page.navigate', { url: 'https://en.wikipedia.org/wiki/Main_Page' }, sessionId); + await sleep(3000); + this.events = []; + + console.log('\n--- CDP Operation Latency ---'); + + const ss = await this.benchScreenshot(sessionId); + const ssMed = median(ss.times); + const avgSize = ss.sizes.reduce((a, b) => a + b, 0) / ss.sizes.length; + console.log(` ${'Screenshot'.padEnd(20)} median=${ssMed.toFixed(3)}s size=${(avgSize/1024).toFixed(0)}KB`); + results.screenshot = { median: ssMed, avgSizeKB: avgSize / 1024 }; + + const evalTimes = await this.benchEvaluate(sessionId); + const evalMed = median(evalTimes); + console.log(` ${'JS Evaluate'.padEnd(20)} median=${(evalMed * 1000).toFixed(1)}ms`); + results.js_evaluate = { medianMs: evalMed * 1000 }; + + const clickTimes = await this.benchClick(sessionId); + const clickMed = median(clickTimes); + console.log(` ${'Mouse Click'.padEnd(20)} median=${(clickMed * 1000).toFixed(1)}ms`); + results.mouse_click = { medianMs: clickMed * 1000 }; + + const typeTimes = await this.benchType(sessionId); + const typeMed = median(typeTimes); + console.log(` ${'Type 11 chars'.padEnd(20)} median=${(typeMed * 1000).toFixed(1)}ms`); + results.keyboard_type = { medianMs: typeMed * 1000 }; + + const lmTimes = await this.benchLayoutMetrics(sessionId); + const lmMed = median(lmTimes); + console.log(` ${'Layout Metrics'.padEnd(20)} median=${(lmMed * 1000).toFixed(1)}ms`); + results.layout_metrics = { medianMs: lmMed * 1000 }; + + // Memory after + const memAfter = await this.getMemInfo(); + console.log(`\n--- Memory ---`); + console.log(` Before: ${memBefore.usedMB.toFixed(0)} MB used`); + console.log(` After: ${memAfter.usedMB.toFixed(0)} MB used`); + console.log(` Delta: +${(memAfter.usedMB - memBefore.usedMB).toFixed(0)} MB`); + results.memAfter = memAfter; + + await this.send('Target.closeTarget', { targetId }); + this.ws.close(); + return results; + } +} + +function median(arr) { + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function main() { + const instances = [ + { label: 'BASELINE (headless, no live view)', wsPort: 9232, apiPort: 10002 }, + { label: 'CDP LIVE VIEW (headless + screencast)', wsPort: 9222, apiPort: 10001 }, + ]; + + const allResults = {}; + + for (const inst of instances) { + const bench = new CDPBench(inst.wsPort, inst.apiPort, inst.label); + try { + allResults[inst.label] = await bench.run(); + } catch (e) { + console.log(`\n ERROR: ${e.message}\n`); + } + } + + const labels = Object.keys(allResults); + if (labels.length < 2) { + console.log('Not enough results to compare.'); + return; + } + + console.log(`\n${'='.repeat(80)}`); + console.log(' COMPARISON: Baseline vs CDP Live View'); + console.log(`${'='.repeat(80)}`); + + const base = allResults[labels[0]]; + const live = allResults[labels[1]]; + + const fmt = (val, unit) => unit === 's' ? `${val.toFixed(3)}s` : `${val.toFixed(1)}ms`; + const pct = (b, l) => { + const diff = ((l - b) / b) * 100; + return diff > 0 ? `+${diff.toFixed(0)}%` : `${diff.toFixed(0)}%`; + }; + + console.log(`\n${'Metric'.padEnd(25)}${'Baseline'.padStart(15)}${'CDP Live View'.padStart(15)}${'Delta'.padStart(10)}`); + console.log('-'.repeat(65)); + + // Navigation + for (const [name] of URLS) { + const key = `nav_${name}`; + if (base[key] && live[key]) { + const b = base[key].median, l = live[key].median; + console.log(`Nav ${name}`.padEnd(25) + fmt(b, 's').padStart(15) + fmt(l, 's').padStart(15) + pct(b, l).padStart(10)); + } + } + console.log(); + + // Operations + for (const [op, key, unit] of [ + ['Screenshot', 'screenshot', 's'], + ['JS Evaluate', 'js_evaluate', 'ms'], + ['Mouse Click', 'mouse_click', 'ms'], + ['Type 11 chars', 'keyboard_type', 'ms'], + ['Layout Metrics', 'layout_metrics', 'ms'], + ]) { + if (base[key] && live[key]) { + const b = unit === 's' ? base[key].median : base[key].medianMs; + const l = unit === 's' ? live[key].median : live[key].medianMs; + console.log(op.padEnd(25) + fmt(b, unit).padStart(15) + fmt(l, unit).padStart(15) + pct(b, l).padStart(10)); + } + } + + // Screenshot size + if (base.screenshot && live.screenshot) { + console.log(`Screenshot size`.padEnd(25) + + `${base.screenshot.avgSizeKB.toFixed(0)}KB`.padStart(15) + + `${live.screenshot.avgSizeKB.toFixed(0)}KB`.padStart(15)); + } + console.log(); + + // Memory + console.log(`Memory (idle)`.padEnd(25) + + `${base.memBefore.usedMB.toFixed(0)}MB`.padStart(15) + + `${live.memBefore.usedMB.toFixed(0)}MB`.padStart(15) + + `+${(live.memBefore.usedMB - base.memBefore.usedMB).toFixed(0)}MB`.padStart(10)); + console.log(`Memory (after bench)`.padEnd(25) + + `${base.memAfter.usedMB.toFixed(0)}MB`.padStart(15) + + `${live.memAfter.usedMB.toFixed(0)}MB`.padStart(15) + + `+${(live.memAfter.usedMB - base.memAfter.usedMB).toFixed(0)}MB`.padStart(10)); +} + +main().catch(console.error); diff --git a/benchmark-unikernel.mjs b/benchmark-unikernel.mjs new file mode 100644 index 00000000..3bca414c --- /dev/null +++ b/benchmark-unikernel.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * Unikernel benchmark: measures memory, CPU, and CDP latency + * for baseline headless vs CDP live-view headless instances. + */ + +import { WebSocket } from 'ws'; +import https from 'https'; + +const ITERATIONS = 5; +const URLS = [ + ['Wikipedia', 'https://en.wikipedia.org/wiki/Main_Page'], + ['Apple', 'https://www.apple.com'], + ['GitHub', 'https://github.com'], + ['Hacker News', 'https://news.ycombinator.com'], +]; + +const agent = new https.Agent({ rejectUnauthorized: false }); + +function httpsGet(url) { + return new Promise((resolve, reject) => { + https.get(url, { agent }, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ status: res.statusCode, data })); + }).on('error', reject); + }); +} + +function httpsPost(url, body) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const opts = { + hostname: u.hostname, port: u.port || 443, path: u.pathname, + method: 'POST', agent, + headers: { 'Content-Type': 'application/json' }, + }; + const req = https.request(opts, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve({ status: res.statusCode, data })); + }); + req.on('error', reject); + req.write(JSON.stringify(body)); + req.end(); + }); +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } +function median(arr) { + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +class CDPBench { + constructor(host, label) { + this.host = host; + this.label = label; + this.ws = null; + this.msgId = 0; + this.pending = new Map(); + this.events = []; + } + + async connect() { + const url = `wss://${this.host}:9222/devtools/browser`; + return new Promise((resolve, reject) => { + this.ws = new WebSocket(url, { rejectUnauthorized: false }); + this.ws.on('open', () => resolve()); + this.ws.on('error', (e) => reject(e)); + this.ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id && this.pending.has(msg.id)) { + this.pending.get(msg.id)(msg); + this.pending.delete(msg.id); + } else { + this.events.push(msg); + } + }); + }); + } + + send(method, params, sessionId) { + return new Promise((resolve) => { + this.msgId++; + const msg = { id: this.msgId, method }; + if (params) msg.params = params; + if (sessionId) msg.sessionId = sessionId; + this.pending.set(this.msgId, resolve); + this.ws.send(JSON.stringify(msg)); + }); + } + + waitForEvent(name, sessionId, timeoutMs = 30000) { + const idx = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (idx >= 0) return Promise.resolve(this.events.splice(idx, 1)[0]); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${name}`)), timeoutMs); + const check = setInterval(() => { + const i = this.events.findIndex(e => + e.method === name && (!sessionId || e.sessionId === sessionId)); + if (i >= 0) { + clearInterval(check); + clearTimeout(timer); + resolve(this.events.splice(i, 1)[0]); + } + }, 10); + }); + } + + async createTarget() { + const resp = await this.send('Target.createTarget', { url: 'about:blank' }); + const targetId = resp.result.targetId; + const attach = await this.send('Target.attachToTarget', { targetId, flatten: true }); + return { targetId, sessionId: attach.result.sessionId }; + } + + close() { if (this.ws) this.ws.close(); } +} + +async function execInContainer(host, command, args) { + const url = `https://${host}:444/process/exec`; + const resp = await httpsPost(url, { command, args: args || [], timeout: 10 }); + const parsed = JSON.parse(resp.data); + const stdout = parsed.stdout_b64 ? Buffer.from(parsed.stdout_b64, 'base64').toString() : ''; + const stderr = parsed.stderr_b64 ? Buffer.from(parsed.stderr_b64, 'base64').toString() : ''; + return { stdout, stderr, exitCode: parsed.exit_code }; +} + +async function getMemInfo(host) { + const { stdout } = await execInContainer(host, 'cat', ['/proc/meminfo']); + const lines = stdout.split('\n'); + const vals = {}; + for (const line of lines) { + const m = line.match(/^(\w+):\s+(\d+)/); + if (m) vals[m[1]] = parseInt(m[2]); + } + const totalKB = vals.MemTotal || 0; + const availKB = vals.MemAvailable || 0; + const freeKB = vals.MemFree || 0; + const buffersKB = vals.Buffers || 0; + const cachedKB = vals.Cached || 0; + const usedKB = totalKB - freeKB - buffersKB - cachedKB; + return { + totalMB: totalKB / 1024, + usedMB: usedKB / 1024, + availableMB: availKB / 1024, + freeMB: freeKB / 1024, + cachedMB: cachedKB / 1024, + }; +} + +async function getCPUInfo(host) { + const { stdout } = await execInContainer(host, 'cat', ['/proc/stat']); + const cpuLine = stdout.split('\n').find(l => l.startsWith('cpu ')); + if (!cpuLine) return null; + const parts = cpuLine.split(/\s+/).slice(1).map(Number); + const [user, nice, system, idle, iowait, irq, softirq, steal] = parts; + const total = user + nice + system + idle + (iowait || 0) + (irq || 0) + (softirq || 0) + (steal || 0); + const busy = total - idle - (iowait || 0); + return { user, nice, system, idle, iowait: iowait || 0, total, busy, busyPct: (busy / total * 100) }; +} + +async function getProcessList(host) { + const { stdout } = await execInContainer(host, 'ps', ['aux']); + return stdout; +} + +async function warmup(host, label) { + for (let i = 0; i < 40; i++) { + try { + await httpsGet(`https://${host}:444/spec.json`); + console.log(` ${label}: ready`); + return true; + } catch { + await sleep(5000); + } + } + console.log(` ${label}: FAILED to warm up`); + return false; +} + +async function benchmarkInstance(host, label) { + console.log(`\n${'='.repeat(70)}`); + console.log(` BENCHMARK: ${label}`); + console.log(` Host: ${host}`); + console.log(` Iterations per test: ${ITERATIONS}`); + console.log(`${'='.repeat(70)}\n`); + + // --- Resource snapshot BEFORE workload --- + console.log('--- Resource Usage (before workload) ---'); + const memBefore = await getMemInfo(host); + console.log(` Memory total: ${memBefore.totalMB.toFixed(0)} MB`); + console.log(` Memory used: ${memBefore.usedMB.toFixed(0)} MB`); + console.log(` Memory available: ${memBefore.availableMB.toFixed(0)} MB`); + console.log(` Memory cached: ${memBefore.cachedMB.toFixed(0)} MB`); + + const cpuBefore = await getCPUInfo(host); + if (cpuBefore) { + console.log(` CPU busy: ${cpuBefore.busyPct.toFixed(1)}% (cumulative since boot)`); + } + + // --- Process list --- + console.log('\n--- Process List ---'); + const ps = await getProcessList(host); + console.log(ps); + + // --- CDP benchmark --- + const cdp = new CDPBench(host, label); + await cdp.connect(); + const { targetId, sessionId } = await cdp.createTarget(); + await cdp.send('Page.enable', null, sessionId); + await cdp.send('Runtime.enable', null, sessionId); + + // Navigation + console.log('--- Navigation Latency ---'); + const navResults = {}; + for (const [name, url] of URLS) { + const times = []; + for (let i = 0; i < ITERATIONS; i++) { + cdp.events = []; + const start = performance.now(); + await cdp.send('Page.navigate', { url }, sessionId); + try { + await cdp.waitForEvent('Page.loadEventFired', sessionId, 30000); + } catch { /* ok */ } + times.push((performance.now() - start) / 1000); + await sleep(500); + } + const med = median(times); + console.log(` ${name.padEnd(20)} median=${med.toFixed(3)}s [${times.map(t => t.toFixed(3)).join(', ')}]`); + navResults[name] = { median: med, raw: times }; + } + + // Settle on a page for operation benchmarks + await cdp.send('Page.navigate', { url: 'https://en.wikipedia.org/wiki/Main_Page' }, sessionId); + await sleep(3000); + cdp.events = []; + + console.log('\n--- CDP Operation Latency ---'); + + // Screenshot + const ssTimes = [], ssSizes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const resp = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId); + ssTimes.push((performance.now() - start) / 1000); + ssSizes.push(Buffer.from(resp.result?.data || '', 'base64').length); + await sleep(200); + } + console.log(` ${'Screenshot'.padEnd(20)} median=${median(ssTimes).toFixed(3)}s size=${(ssSizes.reduce((a,b)=>a+b,0)/ssSizes.length/1024).toFixed(0)}KB`); + + // JS Evaluate + const evalTimes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await cdp.send('Runtime.evaluate', { expression: 'document.title' }, sessionId); + evalTimes.push((performance.now() - start) / 1000); + } + console.log(` ${'JS Evaluate'.padEnd(20)} median=${(median(evalTimes)*1000).toFixed(1)}ms`); + + // Mouse Click + const clickTimes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 100, y: 100, button: 'left', clickCount: 1 }, sessionId); + clickTimes.push((performance.now() - start) / 1000); + } + console.log(` ${'Mouse Click'.padEnd(20)} median=${(median(clickTimes)*1000).toFixed(1)}ms`); + + // Keyboard Type + const typeTimes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + for (const ch of 'hello world') { + await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch }, sessionId); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp' }, sessionId); + } + typeTimes.push((performance.now() - start) / 1000); + } + console.log(` ${'Type 11 chars'.padEnd(20)} median=${(median(typeTimes)*1000).toFixed(1)}ms`); + + // Layout Metrics + const lmTimes = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + await cdp.send('Page.getLayoutMetrics', null, sessionId); + lmTimes.push((performance.now() - start) / 1000); + } + console.log(` ${'Layout Metrics'.padEnd(20)} median=${(median(lmTimes)*1000).toFixed(1)}ms`); + + // --- Resource snapshot AFTER workload --- + console.log('\n--- Resource Usage (after workload) ---'); + const memAfter = await getMemInfo(host); + console.log(` Memory total: ${memAfter.totalMB.toFixed(0)} MB`); + console.log(` Memory used: ${memAfter.usedMB.toFixed(0)} MB`); + console.log(` Memory available: ${memAfter.availableMB.toFixed(0)} MB`); + console.log(` Memory cached: ${memAfter.cachedMB.toFixed(0)} MB`); + + const cpuAfter = await getCPUInfo(host); + if (cpuAfter) { + console.log(` CPU busy: ${cpuAfter.busyPct.toFixed(1)}% (cumulative since boot)`); + } + + // Measure CPU during a short active period + console.log('\n--- CPU Usage (5s active sampling with page navigations) ---'); + const cpuStart = await getCPUInfo(host); + const wallStart = Date.now(); + // Drive some activity during the sampling + for (let i = 0; i < 3; i++) { + await cdp.send('Page.navigate', { url: URLS[i % URLS.length][1] }, sessionId); + await sleep(1500); + } + const cpuEnd = await getCPUInfo(host); + const wallElapsed = (Date.now() - wallStart) / 1000; + if (cpuStart && cpuEnd) { + const tickDelta = cpuEnd.total - cpuStart.total; + const busyDelta = cpuEnd.busy - cpuStart.busy; + const idleDelta = (cpuEnd.idle + cpuEnd.iowait) - (cpuStart.idle + cpuStart.iowait); + const cpuPct = tickDelta > 0 ? (busyDelta / tickDelta * 100) : 0; + console.log(` Wall time: ${wallElapsed.toFixed(1)}s`); + console.log(` CPU busy ticks: ${busyDelta} idle ticks: ${idleDelta} total: ${tickDelta}`); + console.log(` CPU utilization: ${cpuPct.toFixed(1)}%`); + } + + await cdp.send('Target.closeTarget', { targetId }); + cdp.close(); + + return { + memBefore, memAfter, cpuBefore, cpuAfter, + navResults, + screenshot: { median: median(ssTimes), avgSizeKB: ssSizes.reduce((a,b)=>a+b,0)/ssSizes.length/1024 }, + jsEvaluate: { medianMs: median(evalTimes) * 1000 }, + mouseClick: { medianMs: median(clickTimes) * 1000 }, + keyboardType: { medianMs: median(typeTimes) * 1000 }, + layoutMetrics: { medianMs: median(lmTimes) * 1000 }, + }; +} + +async function main() { + const instances = [ + ['BASELINE (v29 headless, no live view)', 'winter-mountain-2k9xdihk.dev-iad-unikraft-3.onkernel.app'], + ['CDP LIVE VIEW (headless + screencast)', 'autumn-shape-25lzr63z.dev-iad-unikraft-3.onkernel.app'], + ]; + + console.log('Warming up instances...'); + for (const [label, host] of instances) { + const ok = await warmup(host, label); + if (!ok) { console.log(`Skipping ${label}`); continue; } + } + + // Let instances settle after warmup + await sleep(5000); + + const allResults = {}; + for (const [label, host] of instances) { + try { + allResults[label] = await benchmarkInstance(host, label); + } catch (e) { + console.log(`\n ERROR benchmarking ${label}: ${e.message}\n`); + console.log(e.stack); + } + } + + const labels = Object.keys(allResults); + if (labels.length < 2) { + console.log('Not enough successful benchmarks to compare.'); + return; + } + + // --- Comparison --- + console.log(`\n${'='.repeat(90)}`); + console.log(' COMPARISON SUMMARY'); + console.log(`${'='.repeat(90)}\n`); + + const shortLabels = labels.map(l => l.includes('BASELINE') ? 'Baseline' : 'CDP LiveView'); + + let header = 'Metric'.padEnd(28); + for (const sl of shortLabels) header += sl.padStart(16); + header += ' Delta'.padStart(16); + console.log(header); + console.log('-'.repeat(76)); + + const r = labels.map(l => allResults[l]); + + const rows = [ + ['Mem used (before)', r.map(x => `${x.memBefore.usedMB.toFixed(0)} MB`), r.map(x => x.memBefore.usedMB)], + ['Mem used (after)', r.map(x => `${x.memAfter.usedMB.toFixed(0)} MB`), r.map(x => x.memAfter.usedMB)], + ['Mem available (after)', r.map(x => `${x.memAfter.availableMB.toFixed(0)} MB`), r.map(x => x.memAfter.availableMB)], + ['', [], []], + ...URLS.map(([name]) => [ + `Nav ${name}`, + r.map(x => `${x.navResults[name].median.toFixed(3)}s`), + r.map(x => x.navResults[name].median), + ]), + ['', [], []], + ['Screenshot', r.map(x => `${x.screenshot.median.toFixed(3)}s`), r.map(x => x.screenshot.median)], + ['Screenshot size', r.map(x => `${x.screenshot.avgSizeKB.toFixed(0)} KB`), r.map(x => x.screenshot.avgSizeKB)], + ['JS Evaluate', r.map(x => `${x.jsEvaluate.medianMs.toFixed(1)}ms`), r.map(x => x.jsEvaluate.medianMs)], + ['Mouse Click', r.map(x => `${x.mouseClick.medianMs.toFixed(1)}ms`), r.map(x => x.mouseClick.medianMs)], + ['Type 11 chars', r.map(x => `${x.keyboardType.medianMs.toFixed(1)}ms`), r.map(x => x.keyboardType.medianMs)], + ['Layout Metrics', r.map(x => `${x.layoutMetrics.medianMs.toFixed(1)}ms`), r.map(x => x.layoutMetrics.medianMs)], + ]; + + for (const [label, vals, nums] of rows) { + if (!label) { console.log(); continue; } + let row = label.padEnd(28); + for (const v of vals) row += v.padStart(16); + if (nums.length === 2) { + const delta = nums[1] - nums[0]; + const pct = nums[0] !== 0 ? (delta / nums[0] * 100) : 0; + const sign = delta >= 0 ? '+' : ''; + row += ` ${sign}${pct.toFixed(1)}%`.padStart(16); + } + console.log(row); + } + console.log(); +} + +main().catch(console.error); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..8fd347ef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "kernel-images", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kernel-images", + "dependencies": { + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/bun": "latest" + } + }, + "node_modules/@types/bun": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.10.tgz", + "integrity": "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.10" + } + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.10.tgz", + "integrity": "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/cmd/cdp-live-view/main.go b/server/cmd/cdp-live-view/main.go index 5fada7f1..6e7a125f 100644 --- a/server/cmd/cdp-live-view/main.go +++ b/server/cmd/cdp-live-view/main.go @@ -222,11 +222,51 @@ type server struct { targetID string cdpMu sync.Mutex + currentURL string + pageTitle string + stateMu sync.RWMutex // protects sessionID, targetID, currentURL, pageTitle + // sessions tracks targetID -> sessionID for attached targets sessions map[string]string sessionsMu sync.Mutex } +func (s *server) getSessionID() string { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.sessionID +} + +func (s *server) getTargetID() string { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.targetID +} + +func (s *server) setTargetState(targetID, sessionID string) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + s.sessionID = sessionID + s.targetID = targetID +} + +func (s *server) setPageInfo(url, title string) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if url != "" { + s.currentURL = url + } + if title != "" { + s.pageTitle = title + } +} + +func (s *server) getPageInfo() (string, string) { + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.currentURL, s.pageTitle +} + func (s *server) discoverBrowserWSURL(ctx context.Context) (string, error) { url := fmt.Sprintf("http://127.0.0.1:%s/json/version", s.cdpPort) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -287,6 +327,27 @@ func (s *server) connectCDP(ctx context.Context) error { s.handleScreencastFrame(s.ctx, params) }) + // Track URL changes via frame navigation + cdp.onEvent("Page.frameNavigated", func(params json.RawMessage) { + var ev struct { + Frame struct { + URL string `json:"url"` + ParentID string `json:"parentId"` + SecurityOrigin string `json:"securityOrigin"` + } `json:"frame"` + } + json.Unmarshal(params, &ev) + if ev.Frame.ParentID == "" { + s.setPageInfo(ev.Frame.URL, "") + s.broadcastURLUpdate() + } + }) + + // Track page title changes + cdp.onEvent("Page.domContentEventFired", func(params json.RawMessage) { + go s.fetchAndBroadcastTitle() + }) + // When a new page target is created, auto-switch to it cdp.onEvent("Target.targetCreated", func(params json.RawMessage) { var ev struct { @@ -303,22 +364,28 @@ func (s *server) connectCDP(ctx context.Context) error { } }) - // When a page navigates to a real URL, switch to it + // When a page navigates to a real URL, switch to it or update URL cdp.onEvent("Target.targetInfoChanged", func(params json.RawMessage) { var ev struct { TargetInfo struct { TargetID string `json:"targetId"` Type string `json:"type"` URL string `json:"url"` + Title string `json:"title"` } `json:"targetInfo"` } json.Unmarshal(params, &ev) if ev.TargetInfo.Type == "page" && ev.TargetInfo.URL != "" && ev.TargetInfo.URL != "about:blank" && ev.TargetInfo.URL != "chrome://newtab/" { - if ev.TargetInfo.TargetID != s.targetID { + currentTarget := s.getTargetID() + if ev.TargetInfo.TargetID != currentTarget { s.log.Info("page navigated, switching", "targetId", ev.TargetInfo.TargetID, "url", ev.TargetInfo.URL) go s.switchToTarget(s.ctx, ev.TargetInfo.TargetID) } + if ev.TargetInfo.TargetID == currentTarget { + s.setPageInfo(ev.TargetInfo.URL, ev.TargetInfo.Title) + s.broadcastURLUpdate() + } } }) @@ -396,17 +463,25 @@ func (s *server) switchToTarget(ctx context.Context, targetID string) error { } // Stop old screencast if running - if s.sessionID != "" && s.sessionID != sessionID { - s.cdp.callSession(ctx, s.sessionID, "Page.stopScreencast", nil) + oldSession := s.getSessionID() + if oldSession != "" && oldSession != sessionID { + s.cdp.callSession(ctx, oldSession, "Page.stopScreencast", nil) } - s.sessionID = sessionID - s.targetID = targetID + s.setTargetState(targetID, sessionID) // Enable Page domain s.cdp.callSession(ctx, sessionID, "Page.enable", nil) - // Start screencast (maxWidth/maxHeight control frame resolution without modifying the page viewport) + // Set viewport to match screencast dimensions so headless Chrome renders at full resolution + s.cdp.callSession(ctx, sessionID, "Emulation.setDeviceMetricsOverride", map[string]any{ + "width": s.width, + "height": s.height, + "deviceScaleFactor": 1, + "mobile": false, + }) + + // Start screencast _, err := s.cdp.callSession(ctx, sessionID, "Page.startScreencast", map[string]any{ "format": "jpeg", "quality": s.quality, @@ -419,9 +494,95 @@ func (s *server) switchToTarget(ctx context.Context, targetID string) error { } s.log.Info("screencast switched", "targetId", targetID, "sessionId", sessionID) + + // Fetch current URL from the navigation history + go func() { + result, err := s.cdp.callSession(ctx, sessionID, "Page.getNavigationHistory", nil) + if err != nil { + return + } + var nav struct { + CurrentIndex int `json:"currentIndex"` + Entries []struct { + URL string `json:"url"` + Title string `json:"title"` + } `json:"entries"` + } + json.Unmarshal(result, &nav) + if nav.CurrentIndex >= 0 && nav.CurrentIndex < len(nav.Entries) { + s.setPageInfo(nav.Entries[nav.CurrentIndex].URL, nav.Entries[nav.CurrentIndex].Title) + s.broadcastURLUpdate() + } + }() + return nil } +func (s *server) broadcastURLUpdate() { + url, title := s.getPageInfo() + msg, _ := json.Marshal(map[string]any{ + "type": "url_update", + "url": url, + "title": title, + }) + s.viewers.Range(func(key, value any) bool { + v := value.(*viewer) + writeCtx, cancel := context.WithTimeout(s.ctx, 500*time.Millisecond) + defer cancel() + v.ws.Write(writeCtx, websocket.MessageText, msg) + return true + }) +} + +func (s *server) fetchAndBroadcastTitle() { + sid := s.getSessionID() + if s.cdp == nil || sid == "" { + return + } + result, err := s.cdp.callSession(s.ctx, sid, "Runtime.evaluate", map[string]any{ + "expression": "document.title", + }) + if err != nil { + return + } + var evalResult struct { + Result struct { + Value string `json:"value"` + } `json:"result"` + } + json.Unmarshal(result, &evalResult) + if evalResult.Result.Value != "" { + s.setPageInfo("", evalResult.Result.Value) + s.broadcastURLUpdate() + } +} + +func (s *server) handleNavigation(ctx context.Context, ev inputEvent) { + sid := s.getSessionID() + if s.cdp == nil || sid == "" { + return + } + switch ev.Action { + case "back": + s.cdp.callSession(ctx, sid, "Runtime.evaluate", map[string]any{ + "expression": "history.back()", + }) + case "forward": + s.cdp.callSession(ctx, sid, "Runtime.evaluate", map[string]any{ + "expression": "history.forward()", + }) + case "reload": + s.cdp.callSession(ctx, sid, "Page.reload", nil) + case "navigate": + url := ev.URL + if url != "" { + s.cdp.callSession(ctx, sid, "Page.navigate", map[string]string{ + "url": url, + }) + } + } +} + func (s *server) handleScreencastFrame(ctx context.Context, params json.RawMessage) { var frame struct { Data string `json:"data"` @@ -439,9 +600,10 @@ func (s *server) handleScreencastFrame(ctx context.Context, params json.RawMessa return } - // Ack the frame to get the next one + // Ack the frame — capture sessionID now to avoid racing with switchToTarget + sid := s.getSessionID() go func() { - s.cdp.callSession(ctx, s.sessionID, "Page.screencastFrameAck", map[string]int{ + s.cdp.callSession(ctx, sid, "Page.screencastFrameAck", map[string]int{ "sessionId": frame.SessionID, }) }() @@ -488,10 +650,14 @@ type inputEvent struct { Code string `json:"code,omitempty"` Text string `json:"text,omitempty"` KeyCode int `json:"keyCode,omitempty"` + + Action string `json:"action,omitempty"` + URL string `json:"url,omitempty"` } func (s *server) handleInput(ctx context.Context, ev inputEvent) { - if s.cdp == nil || s.sessionID == "" { + sid := s.getSessionID() + if s.cdp == nil || sid == "" { return } @@ -515,7 +681,7 @@ func (s *server) handleInput(ctx context.Context, ev inputEvent) { params["deltaX"] = ev.DeltaX params["deltaY"] = ev.DeltaY } - s.cdp.callSession(ctx, s.sessionID, "Input.dispatchMouseEvent", params) + s.cdp.callSession(ctx, sid, "Input.dispatchMouseEvent", params) case "key": params := map[string]any{ @@ -529,7 +695,10 @@ func (s *server) handleInput(ctx context.Context, ev inputEvent) { if ev.Text != "" { params["text"] = ev.Text } - s.cdp.callSession(ctx, s.sessionID, "Input.dispatchKeyEvent", params) + s.cdp.callSession(ctx, sid, "Input.dispatchKeyEvent", params) + + case "navigate": + s.handleNavigation(ctx, ev) } } @@ -559,6 +728,19 @@ func (s *server) handleViewer(w http.ResponseWriter, r *http.Request) { return } + // Send current URL to newly connected viewer + currentURL, pageTitle := s.getPageInfo() + if currentURL != "" { + msg, _ := json.Marshal(map[string]any{ + "type": "url_update", + "url": currentURL, + "title": pageTitle, + }) + writeCtx, cancel := context.WithTimeout(s.ctx, 500*time.Millisecond) + ws.Write(writeCtx, websocket.MessageText, msg) + cancel() + } + for { _, data, err := ws.Read(s.ctx) if err != nil { diff --git a/server/cmd/cdp-live-view/viewer.html b/server/cmd/cdp-live-view/viewer.html index c4267256..10a944c2 100644 --- a/server/cmd/cdp-live-view/viewer.html +++ b/server/cmd/cdp-live-view/viewer.html @@ -3,66 +3,263 @@ Live View + -
- +
+ +
+
+
+
+
+ + +
+
+
+ New Tab +
×
+
+
+ + + + + +
+ +
-
connecting...
+ +
connecting...
+