From db73abe96ed4a1608ed8863cef9fb3ec54665162 Mon Sep 17 00:00:00 2001 From: void0x14 Date: Fri, 29 May 2026 03:02:45 +0300 Subject: [PATCH 1/4] fix: clear latestCtx on executionContextDestroyed to prevent stale context routing When a subframe's execution context is destroyed, latestCtx was not cleared. This caused Runtime.evaluate (without contextId) to keep routing to the destroyed context, producing persistent 'Failed to find execution context' errors that never recovered. Fix: - executionContextDestroyed: clear latestCtx if it matches the destroyed context - executionContextsCleared: clear latestCtx for the session (navigation case) - Add test assertion verifying latestCtx is cleared after context destruction --- pkg/bridge/events.go | 17 ++++++++++++++++- pkg/bridge/events_test.go | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/bridge/events.go b/pkg/bridge/events.go index 37fe3a1..2d283b8 100644 --- a/pkg/bridge/events.go +++ b/pkg/bridge/events.go @@ -367,7 +367,7 @@ func (b *Bridge) SetupEventSubscriptions() { }) // Runtime.executionContextsCleared → Runtime.executionContextsCleared - // Also clear the ctxMap since all old context IDs are now stale + // Also clear the ctxMap and latestCtx since all old context IDs are now stale b.backend.Subscribe("Runtime.executionContextsCleared", func(jugglerSessionID string, params json.RawMessage) { cdpSessionID := b.resolveCDPSession(jugglerSessionID) if cdpSessionID != "" { @@ -376,6 +376,11 @@ func (b *Bridge) SetupEventSubscriptions() { b.ctxMap = make(map[int]string) b.ctxMapMu.Unlock() + // Clear latestCtx for this session to avoid stale context routing + b.latestCtxMu.Lock() + delete(b.latestCtx, jugglerSessionID) + b.latestCtxMu.Unlock() + b.emitEvent("Runtime.executionContextsCleared", map[string]interface{}{}, cdpSessionID) // Mark for isolated world re-emission @@ -528,6 +533,16 @@ func (b *Bridge) SetupEventSubscriptions() { b.ctxMapMu.Unlock() } + // Clear latestCtx if it points to the destroyed context. + // Without this, Runtime.evaluate without contextId would keep + // routing to a destroyed context, causing persistent + // "Failed to find execution context" errors. + b.latestCtxMu.Lock() + if b.latestCtx[jugglerSessionID] == ev.ExecutionContextID { + delete(b.latestCtx, jugglerSessionID) + } + b.latestCtxMu.Unlock() + b.emitEvent("Runtime.executionContextDestroyed", map[string]interface{}{ "executionContextId": numericID, "executionContextUniqueId": ev.ExecutionContextID, diff --git a/pkg/bridge/events_test.go b/pkg/bridge/events_test.go index 5fcccb9..7cbbcba 100644 --- a/pkg/bridge/events_test.go +++ b/pkg/bridge/events_test.go @@ -317,6 +317,11 @@ func TestSetupEventSubscriptions_ExecutionContextDestroyed(t *testing.T) { b.ctxMap[150] = "jug-ctx-1" b.ctxMapMu.Unlock() + // Pre-populate latestCtx to simulate a context that was once "latest" + b.latestCtxMu.Lock() + b.latestCtx["jug-s1"] = "jug-ctx-1" + b.latestCtxMu.Unlock() + mb.mu.Lock() handlers := mb.handlers["Runtime.executionContextDestroyed"] mb.mu.Unlock() @@ -335,6 +340,14 @@ func TestSetupEventSubscriptions_ExecutionContextDestroyed(t *testing.T) { if exists { t.Error("context mapping for 150 should have been removed") } + + // latestCtx should be cleared for the destroyed context + b.latestCtxMu.RLock() + latest := b.latestCtx["jug-s1"] + b.latestCtxMu.RUnlock() + if latest != "" { + t.Errorf("latestCtx for jug-s1 should be empty after context destruction, got %q", latest) + } } func TestSetupEventSubscriptions_AllEventsSubscribed(t *testing.T) { From 78e5bfe1713f69b5746faa89dcff903b2e353a37 Mon Sep 17 00:00:00 2001 From: void0x14 Date: Fri, 29 May 2026 12:36:18 +0300 Subject: [PATCH 2/4] fix: use TextMessage for uncompressed CDP frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BinaryMessage (opcode 0x02) was always sent regardless of compression state. CDP wire format is JSON text — engine recvWsTextAlloc only handles TEXT (0x01) and CONTINUATION (0x00) frames, causing WsFrameError. - Send: TextMessage when compress=false, BinaryMessage when compress=true - SendBatch: same fix SOURCE: Chrome DevTools Protocol — wire format is JSON text frames SOURCE: RFC 6455 Section 5.2 — opcode 0x01 = text, 0x02 = binary --- pkg/cdp/server.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/cdp/server.go b/pkg/cdp/server.go index b4f9df9..af77c61 100644 --- a/pkg/cdp/server.go +++ b/pkg/cdp/server.go @@ -61,8 +61,10 @@ func (c *Connection) Send(msg *Message) error { c.writeMu.Lock() defer c.writeMu.Unlock() - // Option 1: Binary framing (more efficient than text) - msgType := websocket.BinaryMessage + // CDP messages are JSON text — use TextMessage (opcode 0x01) unless compressed. + // SOURCE: Chrome DevTools Protocol — wire format is JSON text frames + // BinaryMessage (opcode 0x02) is only used when payload is compressed (flate). + msgType := websocket.TextMessage if c.compress { msgType = websocket.BinaryMessage } @@ -93,7 +95,12 @@ func (c *Connection) SendBatch(msgs []Message) error { c.writeMu.Lock() defer c.writeMu.Unlock() - msgType := websocket.BinaryMessage + // Use TextMessage for uncompressed, BinaryMessage for compressed. + // SOURCE: Chrome DevTools Protocol — wire format is JSON text frames + msgType := websocket.TextMessage + if c.compress { + msgType = websocket.BinaryMessage + } if err := c.ws.WriteMessage(msgType, data); err != nil { return err } From 4629a89524f12bb24acc8602b0cf463e4d18682e Mon Sep 17 00:00:00 2001 From: void0x14 Date: Fri, 29 May 2026 13:04:08 +0300 Subject: [PATCH 3/4] fix: mainCtx fallback when latestCtx cleared after subframe destruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subframe's execution context is destroyed, latestCtx is cleared (fix #1). But Juggler requires executionContextId in Runtime.evaluate. Without a fallback, foxbridge sends undefined → Juggler rejects. Add mainCtx map that tracks the main frame's execution context per session. Unlike latestCtx, mainCtx is updated on every main frame context creation (surviving navigation) and only cleared on executionContextsCleared (full navigation reset). Fallback chain: latestCtx → mainCtx → empty (Juggler error) This ensures Runtime.evaluate always has a valid contextId. Also fix binary framing: Send() now uses TextMessage (opcode 0x01) for uncompressed CDP frames instead of always BinaryMessage (0x02). Fixes #1 --- pkg/bridge/bridge.go | 6 ++++++ pkg/bridge/events.go | 19 +++++++++++++++++++ pkg/bridge/runtime.go | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/pkg/bridge/bridge.go b/pkg/bridge/bridge.go index d7aa6ad..702aa8f 100644 --- a/pkg/bridge/bridge.go +++ b/pkg/bridge/bridge.go @@ -29,6 +29,11 @@ type Bridge struct { // latestCtx tracks the most recent Juggler execution context per session latestCtxMu sync.RWMutex latestCtx map[string]string // jugglerSessionID → latest executionContextId + // mainCtx tracks the main frame's execution context per session. + // Unlike latestCtx, this survives subframe destruction — only cleared on navigation. + // Used as fallback when latestCtx is empty (subframe was destroyed). + mainCtxMu sync.RWMutex + mainCtx map[string]string // jugglerSessionID → main frame executionContextId // isolatedWorlds tracks isolated world names per CDP session for re-emission after navigation isolatedWorldsMu sync.RWMutex isolatedWorlds map[string][]isolatedWorldInfo // cdpSessionID → list of isolated worlds @@ -89,6 +94,7 @@ func New(b backend.Backend, sessions *cdp.SessionManager, server *cdp.Server, is ctxCounter: 100, loaderMap: make(map[string]string), latestCtx: make(map[string]string), + mainCtx: make(map[string]string), isolatedWorlds: make(map[string][]isolatedWorldInfo), nodeObjects: make(map[int]string), lastQuery: make(map[string]string), diff --git a/pkg/bridge/events.go b/pkg/bridge/events.go index 2d283b8..3aa68ad 100644 --- a/pkg/bridge/events.go +++ b/pkg/bridge/events.go @@ -381,6 +381,11 @@ func (b *Bridge) SetupEventSubscriptions() { delete(b.latestCtx, jugglerSessionID) b.latestCtxMu.Unlock() + // Clear mainCtx too — navigation creates entirely new contexts + b.mainCtxMu.Lock() + delete(b.mainCtx, jugglerSessionID) + b.mainCtxMu.Unlock() + b.emitEvent("Runtime.executionContextsCleared", map[string]interface{}{}, cdpSessionID) // Mark for isolated world re-emission @@ -432,6 +437,20 @@ func (b *Bridge) SetupEventSubscriptions() { b.latestCtx[jugglerSessionID] = ev.ExecutionContextID b.latestCtxMu.Unlock() + // Track the main frame context separately. This survives subframe destruction + // and is used as fallback when latestCtx is cleared. + // Only update for main frame contexts (matching session's FrameID). + b.mainCtxMu.Lock() + if info, ok := b.sessions.GetByJugglerSession(jugglerSessionID); ok { + if info.FrameID == "" || ev.AuxData.FrameID == info.FrameID { + b.mainCtx[jugglerSessionID] = ev.ExecutionContextID + } + } else { + // Session not yet registered — assume main frame + b.mainCtx[jugglerSessionID] = ev.ExecutionContextID + } + b.mainCtxMu.Unlock() + cdpFrameID := b.cdpFrameIDForJugglerSession(jugglerSessionID, ev.AuxData.FrameID) b.emitEvent("Runtime.executionContextCreated", map[string]interface{}{ diff --git a/pkg/bridge/runtime.go b/pkg/bridge/runtime.go index ca17fa7..4130ec6 100644 --- a/pkg/bridge/runtime.go +++ b/pkg/bridge/runtime.go @@ -32,6 +32,13 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latestCtx := b.latestCtx[jugglerSessionID] b.latestCtxMu.RUnlock() + // Fall back to mainCtx if latestCtx is empty (e.g., subframe was destroyed) + if latestCtx == "" { + b.mainCtxMu.RLock() + latestCtx = b.mainCtx[jugglerSessionID] + b.mainCtxMu.RUnlock() + } + if frameID != "" && latestCtx != "" { ctxID := b.nextCtxID() b.ctxMapMu.Lock() @@ -85,6 +92,14 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latest := b.latestContextForSession(msg.SessionID) if latest != "" { execCtxID = latest + } else { + // Fall back to main frame context when latestCtx is cleared (subframe destruction) + jugglerSessionID := b.resolveSession(msg.SessionID) + b.mainCtxMu.RLock() + if main := b.mainCtx[jugglerSessionID]; main != "" { + execCtxID = main + } + b.mainCtxMu.RUnlock() } // If awaitPromise is requested, wrap the expression so the promise is resolved @@ -147,6 +162,14 @@ func (b *Bridge) handleRuntime(conn *cdp.Connection, msg *cdp.Message) (json.Raw latest := b.latestContextForSession(msg.SessionID) if latest != "" { execCtxID = latest + } else { + // Fall back to main frame context when latestCtx is cleared + jugglerSessionID := b.resolveSession(msg.SessionID) + b.mainCtxMu.RLock() + if main := b.mainCtx[jugglerSessionID]; main != "" { + execCtxID = main + } + b.mainCtxMu.RUnlock() } } From 32ac70ca32d1f438589aa0636d12059c338a080a Mon Sep 17 00:00:00 2001 From: void0x14 Date: Fri, 29 May 2026 13:20:55 +0300 Subject: [PATCH 4/4] fix: normalizeRuntimeResult infers type from JSON value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of hardcoding type="undefined" when Juggler omits the type field, infer the actual type from the JSON value: - String value → type="string" - Boolean value → type="boolean" - Number value → type="number" This fixes "Runtime.evaluate expected string, got type=undefined" errors in CDP clients that check the type field. SOURCE: Chrome DevTools Protocol — Runtime.evaluate returns {result:{type,value}} --- pkg/bridge/runtime.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/bridge/runtime.go b/pkg/bridge/runtime.go index 4130ec6..007ade6 100644 --- a/pkg/bridge/runtime.go +++ b/pkg/bridge/runtime.go @@ -555,7 +555,21 @@ func normalizeRuntimeResult(result json.RawMessage) json.RawMessage { if _, ok := object["type"]; ok { return result } - object["type"] = json.RawMessage(`"undefined"`) + // Infer type from the actual JSON value instead of hardcoding "undefined". + // Juggler omits the type field — we must provide it for CDP clients that check. + // SOURCE: Chrome DevTools Protocol — Runtime.evaluate returns {result:{type,value}} + inferredType := `"undefined"` + if rawVal, ok := object["value"]; ok && string(rawVal) != "null" { + valStr := string(rawVal) + if len(valStr) > 0 && valStr[0] == '"' { + inferredType = `"string"` + } else if valStr == "true" || valStr == "false" { + inferredType = `"boolean"` + } else if valStr[0] >= '0' && valStr[0] <= '9' || valStr[0] == '-' { + inferredType = `"number"` + } + } + object["type"] = json.RawMessage(inferredType) normalized, err := json.Marshal(object) if err != nil { return result