Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ the implementation, and where should a change land first.
`read` fails in extension mode with `Separator is found, but chunk is longer than limit` or `chunk exceed the limit`
-> extension-side page extraction hit a large-result chunking limit on the current page
-> inspect `src/browser_cli/daemon/browser_service.py::read_page`, `src/browser_cli/drivers/_extension/page_actions.py`, and `browser-cli-extension/src/background/page_actions.js`; safe fix is a one-shot read fallback to Playwright rather than changing CLI contracts
`read` fails with `Extension disconnected` and daemon log shows `message too big` / `exceeds limit of 1048576`
-> extension tried to send a single oversized `response` frame (common on large pages such as WeChat articles)
-> inspect `browser-cli-extension/src/background/response_framing.js`, `src/browser_cli/extension/session.py` response-chunk reassembly, and `browser_service.read_page` fallback patterns; large responses must be framed as `response-chunk` messages, and stale extensions without framing should fall back to Playwright on read
- If the user reports popup/runtime observer drift:
start at `src/browser_cli/daemon/runtime_presentation.py`, then `src/browser_cli/extension/session.py`, then `browser-cli-extension/src/background.js`, `browser-cli-extension/src/popup_view.js`, and `browser-cli-extension/src/popup.js`.
- If the user reports long-run stability drift across repeated reloads, reconnects, or artifact runs:
Expand Down
2 changes: 1 addition & 1 deletion browser-cli-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Browser CLI Bridge",
"version": "0.1.0",
"version": "0.1.1",
"description": "Browser CLI real-Chrome backend extension.",
"minimum_chrome_version": "116",
"permissions": [
Expand Down
3 changes: 2 additions & 1 deletion browser-cli-extension/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createInputHandlers } from './background/input_actions.js';
import { createLocatorHandlers } from './background/locator_actions.js';
import { createObserveHandlers } from './background/observe_actions.js';
import { createPageHandlers } from './background/page_actions.js';
import { sendFramedMessage } from './background/response_framing.js';
import { createTraceHandlers } from './background/trace_actions.js';
import { createVideoHandlers } from './background/video_actions.js';
import { createWorkspaceHandlers } from './background/workspace.js';
Expand Down Expand Up @@ -232,7 +233,7 @@ async function connect() {
}
const response = await handleRequest(payload);
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(response));
sendFramedMessage(state.ws, response);
}
};
socket.onclose = () => {
Expand Down
31 changes: 31 additions & 0 deletions browser-cli-extension/src/background/response_framing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { RESPONSE_CHUNK_SIZE } from '../protocol.js';

/**
* Send a protocol message, chunking oversized JSON so the daemon WebSocket
* receiver never hits its default 1 MiB frame limit.
*/
export function sendFramedMessage(socket, message, chunkSize = RESPONSE_CHUNK_SIZE) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error('Extension socket is not connected.');
}
const encoded = JSON.stringify(message);
const size = Number(chunkSize) > 0 ? Number(chunkSize) : RESPONSE_CHUNK_SIZE;
if (encoded.length <= size) {
socket.send(encoded);
return 1;
}
const id = String(message.id || '');
let index = 0;
for (let offset = 0; offset < encoded.length; offset += size) {
const chunk = encoded.slice(offset, offset + size);
socket.send(JSON.stringify({
type: 'response-chunk',
id,
index,
final: offset + size >= encoded.length,
chunk,
}));
index += 1;
}
return index;
}
2 changes: 2 additions & 0 deletions browser-cli-extension/src/protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const PROTOCOL_VERSION = '1';
export const DEFAULT_DAEMON_HOST = '127.0.0.1';
export const DEFAULT_DAEMON_PORT = 19825;
export const ARTIFACT_CHUNK_SIZE = 256 * 1024;
// Keep framed WebSocket messages comfortably under the default 1 MiB limit.
export const RESPONSE_CHUNK_SIZE = 256 * 1024;

export const REQUIRED_CAPABILITIES = [
'open',
Expand Down
55 changes: 55 additions & 0 deletions browser-cli-extension/tests/response_framing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import test from 'node:test'
import assert from 'node:assert/strict'

import { RESPONSE_CHUNK_SIZE } from '../src/protocol.js'
import { sendFramedMessage } from '../src/background/response_framing.js'

function installSocketStub() {
const sent = []
return {
sent,
socket: {
readyState: 1,
send(payload) {
sent.push(payload)
},
},
}
}

test('sendFramedMessage sends small responses as a single frame', () => {
const { sent, socket } = installSocketStub()
const message = { type: 'response', id: 'req-1', ok: true, data: { html: '<p>hi</p>' } }
assert.equal(sendFramedMessage(socket, message), 1)
assert.equal(sent.length, 1)
assert.deepEqual(JSON.parse(sent[0]), message)
})

test('sendFramedMessage chunks oversized responses under the frame limit', () => {
const { sent, socket } = installSocketStub()
const html = 'x'.repeat(RESPONSE_CHUNK_SIZE + 128)
const message = { type: 'response', id: 'req-2', ok: true, data: { html } }
const frameCount = sendFramedMessage(socket, message, 1024)
assert.ok(frameCount > 1)
assert.equal(sent.length, frameCount)

const chunks = sent.map((raw) => JSON.parse(raw))
for (const chunk of chunks) {
assert.equal(chunk.type, 'response-chunk')
assert.equal(chunk.id, 'req-2')
assert.ok(JSON.stringify(chunk).length < RESPONSE_CHUNK_SIZE)
}
assert.equal(chunks.at(-1)?.final, true)
const assembled = chunks
.sort((left, right) => left.index - right.index)
.map((chunk) => chunk.chunk)
.join('')
assert.deepEqual(JSON.parse(assembled), message)
})

test('sendFramedMessage rejects a closed socket', () => {
assert.throws(
() => sendFramedMessage({ readyState: 3, send() {} }, { type: 'response', id: 'x' }),
/not connected/,
)
})
3 changes: 3 additions & 0 deletions src/browser_cli/daemon/browser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class BrowserService:
"Separator is not found, and chunk exceed the limit",
"chunk is longer than limit",
"chunk exceed the limit",
"Extension disconnected",
"message too big",
"exceeds limit of",
)

def __init__(
Expand Down
4 changes: 4 additions & 0 deletions src/browser_cli/extension/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
ALL_EXTENSION_CAPABILITIES,
ARTIFACT_CHUNK_SIZE,
CORE_EXTENSION_CAPABILITIES,
MAX_RESPONSE_CHUNK_INDEX,
OPTIONAL_EXTENSION_CAPABILITIES,
REQUIRED_EXTENSION_CAPABILITIES,
RESPONSE_CHUNK_SIZE,
ExtensionArtifactBegin,
ExtensionArtifactChunk,
ExtensionArtifactEnd,
Expand All @@ -21,6 +23,8 @@
"OPTIONAL_EXTENSION_CAPABILITIES",
"ALL_EXTENSION_CAPABILITIES",
"ARTIFACT_CHUNK_SIZE",
"RESPONSE_CHUNK_SIZE",
"MAX_RESPONSE_CHUNK_INDEX",
"ExtensionHello",
"ExtensionRequest",
"ExtensionResponse",
Expand Down
4 changes: 4 additions & 0 deletions src/browser_cli/extension/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
CORE_EXTENSION_CAPABILITIES = REQUIRED_EXTENSION_CAPABILITIES
PROTOCOL_VERSION = "1"
ARTIFACT_CHUNK_SIZE = 256 * 1024
# Keep framed WebSocket messages comfortably under the default 1 MiB limit.
RESPONSE_CHUNK_SIZE = 256 * 1024
# Cap chunk indexes so a hostile/malformed final index cannot force huge allocations.
MAX_RESPONSE_CHUNK_INDEX = 16_384


@dataclass(slots=True, frozen=True)
Expand Down
102 changes: 102 additions & 0 deletions src/browser_cli/extension/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from browser_cli.errors import ExtensionPortInUseError, OperationFailedError

from .protocol import (
MAX_RESPONSE_CHUNK_INDEX,
ExtensionArtifactBegin,
ExtensionArtifactChunk,
ExtensionArtifactEnd,
Expand Down Expand Up @@ -58,6 +59,49 @@ def to_payload(self) -> dict[str, Any]:
}


@dataclass(slots=True)
class _ResponseChunkBuffer:
chunks: dict[int, str] = field(default_factory=dict)

def append(self, index: int, chunk: str) -> None:
self.chunks[index] = chunk

def assemble(self) -> str:
if not self.chunks:
return ""
indexes = sorted(self.chunks)
min_index = indexes[0]
max_index = indexes[-1]
if min_index != 0 or len(indexes) != max_index + 1:
raise OperationFailedError(
"Extension response chunks are incomplete; "
f"received {len(indexes)} chunks covering {min_index}..{max_index}",
error_code="EXTENSION_RESPONSE_CHUNK_INCOMPLETE",
)
return "".join(self.chunks[index] for index in indexes)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _parse_response_chunk_index(raw: Any) -> int:
if isinstance(raw, bool) or raw is None:
raise OperationFailedError(
f"Extension response chunk index is invalid: {raw!r}",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
)
try:
index = int(raw)
except (TypeError, ValueError) as exc:
raise OperationFailedError(
f"Extension response chunk index is invalid: {raw!r}",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
) from exc
if index < 0 or index > MAX_RESPONSE_CHUNK_INDEX:
raise OperationFailedError(
f"Extension response chunk index out of bounds: {index}",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
)
return index


@dataclass(slots=True)
class ExtensionSession:
websocket: ServerConnection
Expand All @@ -66,13 +110,15 @@ class ExtensionSession:
_artifact_events: dict[str, asyncio.Event] = field(init=False, repr=False)
_artifact_buffers: dict[tuple[str, str], _ArtifactBuffer] = field(init=False, repr=False)
_completed_artifacts: dict[str, list[dict[str, Any]]] = field(init=False, repr=False)
_response_buffers: dict[str, _ResponseChunkBuffer] = field(init=False, repr=False)
_lock: asyncio.Lock = field(init=False, repr=False)

def __post_init__(self) -> None:
self._pending = {}
self._artifact_events = {}
self._artifact_buffers = {}
self._completed_artifacts = {}
self._response_buffers = {}
self._lock = asyncio.Lock()

@property
Expand Down Expand Up @@ -106,6 +152,7 @@ async def send_request(
finally:
self._pending.pop(request.id, None)
self._artifact_events.pop(request.id, None)
self._response_buffers.pop(request.id, None)
if not response.ok:
raise OperationFailedError(
response.error_message or f"Extension request failed: {action}",
Expand All @@ -123,6 +170,58 @@ def resolve_response(self, response: ExtensionResponse) -> None:
return
future.set_result(response)

def append_response_chunk(self, payload: dict[str, Any]) -> None:
request_id = str(payload.get("id") or "")
if not request_id:
return
future = self._pending.get(request_id)
if future is None or future.done():
self._response_buffers.pop(request_id, None)
return
try:
index = _parse_response_chunk_index(payload.get("index"))
except OperationFailedError as exc:
self._response_buffers.pop(request_id, None)
if not future.done():
future.set_exception(exc)
return
buffer = self._response_buffers.setdefault(request_id, _ResponseChunkBuffer())
buffer.append(index, str(payload.get("chunk") or ""))
if not bool(payload.get("final")):
return
try:
assembled = buffer.assemble()
message = json.loads(assembled)
if not isinstance(message, dict):
raise OperationFailedError(
"Extension chunked response is not a JSON object.",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
)
response = ExtensionResponse.from_message(message)
if response.id != request_id:
raise OperationFailedError(
"Extension chunked response id mismatch: "
f"expected {request_id}, got {response.id}",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
)
except OperationFailedError as exc:
self._response_buffers.pop(request_id, None)
if not future.done():
future.set_exception(exc)
return
except (json.JSONDecodeError, TypeError, ValueError) as exc:
self._response_buffers.pop(request_id, None)
if not future.done():
future.set_exception(
OperationFailedError(
f"Extension chunked response is invalid: {exc}",
error_code="EXTENSION_RESPONSE_CHUNK_INVALID",
)
)
return
self._response_buffers.pop(request_id, None)
self.resolve_response(response)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def fail_all(self, message: str) -> None:
for future in list(self._pending.values()):
if not future.done():
Expand All @@ -132,6 +231,7 @@ def fail_all(self, message: str) -> None:
self._pending.clear()
self._artifact_buffers.clear()
self._completed_artifacts.clear()
self._response_buffers.clear()
for event in list(self._artifact_events.values()):
event.set()
self._artifact_events.clear()
Expand Down Expand Up @@ -302,6 +402,8 @@ async def _handle_websocket(self, websocket: ServerConnection) -> None:
message_type = str(payload.get("type") or "")
if message_type == "response":
session.resolve_response(ExtensionResponse.from_message(dict(payload)))
elif message_type == "response-chunk":
session.append_response_chunk(dict(payload))
elif message_type == "artifact-begin":
session.begin_artifact(ExtensionArtifactBegin.from_message(dict(payload)))
elif message_type == "artifact-chunk":
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_daemon_browser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,44 @@ async def _playwright_capture_html(page_id: str) -> dict[str, str]:
asyncio.run(_scenario())


@pytest.mark.parametrize(
"extension_error",
[
"Extension disconnected.",
"message too big",
"exceeds limit of",
],
)
def test_read_page_falls_back_to_playwright_when_extension_disconnects_mid_read(
_patched_browser_service: _FakeExtensionHub,
extension_error: str,
) -> None:
async def _scenario() -> None:
_patched_browser_service.connect()
service = browser_service_module.BrowserService(TabRegistry())
await service.ensure_started()
assert service.active_driver_name == "extension"

async def _extension_capture_html(page_id: str) -> dict[str, str]:
raise RuntimeError(extension_error)

async def _playwright_capture_html(page_id: str) -> dict[str, str]:
return {"page_id": page_id, "html": "<html>ok</html>"}

service._extension.capture_html = _extension_capture_html # type: ignore[method-assign] # noqa: SLF001
service._playwright.capture_html = _playwright_capture_html # type: ignore[method-assign] # noqa: SLF001

payload = await service.read_page(url="https://example.com/large-doc", output_mode="html")

assert payload["body"] == "<html>ok</html>"
assert payload["driver_fallback"]["reason"] == "extension-read-fallback"
assert payload["driver_fallback"]["error"] == extension_error
assert service.active_driver_name == "playwright"
await service.stop()

asyncio.run(_scenario())


def test_browser_service_downgrade_survives_previous_driver_stop_failure(
_patched_browser_service: _FakeExtensionHub,
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading
Loading