Browser Evaluate CDP Refactor Plan

Kontext

act:evaluate führt vom Benutzer bereitgestelltes JavaScript auf der Seite aus. Heute läuft es über Playwright (page.evaluate oder locator.evaluate). Playwright serialisiert CDP-Befehle pro Seite, sodass ein hängendes oder lang laufendes Evaluate die Seiten-Befehlswarteschlange blockieren und jede spätere Aktion auf diesem Tab als „hängend” erscheinen lassen kann.

PR #13498 fügt ein pragmatisches Sicherheitsnetz hinzu (begrenztes Evaluate, Abort-Propagation und Best-Effort-Recovery). Dieses Dokument beschreibt einen größeren Refactor, der act:evaluate von Playwright inhärent isoliert, damit ein hängendes Evaluate normale Playwright-Operationen nicht blockieren kann.

Ziele

  • act:evaluate kann spätere Browser-Aktionen auf demselben Tab nicht dauerhaft blockieren.
  • Timeouts sind durchgängig Single Source of Truth, sodass sich ein Aufrufer auf ein Budget verlassen kann.
  • Abort und Timeout werden über HTTP und In-Process-Dispatch gleich behandelt.
  • Element-Targeting für Evaluate wird unterstützt, ohne alles von Playwright abzulösen.
  • Abwärtskompatibilität für bestehende Aufrufer und Payloads beibehalten.

Nicht-Ziele

  • Alle Browser-Aktionen (click, type, wait usw.) durch CDP-Implementierungen ersetzen.
  • Das bestehende, in PR #13498 eingeführte Sicherheitsnetz entfernen (es bleibt ein nützlicher Fallback).
  • Neue unsichere Fähigkeiten jenseits des bestehenden browser.evaluateEnabled-Gates einführen.
  • Prozessisolierung (Worker-Prozess/-Thread) für Evaluate hinzufügen. Falls nach diesem Refactor weiterhin schwer zu behebende Hänger auftreten, ist das ein Follow-up.

Aktuelle Architektur (Warum es hängt)

Auf hoher Ebene:

  • Aufrufer senden act:evaluate an den Browser-Control-Service.
  • Der Route-Handler ruft Playwright auf, um das JavaScript auszuführen.
  • Playwright serialisiert Seitenbefehle, sodass ein nie beendetes Evaluate die Queue blockiert.
  • Eine blockierte Queue bedeutet, dass spätere Click/Type/Wait-Operationen auf dem Tab hängen können.

Vorgeschlagene Architektur

1. Deadline-Propagation

Ein einzelnes Budget-Konzept einführen und alles daraus ableiten:

  • Aufrufer setzt timeoutMs (oder eine Deadline in der Zukunft).
  • Das äußere Request-Timeout, die Route-Handler-Logik und das Ausführungsbudget innerhalb der Seite nutzen alle dasselbe Budget, mit kleinem Headroom für Serialisierungs-Overhead wo nötig.
  • Abort wird überall als AbortSignal propagiert, damit Abbrüche konsistent sind.

Implementierungsrichtung:

  • Einen kleinen Helper hinzufügen (z. B. createBudget({ timeoutMs, signal })), der zurückgibt:
    • signal: das verknüpfte AbortSignal
    • deadlineAtMs: absolute Deadline
    • remainingMs(): verbleibendes Budget für Unteroperationen
  • Diesen Helper verwenden in:
    • src/browser/client-fetch.ts (HTTP und In-Process-Dispatch)
    • src/node-host/runner.ts (Proxy-Pfad)
    • Browser-Action-Implementierungen (Playwright und CDP)

2. Separate Evaluate-Engine (CDP-Pfad)

Eine CDP-basierte Evaluate-Implementierung hinzufügen, die Playwrights Per-Page-Befehlswarteschlange nicht teilt. Die Schlüsseleigenschaft ist, dass der Evaluate-Transport eine separate WebSocket-Verbindung und eine separate CDP-Session ist, die an das Ziel angehängt ist.

Implementierungsrichtung:

  • Neues Modul, z. B. src/browser/cdp-evaluate.ts, das:
    • Sich mit dem konfigurierten CDP-Endpunkt verbindet (Browser-Level-Socket).
    • Target.attachToTarget({ targetId, flatten: true }) nutzt, um eine sessionId zu erhalten.
    • Entweder ausführt:
      • Runtime.evaluate für seitenweites Evaluate, oder
      • DOM.resolveNode plus Runtime.callFunctionOn für Element-Evaluate.
    • Bei Timeout oder Abort:
      • Best-Effort Runtime.terminateExecution für die Session sendet.
      • Den WebSocket schließt und einen klaren Fehler zurückgibt.

Hinweise:

  • Dies führt weiterhin JavaScript auf der Seite aus, daher kann die Terminierung Seiteneffekte haben. Der Gewinn ist, dass es die Playwright-Queue nicht blockiert und auf Transportebene durch Beenden der CDP-Session abbrechbar ist.

3. Ref-Geschichte (Element-Targeting ohne komplettes Rewrite)

Der schwierige Teil ist Element-Targeting. CDP benötigt ein DOM-Handle oder eine backendDOMNodeId, während die meisten Browser-Aktionen heute Playwright-Locators basierend auf Refs aus Snapshots verwenden.

Empfohlener Ansatz: bestehende Refs beibehalten, aber optional eine CDP-auflösbare ID anhängen.

3.1 Gespeicherte Ref-Info erweitern

Die gespeicherten Role-Ref-Metadaten optional um eine CDP-ID erweitern:

  • Heute: { role, name, nth }
  • Vorgeschlagen: { role, name, nth, backendDOMNodeId?: number }

Dies lässt alle bestehenden Playwright-basierten Aktionen funktionieren und erlaubt CDP-Evaluate, denselben ref-Wert zu akzeptieren, wenn die backendDOMNodeId verfügbar ist.

3.2 backendDOMNodeId zum Snapshot-Zeitpunkt befüllen

Beim Erzeugen eines Role-Snapshots:

  1. Die bestehende Role-Ref-Map wie heute generieren (role, name, nth).
  2. Den AX-Tree über CDP holen (Accessibility.getFullAXTree) und eine parallele Map von (role, name, nth) -> backendDOMNodeId mit denselben Duplikat-Behandlungsregeln berechnen.
  3. Die ID zurück in die gespeicherten Ref-Info für den aktuellen Tab mergen.

Wenn das Mapping für einen Ref fehlschlägt, backendDOMNodeId undefiniert lassen. Das macht das Feature best-effort und sicher zum Ausrollen.

3.3 Evaluate-Verhalten mit Ref

In act:evaluate:

  • Wenn ref vorhanden ist und eine backendDOMNodeId hat, Element-Evaluate über CDP ausführen.
  • Wenn ref vorhanden ist, aber keine backendDOMNodeId hat, auf den Playwright-Pfad zurückfallen (mit dem Sicherheitsnetz).

Optionaler Notausgang:

  • Das Request-Shape erweitern, um backendDOMNodeId direkt für fortgeschrittene Aufrufer (und zum Debuggen) zu akzeptieren, während ref die primäre Schnittstelle bleibt.

4. Last-Resort-Recovery-Pfad beibehalten

Auch mit CDP-Evaluate gibt es andere Wege, einen Tab oder eine Verbindung zu blockieren. Die bestehenden Recovery-Mechanismen (Execution terminieren + Playwright disconnecten) als letzten Ausweg beibehalten für:

  • Legacy-Aufrufer
  • Umgebungen, in denen CDP-Attach blockiert ist
  • Unerwartete Playwright-Randfälle

Implementierungsplan (einzelne Iteration)

Liefergegenstände

  • Eine CDP-basierte Evaluate-Engine, die außerhalb der Playwright-Per-Page-Befehlswarteschlange läuft.
  • Ein einzelnes durchgängiges Timeout-/Abort-Budget, konsistent von Aufrufern und Handlern genutzt.
  • Ref-Metadaten, die optional backendDOMNodeId für Element-Evaluate tragen können.
  • act:evaluate bevorzugt die CDP-Engine wenn möglich und fällt auf Playwright zurück wenn nicht.
  • Tests, die beweisen, dass ein hängendes Evaluate spätere Aktionen nicht blockiert.
  • Logs/Metriken, die Fehler und Fallbacks sichtbar machen.

Implementierungs-Checkliste

  1. Einen gemeinsamen „Budget”-Helper hinzufügen, der timeoutMs + upstream AbortSignal verknüpft zu:
    • einem einzelnen AbortSignal
    • einer absoluten Deadline
    • einem remainingMs()-Helper für nachgelagerte Operationen
  2. Alle Aufruferpfade aktualisieren, damit timeoutMs überall dasselbe bedeutet:
    • src/browser/client-fetch.ts (HTTP und In-Process-Dispatch)
    • src/node-host/runner.ts (Node-Proxy-Pfad)
    • CLI-Wrapper, die /act aufrufen (--timeout-ms zu browser evaluate hinzufügen)
  3. src/browser/cdp-evaluate.ts implementieren:
    • Zum Browser-Level-CDP-Socket verbinden
    • Target.attachToTarget für eine sessionId
    • Runtime.evaluate für Seiten-Evaluate ausführen
    • DOM.resolveNode + Runtime.callFunctionOn für Element-Evaluate ausführen
    • Bei Timeout/Abort: Best-Effort Runtime.terminateExecution, dann Socket schließen
  4. Gespeicherte Role-Ref-Metadaten optional um backendDOMNodeId erweitern:
    • Bestehendes { role, name, nth }-Verhalten für Playwright-Aktionen beibehalten
    • backendDOMNodeId?: number für CDP-Element-Targeting hinzufügen
  5. backendDOMNodeId während der Snapshot-Erstellung befüllen (best-effort):
    • AX-Tree über CDP holen (Accessibility.getFullAXTree)
    • (role, name, nth) -> backendDOMNodeId berechnen und in die gespeicherte Ref-Map mergen
    • Wenn Mapping mehrdeutig oder fehlend ist, ID undefiniert lassen
  6. act:evaluate-Routing aktualisieren:
    • Wenn kein ref: immer CDP-Evaluate verwenden
    • Wenn ref zu einer backendDOMNodeId aufgelöst wird: CDP-Element-Evaluate verwenden
    • Sonst: auf Playwright-Evaluate zurückfallen (weiterhin begrenzt und abbrechbar)
  7. Den bestehenden „Last Resort”-Recovery-Pfad als Fallback beibehalten, nicht als Standardpfad.
  8. Tests hinzufügen:
    • Hängendes Evaluate hat Timeout innerhalb des Budgets und der nächste Click/Type funktioniert
    • Abort bricht Evaluate ab (Client-Disconnect oder Timeout) und entsperrt nachfolgende Aktionen
    • Mapping-Fehler fallen sauber auf Playwright zurück
  9. Beobachtbarkeit hinzufügen:
    • Evaluate-Dauer und Timeout-Zähler
    • terminateExecution-Nutzung
    • Fallback-Rate (CDP -> Playwright) und Gründe

Akzeptanzkriterien

  • Ein absichtlich hängendes act:evaluate kehrt innerhalb des Aufrufer-Budgets zurück und blockiert den Tab nicht für spätere Aktionen.
  • timeoutMs verhält sich konsistent über CLI, Agent-Tool, Node-Proxy und In-Process-Aufrufe.
  • Wenn ref auf backendDOMNodeId gemappt werden kann, nutzt Element-Evaluate CDP; andernfalls ist der Fallback-Pfad weiterhin begrenzt und wiederherstellbar.

Testplan

  • Unit-Tests:
    • (role, name, nth)-Matching-Logik zwischen Role-Refs und AX-Tree-Knoten.
    • Budget-Helper-Verhalten (Headroom, Restzeit-Berechnung).
  • Integrationstests:
    • CDP-Evaluate-Timeout kehrt innerhalb des Budgets zurück und blockiert die nächste Aktion nicht.
    • Abort bricht Evaluate ab und löst Best-Effort-Terminierung aus.
  • Vertragstests:
    • Sicherstellen, dass BrowserActRequest und BrowserActResponse kompatibel bleiben.

Risiken und Gegenmaßnahmen

  • Mapping ist unvollkommen:
    • Gegenmaßnahme: Best-Effort-Mapping, Fallback auf Playwright-Evaluate und Debug-Tooling hinzufügen.
  • Runtime.terminateExecution hat Seiteneffekte:
    • Gegenmaßnahme: Nur bei Timeout/Abort verwenden und das Verhalten in Fehlermeldungen dokumentieren.
  • Zusätzlicher Overhead:
    • Gegenmaßnahme: AX-Tree nur bei angefragten Snapshots holen, pro Ziel cachen und CDP-Session kurzlebig halten.
  • Extension-Relay-Einschränkungen:
    • Gegenmaßnahme: Browser-Level-Attach-APIs verwenden wenn Per-Page-Sockets nicht verfügbar sind, und den aktuellen Playwright-Pfad als Fallback behalten.

Offene Fragen

  • Sollte die neue Engine als playwright, cdp oder auto konfigurierbar sein?
  • Wollen wir ein neues „nodeRef”-Format für fortgeschrittene Benutzer einführen oder nur ref behalten?
  • Wie sollten Frame-Snapshots und Selector-Scoped-Snapshots am AX-Mapping teilnehmen?