Merge pull request #601 from h4ckf0r0day/fix/module-dedupe-v8-oom
fix(js): prevent duplicate module evaluation and contain V8 heap exhaustion
This commit is contained in:
@@ -82,6 +82,11 @@ pub struct ObscuraModuleLoader {
|
||||
standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>,
|
||||
import_map: Rc<RefCell<ImportMap>>,
|
||||
activity: Arc<ModuleLoadActivity>,
|
||||
/// Canonical and requested specifiers fetched into deno_core's module map.
|
||||
/// The runtime uses a cursor into this append-only list to associate a
|
||||
/// prepared root with the dependencies that its successful evaluation also
|
||||
/// evaluates.
|
||||
loaded_specifiers: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl ObscuraModuleLoader {
|
||||
@@ -110,6 +115,7 @@ impl ObscuraModuleLoader {
|
||||
standalone_client: Some(standalone_client),
|
||||
import_map,
|
||||
activity: Arc::new(ModuleLoadActivity::default()),
|
||||
loaded_specifiers: Rc::new(RefCell::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +132,17 @@ impl ObscuraModuleLoader {
|
||||
standalone_client: None,
|
||||
import_map,
|
||||
activity: Arc::new(ModuleLoadActivity::default()),
|
||||
loaded_specifiers: Rc::new(RefCell::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activity(&self) -> Arc<ModuleLoadActivity> {
|
||||
self.activity.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn loaded_specifiers(&self) -> Rc<RefCell<Vec<String>>> {
|
||||
self.loaded_specifiers.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn io_err(msg: String) -> ModuleLoaderError {
|
||||
@@ -194,6 +205,8 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
// plain Option<String> rather than borrowing &self across an `await`.
|
||||
let proxy_url = self.proxy_url.clone();
|
||||
let activity = self.activity.clone();
|
||||
let loaded_specifiers = self.loaded_specifiers.clone();
|
||||
loaded_specifiers.borrow_mut().push(url.clone());
|
||||
// Register before returning the future. The lifecycle can inspect the
|
||||
// runtime between deno_core accepting the load and first polling it.
|
||||
// Keeping the guard inside the future makes cancellation/navigation
|
||||
@@ -252,6 +265,11 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
let found = ModuleSpecifier::parse(resp.url.as_str()).map_err(|e| {
|
||||
io_err(format!("Invalid final module URL {}: {}", resp.url, e))
|
||||
})?;
|
||||
if found.as_str() != requested.as_str() {
|
||||
loaded_specifiers
|
||||
.borrow_mut()
|
||||
.push(found.to_string());
|
||||
}
|
||||
let code = obscura_net::decode_non_html(&resp.body, resp.content_type());
|
||||
Ok(ModuleSource::new_with_redirect(
|
||||
deno_core::ModuleType::JavaScript,
|
||||
|
||||
@@ -51,6 +51,41 @@ static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
||||
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
const DEFAULT_CDP_AWAIT_TIMEOUT_MS: u64 = 30_000;
|
||||
const HEAP_LIMIT_RECOVERY_HEADROOM_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeapLimitState {
|
||||
tripped: std::sync::atomic::AtomicBool,
|
||||
restore_limit: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
fn install_heap_limit_guard(
|
||||
runtime: &mut JsRuntime,
|
||||
isolate_handle: IsolateHandle,
|
||||
state: std::sync::Arc<HeapLimitState>,
|
||||
) {
|
||||
runtime.add_near_heap_limit_callback(move |current_limit, _initial_limit| {
|
||||
let _ = state.restore_limit.compare_exchange(
|
||||
0,
|
||||
current_limit,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
);
|
||||
state
|
||||
.tripped
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
isolate_handle.terminate_execution();
|
||||
current_limit.saturating_add(HEAP_LIMIT_RECOVERY_HEADROOM_BYTES)
|
||||
});
|
||||
}
|
||||
|
||||
fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> &str {
|
||||
payload
|
||||
.downcast_ref::<String>()
|
||||
.map(String::as_str)
|
||||
.or_else(|| payload.downcast_ref::<&'static str>().copied())
|
||||
.unwrap_or("unknown panic")
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn with_sync_render_loading_disabled<R>(
|
||||
@@ -94,6 +129,22 @@ pub struct ObscuraJsRuntime {
|
||||
/// construction. Lets a watchdog be armed from `&self` (the CDP dispatcher
|
||||
/// only holds `&Page` on the hot path) and is stable for the isolate's life.
|
||||
isolate_handle: IsolateHandle,
|
||||
/// Signals that V8 approached its configured heap limit. The callback
|
||||
/// terminates the current script and temporarily raises the limit just
|
||||
/// enough for V8 to unwind instead of aborting the worker process.
|
||||
heap_limit_state: std::sync::Arc<HeapLimitState>,
|
||||
/// Browser module-map evaluation is idempotent. deno_core 0.350 asserts if
|
||||
/// the same ModuleId is evaluated twice, so retain the first outcome for
|
||||
/// duplicate script tags and roots already seen by Obscura.
|
||||
module_evaluations: HashMap<deno_core::ModuleId, Result<(), String>>,
|
||||
/// Append-only record owned by the module loader. A cursor around each
|
||||
/// graph load identifies the dependency specifiers that become evaluated
|
||||
/// with its root.
|
||||
loaded_module_specifiers: Rc<RefCell<Vec<String>>>,
|
||||
/// Successful graph evaluation also evaluates every dependency. Remember
|
||||
/// those URLs so a dependency later encountered as a top-level script is a
|
||||
/// browser-style no-op instead of a second deno_core `mod_evaluate` call.
|
||||
evaluated_module_specifiers: HashMap<String, Result<(), String>>,
|
||||
}
|
||||
|
||||
/// A fetched and instantiated module graph whose evaluation is intentionally
|
||||
@@ -101,6 +152,8 @@ pub struct ObscuraJsRuntime {
|
||||
pub struct PreparedModule {
|
||||
module_id: deno_core::ModuleId,
|
||||
description: String,
|
||||
entry_specifier: Option<String>,
|
||||
graph_specifiers: Vec<String>,
|
||||
}
|
||||
|
||||
fn remaining_deadline_ms(deadline: tokio::time::Instant) -> Option<u64> {
|
||||
@@ -197,6 +250,10 @@ impl ObscuraJsRuntime {
|
||||
/// read. Keeping one sample across the task also lets repeated CSSOM reads
|
||||
/// share the retained layout on pages with running animations.
|
||||
fn begin_javascript_task(&mut self) {
|
||||
// Some internal callers intentionally ignore script errors. Recover a
|
||||
// heap-limit termination before any later task enters V8 even when the
|
||||
// caller that triggered it did not need the error value.
|
||||
self.recover_heap_limit();
|
||||
#[cfg(feature = "render")]
|
||||
begin_animation_task(&mut self.state.borrow_mut());
|
||||
}
|
||||
@@ -223,11 +280,12 @@ impl ObscuraJsRuntime {
|
||||
import_map.clone(),
|
||||
);
|
||||
let module_load_activity = module_loader.activity();
|
||||
let loaded_module_specifiers = module_loader.loaded_specifiers();
|
||||
let module_loader = Rc::new(module_loader);
|
||||
|
||||
// Build the isolate under the process-wide creation lock so two
|
||||
// connection threads never construct isolates concurrently (#430).
|
||||
let (runtime, isolate_handle) = {
|
||||
let (runtime, isolate_handle, heap_limit_state) = {
|
||||
let _create_guard = ISOLATE_CREATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
@@ -241,6 +299,14 @@ impl ObscuraJsRuntime {
|
||||
|
||||
runtime.op_state().borrow_mut().put(state_clone);
|
||||
|
||||
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
|
||||
let heap_limit_state = std::sync::Arc::new(HeapLimitState::default());
|
||||
install_heap_limit_guard(
|
||||
&mut runtime,
|
||||
isolate_handle.clone(),
|
||||
heap_limit_state.clone(),
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script(
|
||||
"<obscura:init>",
|
||||
@@ -248,8 +314,7 @@ impl ObscuraJsRuntime {
|
||||
)
|
||||
.expect("init should not fail");
|
||||
|
||||
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
|
||||
(runtime, isolate_handle)
|
||||
(runtime, isolate_handle, heap_limit_state)
|
||||
};
|
||||
|
||||
ObscuraJsRuntime {
|
||||
@@ -260,9 +325,61 @@ impl ObscuraJsRuntime {
|
||||
import_map,
|
||||
module_load_activity,
|
||||
isolate_handle,
|
||||
heap_limit_state,
|
||||
module_evaluations: HashMap::new(),
|
||||
loaded_module_specifiers,
|
||||
evaluated_module_specifiers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the configured V8 heap limit after the emergency headroom has
|
||||
/// allowed a terminated allocation to unwind. The callback is then armed
|
||||
/// again so a second hostile script cannot grow the isolate without bound.
|
||||
fn recover_heap_limit(&mut self) -> bool {
|
||||
if !self
|
||||
.heap_limit_state
|
||||
.tripped
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.runtime.v8_isolate().cancel_terminate_execution();
|
||||
let restore_limit = self
|
||||
.heap_limit_state
|
||||
.restore_limit
|
||||
.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
self.runtime
|
||||
.remove_near_heap_limit_callback(restore_limit);
|
||||
install_heap_limit_guard(
|
||||
&mut self.runtime,
|
||||
self.isolate_handle.clone(),
|
||||
self.heap_limit_state.clone(),
|
||||
);
|
||||
tracing::warn!("V8 heap limit reached: terminated the current JavaScript task");
|
||||
true
|
||||
}
|
||||
|
||||
fn finish_heap_checked<T>(&mut self, result: Result<T, String>) -> Result<T, String> {
|
||||
if self.recover_heap_limit() {
|
||||
Err("JavaScript heap limit exceeded; execution terminated".to_string())
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_runtime_script(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
source: String,
|
||||
) -> Result<deno_core::v8::Global<deno_core::v8::Value>, String> {
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script(name, source)
|
||||
.map_err(|error| error.to_string());
|
||||
self.finish_heap_checked(result)
|
||||
}
|
||||
|
||||
/// Parse and merge an inline document import map. Rules which would alter
|
||||
/// already-observed module resolutions are discarded while unrelated new
|
||||
/// rules remain available, matching Chromium's multiple-map model.
|
||||
@@ -406,7 +523,7 @@ impl ObscuraJsRuntime {
|
||||
|
||||
pub fn set_user_agent(&mut self, ua: &str) {
|
||||
let escaped = ua.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<set-ua>",
|
||||
format!("globalThis.__obscura_ua = '{}';", escaped),
|
||||
);
|
||||
@@ -416,7 +533,7 @@ impl ObscuraJsRuntime {
|
||||
let p = platform.replace('\'', "\\'");
|
||||
let uap = ua_platform.replace('\'', "\\'");
|
||||
let uapv = ua_platform_version.replace('\'', "\\'");
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<set-platform>",
|
||||
format!(
|
||||
"globalThis.__obscura_platform='{}';globalThis.__obscura_ua_platform='{}';globalThis.__obscura_ua_platform_version='{}';",
|
||||
@@ -426,7 +543,7 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
|
||||
pub fn set_stealth(&mut self, enabled: bool) {
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<set-stealth>",
|
||||
format!("globalThis.__obscura_stealth = {};", enabled),
|
||||
);
|
||||
@@ -450,7 +567,7 @@ impl ObscuraJsRuntime {
|
||||
state.resolved_scroll = None;
|
||||
}
|
||||
}
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<set-viewport>",
|
||||
format!(
|
||||
"globalThis.__obscura_viewport_w={width};\
|
||||
@@ -490,7 +607,7 @@ impl ObscuraJsRuntime {
|
||||
"globalThis.__obscura_set_screen_override(null,null,{emulated});"
|
||||
),
|
||||
};
|
||||
let _ = self.runtime.execute_script("<set-screen-size>", script);
|
||||
let _ = self.execute_runtime_script("<set-screen-size>", script);
|
||||
}
|
||||
|
||||
/// Current clamped root scroll offset shared by CSSOM geometry and paint.
|
||||
@@ -916,7 +1033,7 @@ impl ObscuraJsRuntime {
|
||||
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
|
||||
/// have been set. Must be called once per page setup, after all set_* methods.
|
||||
pub fn run_page_init(&mut self) {
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<obscura:page-init>",
|
||||
"globalThis.__obscura_init();".to_string(),
|
||||
);
|
||||
@@ -926,7 +1043,7 @@ impl ObscuraJsRuntime {
|
||||
/// values are injected as numeric globals the bootstrap reads; when unset it
|
||||
/// keeps the built-in default. Callers validate the range before calling.
|
||||
pub fn set_geolocation(&mut self, latitude: f64, longitude: f64) {
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<set-geo>",
|
||||
format!(
|
||||
"globalThis.__obscura_geo_lat={};globalThis.__obscura_geo_lon={};",
|
||||
@@ -939,8 +1056,7 @@ impl ObscuraJsRuntime {
|
||||
self.begin_javascript_task();
|
||||
let wrapped = Self::wrap_expression(expression);
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<eval>", wrapped)
|
||||
.execute_runtime_script("<eval>", wrapped)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
self.v8_to_json(result)
|
||||
}
|
||||
@@ -1025,8 +1141,7 @@ impl ObscuraJsRuntime {
|
||||
};
|
||||
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<eval-remote>", meta_code)
|
||||
.execute_runtime_script("<eval-remote>", meta_code)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
|
||||
let meta_str = if await_promise {
|
||||
@@ -1035,8 +1150,7 @@ impl ObscuraJsRuntime {
|
||||
let settled = self
|
||||
.resolve_promises_until(
|
||||
|rt| {
|
||||
rt.runtime
|
||||
.execute_script("<done?>", sentinel.clone())
|
||||
rt.execute_runtime_script("<done?>", sentinel.clone())
|
||||
.ok()
|
||||
.and_then(|v| rt.v8_to_json(v).ok())
|
||||
.and_then(|j| j.as_bool())
|
||||
@@ -1064,22 +1178,20 @@ impl ObscuraJsRuntime {
|
||||
);
|
||||
}
|
||||
let rejected = self
|
||||
.runtime
|
||||
.execute_script(
|
||||
.execute_runtime_script(
|
||||
"<readRejected>",
|
||||
"globalThis.__obscura_await_rejected".to_string(),
|
||||
)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
if self.v8_to_json(rejected)?.as_bool().unwrap_or(false) {
|
||||
let err = self.runtime.execute_script("<readError>", format!("String(globalThis.__obscura_objects['{0}'] && (globalThis.__obscura_objects['{0}'].message || globalThis.__obscura_objects['{0}']))", oid))
|
||||
let err = self.execute_runtime_script("<readError>", format!("String(globalThis.__obscura_objects['{0}'] && (globalThis.__obscura_objects['{0}'].message || globalThis.__obscura_objects['{0}']))", oid))
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
return Err(format!(
|
||||
"Promise rejected: {}",
|
||||
self.v8_to_json(err)?.as_str().unwrap_or("")
|
||||
));
|
||||
}
|
||||
self.runtime
|
||||
.execute_script("<readMeta>", "globalThis.__obscura_await_meta".to_string())
|
||||
self.execute_runtime_script("<readMeta>", "globalThis.__obscura_await_meta".to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?
|
||||
} else {
|
||||
result
|
||||
@@ -1097,8 +1209,7 @@ impl ObscuraJsRuntime {
|
||||
|
||||
if await_promise && return_by_value {
|
||||
let read = self
|
||||
.runtime
|
||||
.execute_script(
|
||||
.execute_runtime_script(
|
||||
"<readResult>",
|
||||
format!("globalThis.__obscura_objects['{}']", oid),
|
||||
)
|
||||
@@ -1176,8 +1287,7 @@ impl ObscuraJsRuntime {
|
||||
done_counter = done_counter,
|
||||
);
|
||||
|
||||
self.runtime
|
||||
.execute_script("<callFnAsync>", code)
|
||||
self.execute_runtime_script("<callFnAsync>", code)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
|
||||
let __t0 = std::time::Instant::now();
|
||||
@@ -1185,8 +1295,7 @@ impl ObscuraJsRuntime {
|
||||
let settled = self
|
||||
.resolve_promises_until(
|
||||
|rt| {
|
||||
rt.runtime
|
||||
.execute_script("<done?>", sentinel.clone())
|
||||
rt.execute_runtime_script("<done?>", sentinel.clone())
|
||||
.ok()
|
||||
.and_then(|v| rt.v8_to_json(v).ok())
|
||||
.and_then(|j| j.as_bool())
|
||||
@@ -1216,8 +1325,7 @@ impl ObscuraJsRuntime {
|
||||
|
||||
if return_by_value {
|
||||
let read = self
|
||||
.runtime
|
||||
.execute_script(
|
||||
.execute_runtime_script(
|
||||
"<readResult>",
|
||||
format!("globalThis.__obscura_objects['{}']", oid),
|
||||
)
|
||||
@@ -1227,8 +1335,7 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
|
||||
let meta_result = self
|
||||
.runtime
|
||||
.execute_script("<readMeta>", "globalThis.__obscura_await_meta".to_string())
|
||||
.execute_runtime_script("<readMeta>", "globalThis.__obscura_await_meta".to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
let meta_str = self.v8_to_json(meta_result)?;
|
||||
let meta_json = if let serde_json::Value::String(s) = &meta_str {
|
||||
@@ -1257,8 +1364,7 @@ impl ObscuraJsRuntime {
|
||||
args = args_list,
|
||||
);
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<callFnByValue>", code)
|
||||
.execute_runtime_script("<callFnByValue>", code)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
let json_val = self.v8_to_json(result)?;
|
||||
return Ok(Self::info_from_json(&json_val));
|
||||
@@ -1281,8 +1387,7 @@ impl ObscuraJsRuntime {
|
||||
meta_fn = Self::meta_extract_js("__result"),
|
||||
);
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<callFnRemote>", code)
|
||||
.execute_runtime_script("<callFnRemote>", code)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
let meta_str = self.v8_to_json(result)?;
|
||||
let meta_json = if let serde_json::Value::String(s) = &meta_str {
|
||||
@@ -1320,8 +1425,7 @@ impl ObscuraJsRuntime {
|
||||
"globalThis.__obscura_objects['{}'] = ({});",
|
||||
oid, js_expression,
|
||||
);
|
||||
self.runtime
|
||||
.execute_script("<store>", code)
|
||||
self.execute_runtime_script("<store>", code)
|
||||
.map_err(|e| format!("Store error: {}", e))?;
|
||||
self.object_store.insert(
|
||||
oid.clone(),
|
||||
@@ -1348,8 +1452,7 @@ impl ObscuraJsRuntime {
|
||||
meta_fn = Self::meta_extract_js("__result"),
|
||||
);
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<store-meta>", code)
|
||||
.execute_runtime_script("<store-meta>", code)
|
||||
.map_err(|e| format!("Store error: {}", e))?;
|
||||
let meta_str = self.v8_to_json(result)?;
|
||||
let meta_json = if let serde_json::Value::String(s) = &meta_str {
|
||||
@@ -1367,12 +1470,12 @@ impl ObscuraJsRuntime {
|
||||
pub fn release_object(&mut self, object_id: &str) {
|
||||
if self.object_store.remove(object_id).is_some() {
|
||||
let code = format!("delete globalThis.__obscura_objects['{}'];", object_id,);
|
||||
let _ = self.runtime.execute_script("<release>", code);
|
||||
let _ = self.execute_runtime_script("<release>", code);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release_object_group(&mut self) {
|
||||
let _ = self.runtime.execute_script(
|
||||
let _ = self.execute_runtime_script(
|
||||
"<releaseGroup>",
|
||||
"globalThis.__obscura_objects = {};".to_string(),
|
||||
);
|
||||
@@ -1398,6 +1501,7 @@ impl ObscuraJsRuntime {
|
||||
let budget = tokio::time::Duration::from_millis(budget_ms);
|
||||
let specifier = deno_core::ModuleSpecifier::parse(url)
|
||||
.map_err(|e| format!("Invalid module URL {}: {}", url, e))?;
|
||||
let loaded_start = self.loaded_module_specifiers.borrow().len();
|
||||
|
||||
// Bound the recursive import-graph fetch. deno_core fetches the graph
|
||||
// concurrently through the one page-scoped module loader. Loading the
|
||||
@@ -1425,9 +1529,16 @@ impl ObscuraJsRuntime {
|
||||
// Return as soon as the module finishes evaluating rather than waiting
|
||||
// for the loop to go fully idle: a page timer (setInterval) keeps the
|
||||
// loop busy forever and would otherwise burn the whole budget (#374).
|
||||
let mut graph_specifiers = self.loaded_module_specifiers.borrow()[loaded_start..].to_vec();
|
||||
graph_specifiers.push(specifier.to_string());
|
||||
graph_specifiers.sort_unstable();
|
||||
graph_specifiers.dedup();
|
||||
|
||||
Ok(PreparedModule {
|
||||
module_id,
|
||||
description: format!("Module {}", url),
|
||||
entry_specifier: Some(specifier.to_string()),
|
||||
graph_specifiers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1447,9 +1558,34 @@ impl ObscuraJsRuntime {
|
||||
budget_ms: u64,
|
||||
what: &str,
|
||||
) -> Result<(), String> {
|
||||
if let Some(outcome) = self.module_evaluations.get(&module_id) {
|
||||
return outcome.clone();
|
||||
}
|
||||
|
||||
self.begin_javascript_task();
|
||||
let budget = tokio::time::Duration::from_millis(budget_ms);
|
||||
let result = self.runtime.mod_evaluate(module_id);
|
||||
// deno_core 0.350 asserts instead of treating a second evaluation as
|
||||
// the module-map no-op required by browsers. The local outcome cache
|
||||
// covers duplicate roots prepared by Obscura. A root can also have
|
||||
// been evaluated earlier as another graph's dependency, which is only
|
||||
// observable when mod_evaluate checks V8's private module status, so
|
||||
// contain that dependency assertion at this boundary as well.
|
||||
let evaluation = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.runtime.mod_evaluate(module_id)
|
||||
}));
|
||||
let result = match evaluation {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
let message = panic_payload_message(payload.as_ref());
|
||||
let outcome = if message.contains("Module already evaluated") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} evaluation panicked: {}", what, message))
|
||||
};
|
||||
self.module_evaluations.insert(module_id, outcome.clone());
|
||||
return outcome;
|
||||
}
|
||||
};
|
||||
tokio::pin!(result);
|
||||
|
||||
let outcome = tokio::time::timeout(budget, async {
|
||||
@@ -1465,14 +1601,17 @@ impl ObscuraJsRuntime {
|
||||
})
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
let outcome = match outcome {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(format!("{} eval error: {}", what, e)),
|
||||
Err(_) => Err(format!(
|
||||
"{} evaluation timed out after {}ms",
|
||||
what, budget_ms
|
||||
)),
|
||||
}
|
||||
};
|
||||
let outcome = self.finish_heap_checked(outcome);
|
||||
self.module_evaluations.insert(module_id, outcome.clone());
|
||||
outcome
|
||||
}
|
||||
|
||||
pub async fn load_inline_module(
|
||||
@@ -1508,6 +1647,7 @@ impl ObscuraJsRuntime {
|
||||
// each prepared module distinct until its scheduled evaluation.
|
||||
let specifier = deno_core::ModuleSpecifier::parse(base_url)
|
||||
.unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap());
|
||||
let loaded_start = self.loaded_module_specifiers.borrow().len();
|
||||
|
||||
let module_id = match tokio::time::timeout(
|
||||
budget,
|
||||
@@ -1533,9 +1673,17 @@ impl ObscuraJsRuntime {
|
||||
// keeps the loop busy forever, and waiting for idle burned the whole
|
||||
// budget on this preamble module and starved the module that mounts the
|
||||
// app, leaving #root empty (issue #374).
|
||||
let mut graph_specifiers = self.loaded_module_specifiers.borrow()[loaded_start..].to_vec();
|
||||
graph_specifiers.sort_unstable();
|
||||
graph_specifiers.dedup();
|
||||
|
||||
Ok(PreparedModule {
|
||||
module_id,
|
||||
description: "Inline module".to_string(),
|
||||
// Multiple inline modules intentionally share the document URL,
|
||||
// but each has its own source and ModuleId.
|
||||
entry_specifier: None,
|
||||
graph_specifiers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1547,7 +1695,15 @@ impl ObscuraJsRuntime {
|
||||
let PreparedModule {
|
||||
module_id,
|
||||
description,
|
||||
entry_specifier,
|
||||
graph_specifiers,
|
||||
} = prepared;
|
||||
if let Some(outcome) = entry_specifier
|
||||
.as_ref()
|
||||
.and_then(|specifier| self.evaluated_module_specifiers.get(specifier))
|
||||
{
|
||||
return outcome.clone();
|
||||
}
|
||||
// Tokio timeouts cannot run while synchronous top-level module work
|
||||
// pins the runtime thread in V8. Pair the async timeout with a hard V8
|
||||
// watchdog so this budget is a real wall-clock ceiling for both forms
|
||||
@@ -1557,14 +1713,25 @@ impl ObscuraJsRuntime {
|
||||
.drive_module_eval(module_id, budget_ms, &description)
|
||||
.await;
|
||||
let watchdog_fired = self.disarm_watchdog(watchdog);
|
||||
if watchdog_fired {
|
||||
let result = if watchdog_fired {
|
||||
Err(format!(
|
||||
"{} evaluation timed out after {}ms",
|
||||
description, budget_ms
|
||||
))
|
||||
} else {
|
||||
result
|
||||
};
|
||||
|
||||
if let Some(entry_specifier) = entry_specifier {
|
||||
self.evaluated_module_specifiers
|
||||
.insert(entry_specifier, result.clone());
|
||||
}
|
||||
if result.is_ok() {
|
||||
for specifier in graph_specifiers {
|
||||
self.evaluated_module_specifiers.insert(specifier, Ok(()));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> {
|
||||
@@ -1573,53 +1740,60 @@ impl ObscuraJsRuntime {
|
||||
// &'static str. Browser script URLs are runtime data, and V8 uses this
|
||||
// origin as import()'s referrer, so compile in the runtime's main
|
||||
// context directly instead of substituting the fixed "<script>" name.
|
||||
let scope = &mut self.runtime.handle_scope();
|
||||
let source = deno_core::v8::String::new(scope, source)
|
||||
.ok_or_else(|| "JS error: source allocation failed".to_string())?;
|
||||
let name = deno_core::v8::String::new(scope, name)
|
||||
.ok_or_else(|| "JS error: script URL allocation failed".to_string())?;
|
||||
let origin = deno_core::v8::ScriptOrigin::new(
|
||||
scope,
|
||||
name.into(),
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let scope = &mut deno_core::v8::TryCatch::new(scope);
|
||||
let script = deno_core::v8::Script::compile(scope, source, Some(&origin));
|
||||
let Some(script) = script else {
|
||||
if scope.is_execution_terminating() {
|
||||
scope.cancel_terminate_execution();
|
||||
return Err("JS error: Uncaught Error: execution terminated".to_string());
|
||||
}
|
||||
return match scope.exception() {
|
||||
Some(exception) => {
|
||||
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
|
||||
Err(format!("JS error: {error}"))
|
||||
let result = (|| {
|
||||
let scope = &mut self.runtime.handle_scope();
|
||||
let source = deno_core::v8::String::new(scope, source)
|
||||
.ok_or_else(|| "JS error: source allocation failed".to_string())?;
|
||||
let name = deno_core::v8::String::new(scope, name)
|
||||
.ok_or_else(|| "JS error: script URL allocation failed".to_string())?;
|
||||
let origin = deno_core::v8::ScriptOrigin::new(
|
||||
scope,
|
||||
name.into(),
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let scope = &mut deno_core::v8::TryCatch::new(scope);
|
||||
let script = deno_core::v8::Script::compile(scope, source, Some(&origin));
|
||||
let Some(script) = script else {
|
||||
if scope.is_execution_terminating() {
|
||||
scope.cancel_terminate_execution();
|
||||
return Err("JS error: Uncaught Error: execution terminated".to_string());
|
||||
}
|
||||
None => Err("JS error: script compilation failed without an exception".to_string()),
|
||||
return match scope.exception() {
|
||||
Some(exception) => {
|
||||
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
|
||||
Err(format!("JS error: {error}"))
|
||||
}
|
||||
None => {
|
||||
Err("JS error: script compilation failed without an exception".to_string())
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
if script.run(scope).is_none() {
|
||||
if scope.is_execution_terminating() {
|
||||
scope.cancel_terminate_execution();
|
||||
return Err("JS error: Uncaught Error: execution terminated".to_string());
|
||||
}
|
||||
return match scope.exception() {
|
||||
Some(exception) => {
|
||||
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
|
||||
Err(format!("JS error: {error}"))
|
||||
if script.run(scope).is_none() {
|
||||
if scope.is_execution_terminating() {
|
||||
scope.cancel_terminate_execution();
|
||||
return Err("JS error: Uncaught Error: execution terminated".to_string());
|
||||
}
|
||||
None => Err("JS error: script execution failed without an exception".to_string()),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
return match scope.exception() {
|
||||
Some(exception) => {
|
||||
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
|
||||
Err(format!("JS error: {error}"))
|
||||
}
|
||||
None => {
|
||||
Err("JS error: script execution failed without an exception".to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
self.finish_heap_checked(result)
|
||||
}
|
||||
|
||||
pub fn execute_script(&mut self, name: &str, source: &str) -> Result<(), String> {
|
||||
@@ -1706,7 +1880,7 @@ impl ObscuraJsRuntime {
|
||||
.await
|
||||
.map_err(|e| format!("Event loop error: {}", e));
|
||||
self.runtime.v8_isolate().perform_microtask_checkpoint();
|
||||
result
|
||||
self.finish_heap_checked(result)
|
||||
}
|
||||
|
||||
/// Whether the serialized dynamic-script queue is still fetching or
|
||||
@@ -1850,6 +2024,7 @@ impl ObscuraJsRuntime {
|
||||
};
|
||||
let fired = self.disarm_watchdog(token);
|
||||
match result {
|
||||
Err(error) if error.contains("heap limit exceeded") => Err(error),
|
||||
Err(error) if fired || error.contains("execution terminated") => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
@@ -1887,7 +2062,7 @@ impl ObscuraJsRuntime {
|
||||
self.begin_javascript_task();
|
||||
self.runtime.v8_isolate().perform_microtask_checkpoint();
|
||||
let mut waiting_for_wake = false;
|
||||
std::future::poll_fn(|cx| {
|
||||
let result = std::future::poll_fn(|cx| {
|
||||
let tick = self
|
||||
.runtime
|
||||
.poll_event_loop(cx, deno_core::PollEventLoopOptions::default());
|
||||
@@ -1905,7 +2080,8 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.await;
|
||||
self.finish_heap_checked(result)
|
||||
}
|
||||
|
||||
/// Drive one browser task while allowing the future to remain parked on
|
||||
@@ -1935,10 +2111,13 @@ impl ObscuraJsRuntime {
|
||||
self.cancel_termination();
|
||||
return Err("autonomous microtask checkpoint exceeded its task budget".into());
|
||||
}
|
||||
if self.recover_heap_limit() {
|
||||
return Err("JavaScript heap limit exceeded; execution terminated".into());
|
||||
}
|
||||
|
||||
let isolate_handle = self.isolate_handle();
|
||||
let mut waiting_for_wake = false;
|
||||
std::future::poll_fn(|cx| {
|
||||
let result = std::future::poll_fn(|cx| {
|
||||
let watchdog = crate::cdp_watchdog::arm(
|
||||
isolate_handle.clone(),
|
||||
std::time::Duration::from_millis(AUTONOMOUS_TASK_WATCHDOG_MS),
|
||||
@@ -1967,7 +2146,8 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.await;
|
||||
self.finish_heap_checked(result)
|
||||
}
|
||||
|
||||
/// Drive one cooperative event-loop turn for browser lifecycle code that
|
||||
@@ -2098,6 +2278,7 @@ impl ObscuraJsRuntime {
|
||||
};
|
||||
let fired = self.disarm_watchdog(token);
|
||||
match result {
|
||||
Err(error) if error.contains("heap limit exceeded") => Err(error),
|
||||
Err(error) if fired || error.contains("execution terminated") => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
@@ -2119,6 +2300,9 @@ impl ObscuraJsRuntime {
|
||||
let token = self.arm_watchdog(timeout);
|
||||
let result = self.runtime.execute_script("<eval>", wrapped);
|
||||
let fired = self.disarm_watchdog(token);
|
||||
if self.recover_heap_limit() {
|
||||
return Err("JavaScript heap limit exceeded; execution terminated".to_string());
|
||||
}
|
||||
match result {
|
||||
Ok(v) if !fired => self.v8_to_json(v),
|
||||
Ok(_) => Err("eval timed out".to_string()),
|
||||
@@ -2142,6 +2326,7 @@ impl ObscuraJsRuntime {
|
||||
.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
||||
)
|
||||
.await;
|
||||
self.recover_heap_limit();
|
||||
}
|
||||
|
||||
/// Pump the event loop until `done_check` returns true (e.g. an IIFE
|
||||
@@ -2183,6 +2368,9 @@ impl ObscuraJsRuntime {
|
||||
.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
||||
)
|
||||
.await;
|
||||
if self.recover_heap_limit() {
|
||||
return false;
|
||||
}
|
||||
// Backoff so a hung promise doesn't burn CPU. Caps at 50ms;
|
||||
// worst case we miss the result by <50ms.
|
||||
if tick_ms < 50 {
|
||||
@@ -14014,6 +14202,45 @@ mod tests {
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
fn spawn_duplicate_module_graph_server() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0u8; 2048];
|
||||
let length = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..length]);
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_ascii_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
let body = match path {
|
||||
"/entry.js" => {
|
||||
"import './shared.js'; globalThis.__module_entry_ran = true;"
|
||||
}
|
||||
"/shared.js" => {
|
||||
"globalThis.__shared_module_runs = \
|
||||
(globalThis.__shared_module_runs || 0) + 1;"
|
||||
}
|
||||
_ => "throw new Error('unexpected module path');",
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: application/javascript\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Connection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ModuleGraphFixture {
|
||||
CookieProtected,
|
||||
@@ -14187,6 +14414,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dependency_prepared_as_root_is_evaluated_only_once() {
|
||||
let base = spawn_duplicate_module_graph_server();
|
||||
let jar = std::sync::Arc::new(obscura_net::CookieJar::new());
|
||||
let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options(
|
||||
jar, None, true,
|
||||
));
|
||||
let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base));
|
||||
rt.set_http_client(client);
|
||||
|
||||
// The HTML scheduler prepares all module graphs before evaluating any
|
||||
// of them. The shared URL is both a dependency and a later root, which
|
||||
// used to reach deno_core::mod_evaluate twice and panic (#591).
|
||||
let entry = rt
|
||||
.prepare_module(&format!("{}/entry.js", base), 1_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let shared = rt
|
||||
.prepare_module(&format!("{}/shared.js", base), 1_000)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
rt.evaluate_prepared_module(entry, 1_000).await.unwrap();
|
||||
rt.evaluate_prepared_module(shared, 1_000).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rt.evaluate("globalThis.__module_entry_ran === true").unwrap(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
assert_eq!(
|
||||
rt.evaluate("globalThis.__shared_module_runs").unwrap(),
|
||||
serde_json::json!(1.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heap_limit_terminates_script_and_runtime_recovers() {
|
||||
crate::v8_flags::set_v8_flags("--max-old-space-size=32 --max-semi-space-size=1");
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let error = rt
|
||||
.evaluate(
|
||||
"(() => { const chunks = []; for (;;) { \
|
||||
chunks.push(new Array(262144).fill(1.25)); } })()",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("heap limit exceeded"),
|
||||
"unexpected heap failure: {error}",
|
||||
);
|
||||
assert_eq!(
|
||||
rt.evaluate("globalThis.__runtime_survived_oom = true").unwrap(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn descendant_module_uses_page_cookie_identity_and_headers() {
|
||||
let (base, requests) = spawn_module_graph_server(ModuleGraphFixture::CookieProtected);
|
||||
|
||||
Reference in New Issue
Block a user