End-to-end rasterization behind the render feature (which now enables
obscura-render/paint):
- obscura-render: screenshot_png(dom, viewport) paints and encodes to PNG
via tiny-skia's encode_png (no extra dep).
- obscura-browser: Page::screenshot renders the live DOM at a viewport.
- CLI: obscura fetch --screenshot out.png writes the settled page to a PNG.
- CDP: Page.captureScreenshot returns the PNG base64-encoded, so
Puppeteer/Playwright page.screenshot() works. captureSnapshot still
errors (it is a DOM/layer snapshot, not an image).
Verified: both paths produce correct 1280x720 RGBA PNGs with elements
laid out and colored as the CSS specifies (block stacking, backgrounds,
white page). Default build unchanged; obstacle course 33/33.
Text, borders, and images are not yet painted; the box layout and
background colors are rendered.
glibc hands each new thread its own 64 MiB arena (up to 8x cores). With one
thread per connection that arena is the dominant per-connection memory term,
and freed chunks stay reserved in it rather than returning to the OS: resident
memory tracks the *peak* number of concurrent connections and never comes back
down, which reads as a leak although nothing is leaked and no thread is stuck.
Measured with reliability/conn-scale.py in obscura-benchmark. 100 connections
each running JS reserve 90.0 GiB of address space uncapped versus 83.5 GiB
capped; per idle connection, 65 MiB of reserved address space drops to 2.0 MiB.
`main` at that same 100-connection JS workload reserves 83.6 GiB, so with the
cap this server is level with it -- most of the total is V8's process-wide
sandbox, which `main` pays too the moment it runs any JS.
The resident-set effect is the one that bites in production. Measured in the
container image against Google Maps, four concurrent connections per round:
350 / 619 / 826 MiB over three rounds uncapped and still climbing linearly,
versus 166 / 235 / 269 MiB capped, on a decelerating curve.
Two arenas cost no measurable throughput here -- 8 concurrent connections x 12
navigations, 1.53s uncapped against 1.50s capped over three runs. V8 allocates
the JS heap through its own allocator and the Rust side is dominated by network
I/O rather than malloc traffic. Applied only from `serve`, which owns the
process, and skipped when the caller has already set MALLOC_ARENA_MAX.
Refs #430.
Network.getResponseBody returns the whole body in one base64 blob and the
cache drops anything over 2 MiB, so large downloads (images, PDFs,
archives) either failed or forced the long-running server to hold the
full body in memory (issue #360).
Add Fetch.takeResponseBodyAsStream + IO.read + IO.close. The body is moved
out of the page cache into a stream handle and delivered in bounded chunks
via IO.read, then freed on IO.close, so it is held once and released after
the download. Binary bodies are byte-exact (stored base64). To stream a
large body, raise OBSCURA_NETWORK_BODY_BUFFER_BYTES so it is cached.
This is entirely opt-in: nothing runs unless a client calls the new
methods, so the default fetch/render/scrape path is unchanged (verified:
obstacle course 33/33, obscura-vs-Chrome head-to-head within noise of the
pre-change baseline).
Fixes#360.
* fix(#19): serialize V8 work across pages with process-wide async lock
Concurrent CDP clients (≥5) trip V8's
`heap->isolate() == Isolate::TryGetCurrent()` invariant and abort the
process via V8_Fatal — no Rust panic, just abort(3). Each Page owns its
own JsRuntime/Isolate and they all run on a single OS thread via
LocalSet+spawn_local; when two pages' V8-touching futures interleave
across an .await, V8 trips the check.
This adds a process-wide tokio::sync::Mutex (obscura_js::v8_lock) that
is acquired:
- At the top of obscura_cdp::dispatch::dispatch (covers every CDP
handler that may call into a JsRuntime).
- In server.rs's process_with_interception spawn_local that runs
page.navigate_with_wait + execute_preload_script (this path
bypasses dispatch).
The guard keeps each V8-touching block contiguous on the thread: V8
fully exits one Isolate before another page is allowed in. Trade-off:
non-V8 handlers (Browser.*, Storage.*, Emulation.*) also serialize,
but those are cheap and the safety win dominates.
The properly concurrent fix is to pin each JsRuntime to its own OS
thread and message-pass; tracked as the larger #19 follow-up.
Adds an opt-in smoke test (`--ignored`) that boots the CDP server and
runs 5 parallel clients through createTarget+navigate. Note: the
deterministic abort needs subresource-fetch yields (page.rs:285
join_all) which data: URLs skip — full repro requires JS-heavy real
traffic (issue author used scrapegraphai.com).
* fix(#19): suspend other Isolates and defer foreign CDP during interception nav
Address maintainer feedback on PR #36 (comment 4328238087): the V8 fatal
still reproduces with `Fetch.enable patterns:["*"]` + 5 concurrent
`Page.navigate`s. The `obscura_js::v8_lock` mutex was insufficient
because V8's actual invariant is "only one Isolate may be entered per
OS thread", and the mutex can't enforce that across `.await` points
inside V8 ops.
Two distinct paths violated the invariant:
1. Foreign CDP messages dispatched while a nav task was running.
`process_with_interception`'s `select!` routed every non-Fetch
message through `process_cdp_message → dispatch → page::handle::*`.
`get_session_page_mut` then `suspend_js`'es other pages (drops
their `JsRuntime` → `Isolate::Exit`) while the spawned nav task is
parked at `op_fetch_url`'s `resolve_rx.await` with its Isolate
still entered. Drop trips
`heap->isolate() == Isolate::TryGetCurrent()`.
2. Subsequent navs through `process_with_interception` did not
suspend prior pages. After nav-1 completes (page-1's JsRuntime
stays alive in `ctx.pages`), nav-2's `init_js` constructs
Isolate-2 while Isolate-1 is still current. The next V8 scope
unwind aborts via `Context::Exit`.
Fix (3 changes in `crates/obscura-cdp/src/server.rs`):
- `cdp_processor`: add a `VecDeque<ServerMessage> deferred` drained
before each new `rx.recv()`, so deferred messages are processed
with no nav task in flight.
- `process_with_interception` `select!`: only handle `NewConnection`
and `Fetch.{continue,fulfill,fail}Request` inline (safe — no V8
enter on this side); push everything else into `deferred`.
- `process_with_interception` entry: explicitly `suspend_js()` every
other page in `ctx.pages` before spawning the nav task, mirroring
the invariant the regular dispatch path already enforces via
`get_session_page_mut`.
Verification:
- New test `crates/obscura-cdp/tests/concurrent_navigations_with_fetch.rs`
is the maintainer's exact repro (5 clients, `Fetch.enable patterns:["*"]`,
concurrent `Page.navigate` to https://example.com, auto-reply
`Fetch.continueRequest`). Aborts on PR #36 head, passes after fix
(5/5 stable runs, ~1.2s each).
- Existing data-URL smoke test (`concurrent_navigations`) still passes.
- Full workspace test suite (90 tests) still passes.
Run with:
cargo test -p obscura-cdp --test concurrent_navigations_with_fetch \
-- --nocapture --ignored
Honest caveat. This is still the lock/serialize approach — it converts
the abort into latency. The properly concurrent fix (Option 2 in
issue #19: pin each `JsRuntime` to its own OS thread and message-pass)
remains the right long-term direction.
* fix(cdp): bound deferred message queue to prevent OOM during stalled navigation
PR #36 comment 4341743194: process_with_interception deferred foreign
CDP messages into an unbounded VecDeque while a navigation task was in
flight. A stalled navigation (e.g. waiting for Fetch interception
resolution) could accumulate arbitrary queued messages and OOM the
process.
Add MAX_DEFERRED_MESSAGES = 256. When the cap is reached, return an
explicit CDP error (-32000) to the client instead of silently dropping
or growing without bound.
All 90 workspace tests pass.