fix(modules): reuse module maps for runtime script roots

Consult the module map before fetching runtime module script roots while
preserving script-element Service Worker filtering and integrity checks.
Keep shared fetches alive across document.open so imports in the retained
realm settle without reviving retired script elements or their events.
This commit is contained in:
ldm0
2026-10-11 18:06:44 +08:00
parent 49face3c8f
commit 41301ac9b9
12 changed files with 410 additions and 30 deletions
+20
View File
@@ -52,12 +52,16 @@ impl IntegrityServers {
let origin = format!("http://{}", main.local_addr()?);
let cross_origin = format!("http://{}", cross.local_addr()?);
let requests = Arc::new(Mutex::new(Vec::new()));
let module_started = Arc::new(tokio::sync::Notify::new());
let module_release = Arc::new(tokio::sync::Semaphore::new(0));
let tasks = [main, cross]
.into_iter()
.map(|listener| {
let origin = origin.clone();
let cross_origin = cross_origin.clone();
let requests = Arc::clone(&requests);
let module_started = Arc::clone(&module_started);
let module_release = Arc::clone(&module_release);
tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
@@ -67,6 +71,8 @@ impl IntegrityServers {
let origin = origin.clone();
let cross_origin = cross_origin.clone();
let requests = Arc::clone(&requests);
let module_started = Arc::clone(&module_started);
let module_release = Arc::clone(&module_release);
connections.spawn(async move {
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") && request.len() < 16384 {
@@ -87,6 +93,15 @@ impl IntegrityServers {
};
let (status, headers, body) = fixture_response(&incoming, &origin, &cross_origin);
requests.lock().push(incoming);
// Hold a module response across document.open without a timed sleep.
if path == "/echo-origin.js?consumer-open" {
module_started.notify_one();
let _ = module_release.acquire().await;
} else if path == "/module-response-started" {
module_started.notified().await;
} else if path == "/module-response-release" {
module_release.close();
}
let response = format!(
"HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
@@ -147,6 +162,11 @@ fn fixture_response(
let (path, query) = request.path.split_once('?').unwrap_or((&request.path, ""));
let javascript = "Content-Type: text/javascript\r\n";
match path {
"/module-response-started" | "/module-response-release" => (
"200 OK",
"Content-Type: text/plain\r\nCache-Control: no-store\r\n".to_owned(),
String::new(),
),
"/runtime-modulepreload.html" => (
"200 OK",
"Content-Type: text/html\r\n".to_owned(),
@@ -115,6 +115,204 @@ async fn runtime_modulepreloads_fetch_after_load_and_reuse_the_child_module_map(
check_runtime_modulepreloads(true).await
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_consumers_share_the_main_module_map() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let probe = r#"(async () => {
const check = (value, message) => { if (!value) throw new Error(message); };
const attach = (tag, url, attributes = {}) => new Promise((resolve, reject) => {
const element = document.createElement(tag);
if (tag === 'link') { element.rel = 'modulepreload'; element.href = url; }
else { element.type = 'module'; element.src = url; }
Object.assign(element, attributes);
const timeout = setTimeout(() => reject(new Error('no event: ' + url)), 3000);
element.onload = element.onerror = event => { clearTimeout(timeout); resolve(event.type); };
document.head.append(element);
});
for (const [name, warmup, ordered] of [
['preloaded', 'preload', false],
['ordered', 'preload', true],
['credentials', 'preload', false],
['integrity', 'preload', false],
['imported', 'import', false],
['evaluated', 'script', false],
['concurrent', 'concurrent-preload', false],
['cold', 'none', false]
]) {
const url = '/echo-origin.js?consumer-' + name;
const before = globalThis.sriExecutions || 0;
const pending = [];
if (warmup === 'preload') {
check(await attach('link', url, {crossOrigin: 'anonymous'}) === 'load', name + ' preload');
check((globalThis.sriExecutions || 0) === before, name + ' premature evaluation');
} else if (warmup === 'import') await import(url);
else if (warmup === 'script') check(await attach('script', url) === 'load', name + ' first script');
else if (warmup === 'concurrent-preload') pending.push(attach('link', url));
const attributes = {async: !ordered};
if (name === 'credentials') attributes.crossOrigin = 'use-credentials';
// The already-fetched module is reused before new fetch options are examined.
if (name === 'integrity') attributes.integrity = 'sha384-AAAA';
pending.push(attach('script', url, attributes), attach('script', url, attributes));
check((await Promise.all(pending)).every(event => event === 'load'), name + ' terminal events');
check(sriExecutions === before + 1, name + ' must evaluate exactly once');
const namespace = await import(url);
check(namespace === await import(url), name + ' namespace identity');
check(sriExecutions === before + 1, name + ' import must reuse evaluation');
}
// Fetch policy must still be enforced on roots that are absent from the map.
check(await attach('script', '/echo-origin.js?consumer-bad-sri', {integrity:'sha384-AAAA'}) === 'error', 'fresh SRI');
check(await attach('script', '/missing.js?consumer-missing') === 'error', 'fresh 404');
const beforeClassic = sriExecutions;
for (let i = 0; i < 2; i++)
check(await attach('script', '/echo-origin.js?consumer-classic', {type:''}) === 'load', 'classic load');
check(sriExecutions === beforeClassic + 2, 'classic scripts must each evaluate');
return true;
})()"#;
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(probe, true),
)
.await??;
assert_eq!(
result["value"],
true,
"{result}; requests={:?}",
servers.requests.lock()
);
let requests = servers.requests.lock();
for name in [
"preloaded",
"ordered",
"credentials",
"integrity",
"imported",
"evaluated",
"concurrent",
"cold",
"bad-sri",
"missing",
"classic",
] {
let path = if name == "missing" {
"/missing.js?consumer-missing".to_owned()
} else {
format!("/echo-origin.js?consumer-{name}")
};
let matching: Vec<_> = requests
.iter()
.filter(|request| request.path == path)
.collect();
assert_eq!(
matching.len(),
if name == "classic" { 2 } else { 1 },
"{path}: {requests:?}"
);
assert_eq!(
matching[0].sec_fetch_dest.as_deref(),
Some("script"),
"{path}"
);
assert_eq!(
matching[0].sec_fetch_mode.as_deref(),
Some(if name == "classic" { "no-cors" } else { "cors" }),
"{path}"
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_map_reservations_preserve_service_worker_responses() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let cases = serde_json::json!([
{"name":"synthetic", "src":"/sw-default.js", "type":"module", "integrity":INTEGRITY, "expected":"load"},
{"name":"cors", "src":"/sw-cors.js", "type":"module", "integrity":INTEGRITY, "expected":"load"},
{"name":"opaque", "src":"/sw-opaque.js", "type":"module", "expected":"error"}
]);
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, true), true),
)
.await??;
assert_integrity_results(result, &cases);
let result = tokio::time::timeout(
Duration::from_secs(10),
page.evaluate_runtime_expression_with_await_async(
r#"(async () => {
const before = sriExecutions;
await import('/sw-default.js');
const script = document.createElement('script');
script.type = 'module'; script.src = '/sw-default.js';
await new Promise((resolve, reject) => {
script.onload = resolve; script.onerror = reject; document.head.append(script);
});
return sriExecutions === before;
})()"#,
true,
),
)
.await??;
assert_eq!(result["value"], true, "{result}");
assert!(
!servers
.requests
.lock()
.iter()
.any(|request| request.path.starts_with("/sw-")),
"intercepted roots must not reach the network"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_fetch_settles_the_retained_map_after_document_open() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let result = tokio::time::timeout(Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(r#"(async () => {
const url = '/echo-origin.js?consumer-open';
globalThis.retiredModuleEvents = 0;
const script = document.createElement('script'); script.type = 'module'; script.src = url;
script.onload = script.onerror = () => retiredModuleEvents++;
document.head.append(script);
await fetch('/module-response-started');
document.open(); document.write('<!doctype html><head></head><body>replacement'); document.close();
const imported = import(url);
await fetch('/module-response-release');
await imported;
return JSON.stringify({executions: sriExecutions, retiredEvents: retiredModuleEvents,
entries: performance.getEntriesByName(new URL(url, location.href).href).length});
})()"#, true),
).await??;
assert!(result["value"].is_string(), "{result}");
let actual: serde_json::Value = serde_json::from_str(result["value"].as_str().unwrap())?;
assert_eq!(
actual,
serde_json::json!({"executions":1,"retiredEvents":0,"entries":0})
);
assert_eq!(
servers
.requests
.lock()
.iter()
.filter(|request| request.path == "/echo-origin.js?consumer-open")
.count(),
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_modulepreloads_keep_document_maps_and_retire_queued_child_starts() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
+12 -5
View File
@@ -1478,7 +1478,9 @@ impl DynamicScriptOwner {
load_delay_binding: Option<MainDocumentScriptLoadDelayLease>,
) {
let ready_state = match script.source_kind {
ScriptSourceKind::External => {
ScriptSourceKind::External
if !crate::script_vm::prepared_script_uses_external_module_graph(&script) =>
{
let tx = self.owner_event_sender();
let loader = loader.clone();
let script_for_load = script.clone();
@@ -1516,10 +1518,15 @@ impl DynamicScriptOwner {
});
DynamicScriptReadyState::Loading
}
ScriptSourceKind::Inline => DynamicScriptReadyState::Ready {
order: self.next_ready_order(),
source_network_result: None,
},
// Module roots must consult the Document's module map before any
// transport runs. The graph owner starts or joins that fetch and
// retains this script's ordering and load-delay lease.
ScriptSourceKind::Inline | ScriptSourceKind::External => {
DynamicScriptReadyState::Ready {
order: self.next_ready_order(),
source_network_result: None,
}
}
};
self.insert_script_with_ready_state(
+3 -1
View File
@@ -372,7 +372,9 @@ impl ModuleOwnerState {
// dynamic-import resolver state belong to the live ScriptState. Only
// parser/script-element work belongs to the replaced Document.
self.document_modulator.clear_for_document_replacement();
self.graph_fetches.clear();
// Network fetches populate the retained ScriptState module map. Their
// initiating script elements retire here, but imports in the same realm
// must still receive the result (as they do for modulepreload).
self.owner_event_tasks.update_ready_tasks(|events| {
for event in events.iter_mut() {
event.retain_for_document_replacement();
@@ -25,10 +25,6 @@ impl fmt::Debug for NativeModuleGraphFetchStore {
}
impl NativeModuleGraphFetchStore {
pub(crate) fn clear(&mut self) {
self.inflight_module_script_fetches.clear();
}
pub(crate) fn suspend_fetch(
&mut self,
continuation: ModuleScriptGraphFetchContinuation,
@@ -5,7 +5,6 @@ use url::Url;
use crate::dom::native::NativeNodeId;
use moli_module_script_tree as module_tree;
use super::graph_fetch_store::NativeModuleGraphFetchStore;
use super::modulator::NativeDocumentModulator as NativeDocumentModulatorUnderTest;
use super::{
DynamicModuleFetchOwnerAdvance, ImportMapRegistryState, ModuleGraphHandle, ModuleLoadError,
@@ -57,20 +56,6 @@ fn import_map_registry_state_merges_late_maps_without_remapping_resolved_specifi
);
}
#[test]
fn native_module_graph_fetch_store_clear_does_not_reuse_inflight_fetch_load_ids() {
let mut store = NativeModuleGraphFetchStore::default();
let first = store.reserve_load_id();
store.clear();
let after_clear = store.reserve_load_id();
assert_ne!(
first, after_clear,
"stale fetch completions can arrive after clear; load ids must stay unique"
);
}
#[test]
fn module_owner_document_replacement_preserves_script_state_module_environment() {
let mut owner = ModuleOwnerState::default();
@@ -154,6 +154,94 @@ impl RendererResourceScheduler {
);
}
pub(crate) fn schedule_main_runtime_module_root_fetch(
&self,
loader: DocumentResourceLoader,
target: MainRuntimeModuleGraphFetchTarget,
script: crate::planning::PreparedScript,
service_worker: crate::dynamic_script_owner::DynamicScriptServiceWorkerContext,
) {
let schedule = MainModuleFetchSchedule::Runtime(target);
let attribution = MainModuleFetchNetworkAttribution::new(
service_worker.document_url.clone(),
script.url.clone(),
);
let Some(load) = loader.register_load(
crate::network::loads::ResourceLoadKind::Script,
crate::network::loads::ResourceLoadDisposition::Ordinary,
None,
) else {
let error = "Document detached before module root fetch registration".to_owned();
schedule.send_completion(
&self.completion_tx,
Err(error.clone()),
Some(Arc::new(Err(error))),
attribution,
);
return;
};
let completion_tx = self.completion_tx.clone();
let request_client = load.request_client();
let request_origin = loader.fetch_context().request_origin();
let runner = load.task_runner();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
load.attach_consumer_cancel(move || {
let _ = cancel_tx.send(());
});
runner.clone().spawn(async move {
// This is the script-element transport previously used before map
// lookup. Keep its Service Worker response filtering and SRI checks,
// but publish the result to the graph's single-module reservation.
let outcome = tokio::select! {
outcome = crate::planning::load_service_worker_aware_external_script_source_outcome(
&script, &request_origin, &request_client, runner, None, None,
service_worker.browser_context_runtime, service_worker.client_id,
service_worker.document_url,
) => outcome,
_ = cancel_rx => {
let error = "module root fetch cancelled".to_owned();
load.finish();
schedule.send_completion(
&completion_tx, Err(error.clone()), Some(Arc::new(Err(error))), attribution,
);
return;
}
};
load.finish();
let result = outcome.source_result.map(|source| {
let response = outcome
.network_result
.as_deref()
.and_then(|result| result.as_ref().ok());
let (url, redirected, referrer_policy) = response.map_or_else(
|| (script.url.clone(), false, None),
|response| {
(
response.final_url.clone(),
response.redirected,
crate::referrer_policy::response_referrer_policy_from_headers(
&response.headers,
),
)
},
);
ModuleGraphFetchedSource::new(
url,
redirected,
outcome
.source_bytes
.filter(|_| crate::planning::is_webassembly_module_script_url(&script.url))
.map_or_else(
|| crate::module_runtime::ModuleSource::text(source),
crate::module_runtime::ModuleSource::binary,
),
)
.with_response_referrer_policy(referrer_policy)
});
schedule.send_completion(&completion_tx, result, outcome.network_result, attribution);
});
}
pub(crate) fn schedule_main_modulepreload_fetch(
&self,
loader: DocumentResourceLoader,
+2 -2
View File
@@ -428,8 +428,8 @@ pub(crate) use self::page_vm::{
AuthorizedCurrentMainParserModuleGraphFetchCompletion,
AuthorizedCurrentMainRuntimeModuleGraphFetchCompletion,
AuthorizedCurrentPopupClassicScriptLoadCompletion,
AuthorizedCurrentPopupDocumentLoadCompletion, AuthorizedLiveMainModulepreloadFetchCompletion,
CurrentChildDocumentLoadApplication,
AuthorizedCurrentPopupDocumentLoadCompletion, AuthorizedLiveMainModuleGraphFetchCompletion,
AuthorizedLiveMainModulepreloadFetchCompletion, CurrentChildDocumentLoadApplication,
};
pub(in crate::runtime) use self::page_vm::{
PageVmCommittedNavigationBootstrap, PageVmDocumentCommitPreparation,
+2 -2
View File
@@ -247,8 +247,8 @@ pub(crate) use page_resource_completion::{
AuthorizedCurrentMainParserModuleGraphFetchCompletion,
AuthorizedCurrentMainRuntimeModuleGraphFetchCompletion,
AuthorizedCurrentPopupClassicScriptLoadCompletion,
AuthorizedCurrentPopupDocumentLoadCompletion, AuthorizedLiveMainModulepreloadFetchCompletion,
CurrentChildDocumentLoadApplication,
AuthorizedCurrentPopupDocumentLoadCompletion, AuthorizedLiveMainModuleGraphFetchCompletion,
AuthorizedLiveMainModulepreloadFetchCompletion, CurrentChildDocumentLoadApplication,
};
pub(crate) use page_script_preparation_error::AuthorizedCurrentPageScriptPreparationError;
pub(crate) use page_service_worker_client_message::AuthorizedCurrentPageServiceWorkerClientMessage;
@@ -27,7 +27,7 @@ pub(crate) use main_module::{
AuthorizedCurrentMainDynamicImportGraphFetchCompletion,
AuthorizedCurrentMainParserModuleGraphFetchCompletion,
AuthorizedCurrentMainRuntimeModuleGraphFetchCompletion,
AuthorizedLiveMainModulepreloadFetchCompletion,
AuthorizedLiveMainModuleGraphFetchCompletion, AuthorizedLiveMainModulepreloadFetchCompletion,
};
impl PageVm {
@@ -45,6 +45,25 @@ impl AuthorizedCurrentMainRuntimeModuleGraphFetchCompletion {
}
}
/// Proof that a retired script's network terminal still belongs to an in-flight
/// module-map request in the same root Page/ScriptState. It authorizes cache
/// settlement only, without restoring the script element or its Document tasks.
pub(crate) struct AuthorizedLiveMainModuleGraphFetchCompletion {
load_id: u64,
result: std::result::Result<crate::module_runtime::ModuleGraphFetchedSource, String>,
}
impl AuthorizedLiveMainModuleGraphFetchCompletion {
pub(crate) fn into_parts(
self,
) -> (
u64,
std::result::Result<crate::module_runtime::ModuleGraphFetchedSource, String>,
) {
(self.load_id, self.result)
}
}
/// Proof that the Page lane executor matched one dynamic-import network
/// terminal against its complete `{root Document, import owner, load id}`
/// target.
@@ -95,6 +114,20 @@ impl PageVm {
completion.network_attribution().request_url(),
completion.network_result(),
);
if owner.root_document() == self.document_lifecycle.identity().document
&& self
.vm()
.document_runtime
.has_inflight_native_module_script_fetch(completion.target().load_id())
{
self.vm_mut()
.apply_live_main_module_graph_fetch_completion(
AuthorizedLiveMainModuleGraphFetchCompletion {
load_id: completion.target().load_id(),
result: completion.into_result(),
},
)?;
}
return Ok(PageResourceCompletionTurnAction::discarded_stale(
source,
owner,
@@ -132,6 +165,20 @@ impl PageVm {
completion.network_attribution().request_url(),
completion.network_result(),
);
if owner.root_document() == self.document_lifecycle.identity().document
&& self
.vm()
.document_runtime
.has_inflight_native_module_script_fetch(completion.target().load_id())
{
self.vm_mut()
.apply_live_main_module_graph_fetch_completion(
AuthorizedLiveMainModuleGraphFetchCompletion {
load_id: completion.target().load_id(),
result: completion.into_result(),
},
)?;
}
return Ok(PageResourceCompletionTurnAction::discarded_stale(
source,
owner,
@@ -523,6 +523,14 @@ impl ScriptVm {
}
Ok(())
}
pub(crate) fn apply_live_main_module_graph_fetch_completion(
&mut self,
authorization: crate::runtime::AuthorizedLiveMainModuleGraphFetchCompletion,
) -> Result<()> {
let (load_id, result) = authorization.into_parts();
self.complete_shared_module_map_fetch_result(load_id, result)
}
#[cfg(test)]
pub(super) fn complete_abandoned_module_script_graph_fetch(
&mut self,
@@ -1109,6 +1117,11 @@ impl ScriptVm {
load_ids.push(load_id);
scheduled.push((load_id, request));
}
let mut root_script = (matches!(fetch_schedule_owner, FetchScheduleOwner::Runtime { .. })
&& scheduled
.iter()
.any(|(_, request)| request.is_top_level_tree_fetch()))
.then(|| continuation.script.clone());
let continuation = continuation.with_pending_graph_fetches(job, load_ids.first().copied());
tracing::debug!(
url = %continuation.script.url,
@@ -1146,6 +1159,30 @@ impl ScriptVm {
document_owner,
dynamic_script_owner_id,
} => {
if request.is_top_level_tree_fetch() {
let service_worker = {
let host = self._context_host.borrow();
crate::dynamic_script_owner::DynamicScriptServiceWorkerContext {
browser_context_runtime: host.browser_context_runtime(),
client_id: host.service_worker_client_id_for_window_fetch(None),
document_url: self.document_runtime.document_url().clone(),
}
};
self.resource_scheduler()
.schedule_main_runtime_module_root_fetch(
document_loader.clone(),
crate::page_resource_completion::MainRuntimeModuleGraphFetchTarget::new(
document_owner,
dynamic_script_owner_id,
load_id,
),
root_script
.take()
.expect("runtime root fetch retains its prepared script"),
service_worker,
);
continue;
}
self.resource_scheduler()
.schedule_main_runtime_module_graph_fetch(
document_loader.clone(),