diff --git a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs index 2d5a6fe..e5c99ab 100644 --- a/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs +++ b/crates/obscura-cdp/tests/dynamic_script_onload_fires.rs @@ -109,7 +109,7 @@ async fn serve_dynamic_order_fixture(explicitly_in_order: bool) -> (String, Arc< "window.__dynamicOrder.push('slow');".to_string(), ) } else if request.starts_with("GET /fast.js") { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; ( "application/javascript", "window.__dynamicOrder.push('fast');".to_string(), @@ -199,12 +199,12 @@ async fn dynamic_classic_fetch_concurrency_matches_force_async_state() { navigate_dynamic_order_fixture(true).await; assert_eq!(ordered_order, ["slow", "fast"]); assert_eq!( - ordered_peak, 1, - "explicit async=false scripts must retain their insertion-order queue" + ordered_peak, 2, + "explicit async=false scripts must overlap fetches while retaining their execution-order queue" ); assert!( - async_elapsed_ms + 50 < ordered_elapsed_ms, - "concurrent fetches should avoid the ordered waterfall: async={async_elapsed_ms}ms ordered={ordered_elapsed_ms}ms" + ordered_elapsed_ms < async_elapsed_ms + 300, + "execution ordering must not serialize network fetches: async={async_elapsed_ms}ms ordered={ordered_elapsed_ms}ms" ); } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 41cb1fc..47a63fe 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -274,28 +274,44 @@ function _decodeDataScriptUrl(url) { globalThis.__markParserScripts = function(nids) { for (const nid of nids || []) Deno.core.ops.op_script_mark_started(+nid); }; +async function __fetchDynClassicScript(task) { + let body; + if (task.url.startsWith('data:')) { + body = _decodeDataScriptUrl(task.url); + } else { + const raw = await Deno.core.ops.op_fetch_url( + task.url, "GET", "{}", "", task.pageOrigin, "no-cors", "same-origin" + ); + const parsed = JSON.parse(raw); + // The HTML script-fetch algorithm treats an unsuccessful HTTP response + // as a network error. Evaluating its response body is both observably + // unlike browsers and dangerous: JSON error payloads and diagnostic HTML + // must never become script source. + if (!(parsed.status >= 200 && parsed.status <= 299)) { + throw new Error('HTTP ' + (parsed.status || 0)); + } + body = parsed.body; + } + return body; +} +function __startDynClassicFetch(task) { + // Attach both reactions immediately. An in-order script may finish fetching + // before an earlier queue member; retaining a settled value avoids an + // unhandled-rejection report while its execution turn is still blocked. + task.fetchResult = __fetchDynClassicScript(task).then( + body => ({ body }), + error => ({ error }), + ); +} async function __runDynScriptTask(task) { try { if (task.isModule) { await import(task.url); } else { - let body; - if (task.url.startsWith('data:')) { - body = _decodeDataScriptUrl(task.url); - } else { - const raw = await Deno.core.ops.op_fetch_url( - task.url, "GET", "{}", "", task.pageOrigin, "no-cors", "same-origin" - ); - const parsed = JSON.parse(raw); - // The HTML script-fetch algorithm treats an unsuccessful HTTP - // response as a network error. Evaluating its response body is both - // observably unlike browsers and dangerous: JSON error payloads and - // diagnostic HTML must never become script source. - if (!(parsed.status >= 200 && parsed.status <= 299)) { - throw new Error('HTTP ' + (parsed.status || 0)); - } - body = parsed.body; - } + if (!task.fetchResult) __startDynClassicFetch(task); + const fetched = await task.fetchResult; + if (fetched.error) throw fetched.error; + const body = fetched.body; if (body) { // A fetched async script is executed by a ScriptRunner task, not by // the fetch promise's microtask continuation. Besides matching event @@ -1374,8 +1390,17 @@ function __prepareInsertedScript(script) { const explicitlyInOrder = !isModule && Object.prototype.hasOwnProperty.call(script, 'async') && script.async === false; - if (!isModule && !explicitlyInOrder) { - __runAsyncClassicScript(task); + if (!isModule) { + // Fetch all dynamically inserted classics immediately. `async=false` + // changes only execution order: browsers still overlap their network + // requests, then hold a ready body behind earlier ordered scripts. + __startDynClassicFetch(task); + if (explicitlyInOrder) { + __dynScriptQueue.push(task); + __processDynScriptQueue(); + } else { + __runAsyncClassicScript(task); + } } else { __dynScriptQueue.push(task); __processDynScriptQueue();