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
22 changes: 13 additions & 9 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ jobs:
[ -n "$VERSION" ] || { echo "::error::could not parse [workspace.package] version"; exit 1; }
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "workspace version: $VERSION"
- name: Require a CHANGELOG section for this version
run: |
V='${{ steps.ver.outputs.version }}'
if grep -qE "^##+ \[?${V//./\\.}\]?" CHANGELOG.md; then
echo "CHANGELOG has a section for $V ✔"
else
echo "::error file=CHANGELOG.md::no '## [$V]' section found in CHANGELOG.md"
exit 1
fi
- name: Require a release-ready CHANGELOG section for this version
# Run the SAME validator the release workflows run, rather than a
# separate grep for the header. A header-only check passes while the
# release still fails, because `extract-release-notes.sh` additionally
# requires a `> subtitle` blockquote and at least one `### ` heading.
#
# That gap is not hypothetical: v0.1.2 passed this gate, was tagged and
# published to crates.io, and then `Release MCP binary` failed at
# "Generate release notes from CHANGELOG" with
# No '> subtitle' blockquote found under '## [0.1.2]'
# A post-tag failure is the expensive kind — the tag is already public.
# Validating with the real script means the PR catches it instead.
run: bash .github/scripts/extract-release-notes.sh --check '${{ steps.ver.outputs.version }}'
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,14 @@ jobs:
# tests (see CLAUDE.md). Network-bound and live anti-bot tests are
# `#[ignore]` and not run here.
- name: Test (single-threaded — V8 constraint)
run: cargo test --workspace --no-fail-fast -- --test-threads=1
# `--nocapture` matters for more than verbosity here. libtest captures
# each test's stdout/stderr and only replays it on failure — so when a
# test *aborts* the process (V8 FATAL, or a panic in a V8 callback that
# cannot unwind), the captured reason dies with it and the log shows a
# bare `signal: 6, SIGABRT` with no diagnosis. That is exactly what
# happened investigating #37. Streaming output unbuffered means the last
# thing printed before an abort is the abort's own message.
run: cargo test --workspace --no-fail-fast -- --test-threads=1 --nocapture
env:
CARGO_INCREMENTAL: "0"

Expand Down
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,69 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.1.3]

> Lands the `deno_core` 0.408 bump deferred from 0.1.2 — a V8 isolate must now be
> constructed inside an entered tokio runtime or the process aborts — and makes
> the V8 heap ceiling environment-tunable.

### Fixed
- **A V8 isolate constructed outside a tokio runtime aborted the process**
([#37](https://github.com/yfedoseev/browser_oxide/issues/37)). `deno_core`
0.408 captures `tokio::runtime::Handle::try_current()` when it registers an
isolate and spawns V8's *delayed* foreground tasks — GC memory-reducer work —
on that handle; with no handle it prints a diagnostic and calls
`std::process::abort()`. The synchronous constructors
(`BrowserJsRuntime::new` / `with_profile` / `with_options`) are public API
callable from a plain `fn main` or a `#[test]`, so they now enter a
process-lifetime fallback runtime when the caller has none. Applies to both
the page and worker realms.

Worth being precise, because it shaped the 0.1.2 release: the abort is
**not** debug-gated and **not** platform-specific. Release builds passed only
while V8 happened not to post a delayed task inside the window under test —
i.e. a latent production abort, which is why the bump was held back from
0.1.2 rather than shipped. Reproduced on Linux, macOS and Windows.

### Added
- **Environment-tunable V8 heap limits.** The right ceiling is a property of
the deployment, not of the engine, and the previous hard-coded 4 GB silently
over-committed small containers.
- `BROWSER_OXIDE_HEAP_MAX_MB` — default `4096` (4 GB)
- `BROWSER_OXIDE_HEAP_INITIAL_MB` — default `1024` (1 GB)

Unparseable or zero values fall back to the defaults with a warning rather
than failing; an initial above the ceiling is clamped, since V8 rejects that
pairing. Both are per-**isolate**, so a `PagePool` of N pages can commit up
to N × the ceiling — see [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).

### Dependencies
- `deno_core` 0.404 → **0.408** (V8 149.2.0 → 149.4.0), the bump deferred from
0.1.2.

### Changed
- CI runs `cargo test` with `--nocapture`. Not cosmetic: libtest captures each
test's output and replays it only on failure, so when a test *aborts* the
reason dies with it. That is why #37 first surfaced as a bare
`signal: 6, SIGABRT` with no diagnosis. Any future abort-on-construction
would otherwise be undiagnosable from CI logs alone.

### Verified
- Canvas fingerprint byte-identical across the V8 bump —
`examples/canvas_fp_probe.rs` reports `len=17502 fnv1a=5b1d42ee9bdc9713`, the
same value as 0.1.1 and 0.1.2.
- Real-site regression vs `main`, 15 open sites, both engine paths: zero
regressions; the warm-reuse fix from 0.1.2 still holds (pool 11/15 → 12/15).
- Full CI matrix green on ubuntu (stable/beta/nightly), macOS and Windows —
including the debug jobs that previously aborted.

## [0.1.2]

> Fixes an unbounded V8 heap leak in `PagePool` warm reuse that was also silently
> corrupting render output — two real sites returned 9-byte bodies on the second
> page through the pool. Adds `Page::reset_for_reuse()`, refreshes the dependency
> tree, and clears two RUSTSEC advisories.

### Fixed
- **`PagePool` / warm reuse leaked V8 heap without bound**
([#33](https://github.com/yfedoseev/browser_oxide/issues/33)). Reusing a
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = ["crates/browser_oxide", "crates/browser_oxide_mcp"]
exclude = ["crates/browser_oxide_py"]

[workspace.package]
version = "0.1.2"
version = "0.1.3"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Yury Fedoseev"]
Expand Down Expand Up @@ -39,7 +39,7 @@ doc_overindented_list_items = "allow"
# The single engine crate — depended on by browser_oxide_mcp. Explicit
# version pinned to the workspace version so cargo-deny's `wildcards =
# "deny"` rule doesn't trip on path-only `version = "*"` resolution.
browser_oxide = { version = "0.1.2", path = "crates/browser_oxide" }
browser_oxide = { version = "0.1.3", path = "crates/browser_oxide" }

# Async runtime + common derive deps shared by multiple crates.
tokio = { version = "1", features = ["full"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/browser_oxide/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
async-trait = "0.1"
# --- V8 / JS ---
deno_core = "0.404"
deno_core = "0.408"
deno_error = "0.7"
# --- serialization ---
serde = { version = "1", features = ["derive"] }
Expand Down
121 changes: 114 additions & 7 deletions crates/browser_oxide/src/js_runtime/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,108 @@ pub struct BrowserRuntimeOptions {
/// (some sites expect a navigation to begin within a few seconds).
/// Keeps this fn for existing callers
/// that don't need the signal.
/// Default V8 heap ceiling, in MiB. Overridable via
/// `BROWSER_OXIDE_HEAP_MAX_MB`.
pub const DEFAULT_HEAP_MAX_MB: usize = 4096;

/// Default initial V8 heap reservation, in MiB. Overridable via
/// `BROWSER_OXIDE_HEAP_INITIAL_MB`.
///
/// Not 256 MB: that caused early-growth GC pauses on fingerprint-heavy sites,
/// where a heavy probe allocates well past 256 MB in a single pass and V8 spent
/// time compacting old space before growing the heap. 1 GB skips those early
/// compactions.
pub const DEFAULT_HEAP_INITIAL_MB: usize = 1024;

/// Resolve `(initial, max)` V8 heap limits in bytes.
///
/// Both are environment-tunable, which matters because the right ceiling is a
/// property of the deployment, not of the engine: a 512 MB container and a
/// 64 GB scraping host want very different numbers, and the previous
/// hard-coded 4 GB silently over-committed the former.
///
/// - `BROWSER_OXIDE_HEAP_MAX_MB` — ceiling, default
/// [`DEFAULT_HEAP_MAX_MB`] (4 GB).
/// - `BROWSER_OXIDE_HEAP_INITIAL_MB` — initial reservation, default
/// [`DEFAULT_HEAP_INITIAL_MB`] (1 GB).
///
/// Unparseable or zero values fall back to the defaults rather than failing:
/// a typo in an env var should not take down a scrape. An initial larger than
/// the max is clamped down to the max, since V8 treats that combination as a
/// hard error.
fn heap_limits() -> (usize, usize) {
fn mb_from_env(key: &str, default_mb: usize) -> usize {
match std::env::var(key) {
Ok(raw) => match raw.trim().parse::<usize>() {
Ok(mb) if mb > 0 => mb,
_ => {
tracing::warn!(
env = key,
value = %raw,
default_mb,
"ignoring unparseable/zero heap limit; using default"
);
default_mb
}
},
Err(_) => default_mb,
}
}

let max_mb = mb_from_env("BROWSER_OXIDE_HEAP_MAX_MB", DEFAULT_HEAP_MAX_MB);
let initial_mb = mb_from_env("BROWSER_OXIDE_HEAP_INITIAL_MB", DEFAULT_HEAP_INITIAL_MB);
let initial_mb = initial_mb.min(max_mb);

const MIB: usize = 1024 * 1024;
(initial_mb * MIB, max_mb * MIB)
}

/// Guarantee a tokio runtime is entered for the duration of `JsRuntime`
/// construction, falling back to a process-lifetime runtime if the caller has
/// none.
///
/// Required as of `deno_core` 0.408. It captures
/// `tokio::runtime::Handle::try_current()` at isolate-registration time
/// (`runtime/jsruntime.rs`) and spawns V8's *delayed* foreground tasks — GC
/// memory-reducer tasks and friends — on that handle. When the handle is
/// `None`, `runtime/setup.rs::spawn_delayed_task` prints a diagnostic and calls
/// `std::process::abort()`. Upstream aborts rather than panics deliberately:
/// V8 invokes it from C++ frames Rust cannot unwind through.
///
/// Two things worth being precise about, because both misled the 0.1.2
/// investigation (#37):
///
/// 1. **This is not debug-only.** The abort is unconditional. Release builds
/// pass only while V8 happens not to post a delayed task in the window
/// being exercised, which is timing, not safety.
/// 2. **It is not the caller's bug to fix.** `BrowserJsRuntime::new` /
/// `with_profile` / `with_options` are synchronous public API, callable from
/// a plain `fn main` or a `#[test]`. Requiring every embedder to wrap
/// construction in a runtime would be a silent breaking change whose
/// failure mode is a process abort.
///
/// The captured handle must stay valid for the isolate's whole life, so the
/// fallback runtime is a `OnceLock` living to process exit — a temporary would
/// leave the isolate holding a handle to a dropped runtime.
fn ensure_tokio_context() -> Option<tokio::runtime::EnterGuard<'static>> {
if tokio::runtime::Handle::try_current().is_ok() {
return None;
}
static FALLBACK_RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
let rt = FALLBACK_RT.get_or_init(|| {
// Single worker + timer driver is all V8's delayed tasks need: they
// sleep, push onto the isolate's foreground queue, and wake it. The
// work itself is drained synchronously by our own event loop.
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_time()
.thread_name("browser-oxide-v8-delayed")
.build()
.expect("failed to build fallback tokio runtime for V8 delayed tasks")
});
Some(rt.enter())
}

pub fn create_runtime(dom: Dom, options: BrowserRuntimeOptions) -> JsRuntime {
create_runtime_with_signals(dom, options).0
}
Expand Down Expand Up @@ -115,13 +217,13 @@ pub fn create_runtime_with_signals(
// property descriptors across every WebIDL interface). Real Chrome on
// a desktop has 4 GB+ available per renderer; we mirror that.
//
// HEAP_INITIAL was 256 MB but caused early-growth GC pauses on
// fingerprint-heavy sites (a heavy probe allocates well past 256 MB
// during its pass; V8 spent time compacting old space before
// growing the heap). 1 GB initial skips those early compactions.
const HEAP_INITIAL: usize = 1024 * 1024 * 1024; // 1 GB initial
const HEAP_MAX: usize = 4 * 1024 * 1024 * 1024; // 4 GB max
let create_params = deno_core::v8::CreateParams::default().heap_limits(HEAP_INITIAL, HEAP_MAX);
let (heap_initial, heap_max) = heap_limits();
let create_params = deno_core::v8::CreateParams::default().heap_limits(heap_initial, heap_max);

// Must outlive the `JsRuntime::new` call below — deno_core captures the
// current tokio handle during isolate registration. See
// `ensure_tokio_context`.
let _tokio_guard = ensure_tokio_context();

let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
Expand Down Expand Up @@ -339,6 +441,11 @@ pub fn create_worker_runtime(
profile: Option<StealthProfile>,
is_secure_context: bool,
) -> JsRuntime {
// Same requirement as the page runtime — worker realms are built on their
// own threads, which may not have a runtime entered. See
// `ensure_tokio_context`.
let _tokio_guard = ensure_tokio_context();

let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
console_extension::init(),
Expand Down
Loading
Loading