From cb701fc2d813c1a2f27515217a47032d51fa8de2 Mon Sep 17 00:00:00 2001 From: Adam Ma Date: Fri, 15 May 2026 12:46:03 -0700 Subject: [PATCH] feat(cli): add --v8-flags plumbing to configure V8 at startup (#130) (#131) * feat(cli): add --v8-flags plumbing to configure V8 at startup (#130) Obscura had no way to pass flags to V8, so users hitting OOM on pages larger than ~5MB had no escape hatch. Adds a generic passthrough rather than a one-off heap knob, so future V8 tuning needs no new code. - New `obscura_js::set_v8_flags` (Once-guarded, empty-string no-op). - Global `--v8-flags ` arg with `allow_hyphen_values = true`, applied once in `main` before any isolate is created. - Usage: `obscura --v8-flags "--max-old-space-size=4096" fetch `. Closes #130. * test(cli): expand --v8-flags coverage with normalize helper Extract normalize_v8_flags() from main() so the trim/empty-check logic is unit-testable without needing to invoke V8. Adds parse-coverage tests for --v8-flags with serve/scrape subcommands and empty-string values, plus pure-function tests for the normalizer. --- crates/obscura-cli/src/main.rs | 116 +++++++++++++++++++++++++++++- crates/obscura-js/src/lib.rs | 2 + crates/obscura-js/src/v8_flags.rs | 37 ++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 crates/obscura-js/src/v8_flags.rs diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 34edb0e..92f17c5 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -27,6 +27,12 @@ struct Args { #[arg(long)] user_agent: Option, + + /// Pass raw flags to V8, in the same form V8/Chromium/Node accept + /// (e.g. `"--max-old-space-size=4096 --max-semi-space-size=64 --expose-gc"`). + /// Applied once at startup before any isolate is created. + #[arg(long, value_name = "FLAGS", allow_hyphen_values = true)] + v8_flags: Option, } #[derive(Subcommand)] @@ -182,6 +188,18 @@ fn merge_proxy(global_proxy: Option, command_proxy: Option) -> O command_proxy.or(global_proxy) } +/// Normalize a raw `--v8-flags` value into the string we'll hand to V8. +/// Returns `None` when the user didn't pass the flag, passed an empty string, +/// or passed only whitespace; in those cases V8 is left untouched. +fn normalize_v8_flags(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { let args = Args::parse(); @@ -196,6 +214,11 @@ async fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); + if let Some(flags) = normalize_v8_flags(args.v8_flags.as_deref()) { + tracing::info!("Applying V8 flags: {}", flags); + obscura_js::set_v8_flags(&flags); + } + let global_proxy = args.proxy.clone(); match args.command { @@ -906,7 +929,8 @@ fn dump_links(page: &Page) -> String { mod tests { use super::{ extract_readable_text, fetch_original_bytes, is_quiet_command, merge_proxy, - select_log_filter, write_or_print, write_or_print_bytes, Args, Command, DumpFormat, + normalize_v8_flags, select_log_filter, write_or_print, write_or_print_bytes, Args, + Command, DumpFormat, }; use clap::Parser; use obscura_dom::parse_html; @@ -1065,6 +1089,96 @@ mod tests { assert!(!is_quiet_command(&None)); } + #[test] + fn parsed_v8_flags_global_arg() { + let args = Args::try_parse_from([ + "obscura", + "--v8-flags", + "--max-old-space-size=4096 --max-semi-space-size=64", + "fetch", + "https://example.com", + ]) + .expect("clap should accept --v8-flags as a global arg"); + assert_eq!( + args.v8_flags.as_deref(), + Some("--max-old-space-size=4096 --max-semi-space-size=64"), + ); + } + + #[test] + fn v8_flags_default_is_none() { + let args = Args::try_parse_from(["obscura", "fetch", "https://example.com"]) + .expect("clap should accept fetch without --v8-flags"); + assert!(args.v8_flags.is_none()); + } + + #[test] + fn parsed_v8_flags_with_serve_subcommand() { + let args = Args::try_parse_from([ + "obscura", + "--v8-flags", + "--max-old-space-size=2048", + "serve", + "--port", + "9333", + ]) + .expect("clap should accept --v8-flags with serve"); + assert_eq!(args.v8_flags.as_deref(), Some("--max-old-space-size=2048")); + } + + #[test] + fn parsed_v8_flags_with_scrape_subcommand() { + let args = Args::try_parse_from([ + "obscura", + "--v8-flags", + "--expose-gc", + "scrape", + "https://a.com", + "https://b.com", + ]) + .expect("clap should accept --v8-flags with scrape"); + assert_eq!(args.v8_flags.as_deref(), Some("--expose-gc")); + } + + #[test] + fn parsed_v8_flags_empty_string_is_accepted() { + let args = Args::try_parse_from([ + "obscura", + "--v8-flags", + "", + "fetch", + "https://example.com", + ]) + .expect("clap should accept empty --v8-flags value"); + assert_eq!(args.v8_flags.as_deref(), Some("")); + } + + #[test] + fn normalize_v8_flags_returns_none_when_unset() { + assert_eq!(normalize_v8_flags(None), None); + } + + #[test] + fn normalize_v8_flags_returns_none_for_empty_or_whitespace() { + assert_eq!(normalize_v8_flags(Some("")), None); + assert_eq!(normalize_v8_flags(Some(" ")), None); + assert_eq!(normalize_v8_flags(Some("\t\n")), None); + } + + #[test] + fn normalize_v8_flags_trims_surrounding_whitespace() { + assert_eq!( + normalize_v8_flags(Some(" --max-old-space-size=4096 ")).as_deref(), + Some("--max-old-space-size=4096"), + ); + } + + #[test] + fn normalize_v8_flags_preserves_multi_flag_string() { + let input = "--max-old-space-size=4096 --max-semi-space-size=64 --expose-gc"; + assert_eq!(normalize_v8_flags(Some(input)).as_deref(), Some(input)); + } + #[test] fn parsed_fetch_quiet_resolves_to_off_filter() { let args = Args::try_parse_from([ diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index ddaebc6..39a2740 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -4,7 +4,9 @@ extern crate html5ever; pub mod module_loader; pub mod runtime; pub mod ops; +pub mod v8_flags; pub mod v8_lock; pub mod markdown; pub use markdown::HTML_TO_MARKDOWN_JS; +pub use v8_flags::set_v8_flags; diff --git a/crates/obscura-js/src/v8_flags.rs b/crates/obscura-js/src/v8_flags.rs new file mode 100644 index 0000000..c8659ab --- /dev/null +++ b/crates/obscura-js/src/v8_flags.rs @@ -0,0 +1,37 @@ +use std::sync::Once; + +static INIT: Once = Once::new(); + +/// Apply user-supplied V8 flags exactly once, before the first isolate is +/// created. +/// +/// `flags` is a raw V8 flag string in the same form V8/Chromium/Node accept +/// (e.g. `"--max-old-space-size=4096 --max-semi-space-size=64"`). An empty or +/// whitespace-only string is a no-op and does not consume the one-shot guard, +/// so a later non-empty call still takes effect. +/// +/// V8 ignores `set_flags_from_string` once the platform is initialized, so the +/// first non-empty call must run before any `JsRuntime` is constructed. +/// Subsequent calls are silently dropped. +pub fn set_v8_flags(flags: &str) { + let trimmed = flags.trim(); + if trimmed.is_empty() { + return; + } + INIT.call_once(|| { + deno_core::v8::V8::set_flags_from_string(trimmed); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_is_noop() { + // Must not panic and must not consume the Once guard. + set_v8_flags(""); + set_v8_flags(" "); + set_v8_flags("\t\n"); + } +}