fix(document): ignore destructive writes during module execution

Track nested ignore-destructive-writes counters per Document across module evaluation and its cleanup checkpoint. Preserve explicit input streams, cross-realm Document identity, and writes from later tasks or pending top-level await continuations.

Validate root and child module behavior with five regression tests. The 149-case dynamic-markup WPT run gains four passes with unchanged remaining outcomes; update their recorded case status. cargo fmt, workspace all-feature clippy, and all 17835 nextest tests pass.
This commit is contained in:
ldm0
2026-09-13 17:02:39 +08:00
parent b00afb7aae
commit c01b35036e
12 changed files with 421 additions and 12 deletions
@@ -2960,11 +2960,7 @@ html/syntax/charset/xhr.html
html/syntax/parsing/cdata-in-integration-point-fragment.html
html/webappapis/dynamic-markup-insertion/document-write/module-delayed.html
html/webappapis/dynamic-markup-insertion/document-write/module-static-import-delayed.html
html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-delayed.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html
html/webappapis/dynamic-markup-insertion/document-write/module.html
html/webappapis/dynamic-markup-insertion/document-write/mutation-observer.html
html/webappapis/dynamic-markup-insertion/document-write/write-active-document.html
html/webappapis/scripting/event-loops/microtask_before_prepare_the_script_element-01.html
@@ -6940,7 +6940,11 @@ html/webappapis/dynamic-markup-insertion/document-write/iframe_007.html
html/webappapis/dynamic-markup-insertion/document-write/iframe_008.html
html/webappapis/dynamic-markup-insertion/document-write/iframe_009.html
html/webappapis/dynamic-markup-insertion/document-write/module-dynamic-import.html
html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-import.html
html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html
html/webappapis/dynamic-markup-insertion/document-write/module.html
html/webappapis/dynamic-markup-insertion/document-write/script_001.html
html/webappapis/dynamic-markup-insertion/document-write/script_002.html
html/webappapis/dynamic-markup-insertion/document-write/script_003.html
+3
View File
@@ -31,6 +31,9 @@ mod child_markup_insertion;
#[path = "scripts/child_script_text.rs"]
mod child_script_text;
#[path = "scripts/module_document_write.rs"]
mod module_document_write;
fn diagnostic_global<'a>(
page: &'a moli_core::page::Page,
name: &str,
@@ -0,0 +1,275 @@
use super::*;
fn markup_url(server: &FixtureServer, markup: &str) -> String {
let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap();
url.query_pairs_mut().append_pair("markup", markup);
url.into()
}
fn module_url(source: &str) -> String {
format!(
"data:text/javascript,{}",
url::form_urlencoded::byte_serialize(source.as_bytes())
.collect::<String>()
.replace('+', "%20")
)
}
const WRITE_ATTEMPTS: &str = r#"
for (const method of ['write', 'writeln']) {
let converted = false;
const result = document[method]({toString() {
converted = true;
return '<p id="written">written</p>';
}});
writeLog.push({method, converted, undefinedResult: result === undefined,
original: !!document.getElementById('original'),
written: !!document.getElementById('written'),
currentScriptNull: document.currentScript === null});
}
document.close();
"#;
async fn module_document_write_matrix(child: bool) -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let imported = serde_json::to_string(&module_url(WRITE_ATTEMPTS))?;
let variants = [
("sync", WRITE_ATTEMPTS.to_owned()),
(
"microtask",
format!("Promise.resolve().then(() => {{ {WRITE_ATTEMPTS} }});"),
),
(
"tla-immediate",
format!("await Promise.resolve(); {WRITE_ATTEMPTS}"),
),
("static-import", format!("import {imported};")),
("external", WRITE_ATTEMPTS.to_owned()),
(
"throw",
format!("{WRITE_ATTEMPTS} throw new Error('module write probe');"),
),
];
let mut results = Vec::new();
for (name, source) in variants {
let script = if name == "external" {
format!(
"<script type=module src=\"{}\"></script>",
module_url(&source)
)
} else {
format!("<script type=module>{source}</script>")
};
let markup = format!(
"<!doctype html><body><p id=original>original</p><script>window.writeLog = [];</script>{script}"
);
let source_url = markup_url(&server, &markup);
let url = if child {
markup_url(
&server,
&format!(
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
source_url.replace('&', "&amp;")
),
)
} else {
source_url
};
let mut page = browser.fetch(&url).await?;
let observed = page
.evaluate_runtime_expression_with_await_async(
r#"(() => {
const target = document.getElementById('target')?.contentWindow || window;
const duringModule = target.writeLog;
// The initial module evaluation, its cleanup checkpoint and any
// exception handling have ended. This later write must work.
target.document.write('<p id="late">late</p>');
target.document.close();
return JSON.stringify({duringModule,
laterWrite: target.document.getElementById('late')?.textContent});
})()"#,
true,
)
.await?;
let observed: serde_json::Value =
serde_json::from_str(observed["value"].as_str().expect("module write probe"))?;
results.push(serde_json::json!({"name": name, "observed": observed}));
}
server.shutdown().await;
let expected_log = ["write", "writeln"].map(|method| {
serde_json::json!({"method": method, "converted": true, "undefinedResult": true,
"original": true, "written": false, "currentScriptNull": true})
});
let expected: Vec<_> = [
"sync",
"microtask",
"tla-immediate",
"static-import",
"external",
"throw",
]
.map(|name| {
serde_json::json!({"name": name, "observed": {
"duringModule": expected_log, "laterWrite": "late"
}})
})
.into();
assert_eq!(results, expected, "child={child}");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn root_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> {
module_document_write_matrix(false).await
}
#[tokio::test(flavor = "multi_thread")]
async fn child_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> {
module_document_write_matrix(true).await
}
#[tokio::test(flavor = "multi_thread")]
async fn modules_can_explicitly_open_a_document_stream() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
for child in [false, true] {
let source_url = markup_url(
&server,
&format!(
"<!doctype html><body><p id=original>original</p><script>window.writeLog=[];</script><script type=module>document.open();{WRITE_ATTEMPTS}</script>"
),
);
let url = if child {
markup_url(
&server,
&format!(
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
source_url.replace('&', "&amp;")
),
)
} else {
source_url
};
let mut page = browser.fetch(&url).await?;
let observed = page
.evaluate_runtime_expression_with_await_async(
r#"(() => {
const target = document.getElementById('target')?.contentWindow || window;
return JSON.stringify({log: target.writeLog,
body: target.document.body.textContent});
})()"#,
true,
)
.await?;
let observed: serde_json::Value =
serde_json::from_str(observed["value"].as_str().expect("explicit stream"))?;
let expected_log = ["write", "writeln"].map(|method| {
serde_json::json!({"method": method, "converted": true, "undefinedResult": true,
"original": false, "written": true, "currentScriptNull": true})
});
assert_eq!(
observed,
serde_json::json!({"log": expected_log,
"body": "writtenwritten\n"}),
"child={child}"
);
}
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_write_guard_is_released_before_pending_tla_continues() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
for child in [false, true] {
let source_url = markup_url(
&server,
r#"<!doctype html><body><p id=original>original</p>
<script type=module>
window.moduleFinished = new Promise(resolve => window.finishModule = resolve);
await new Promise(resolve => window.resumeModule = resolve);
document.write('<p id="later">later</p>');
document.close();
finishModule();
</script>"#,
);
let url = if child {
markup_url(
&server,
&format!(
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
source_url.replace('&', "&amp;")
),
)
} else {
source_url
};
let mut page = browser
.fetch_with_wait_until(&url, RenderedDomWaitUntil::Load, Duration::from_secs(5))
.await?;
let observed = page
.evaluate_runtime_expression_with_await_async(
r#"(async () => {
const target = document.getElementById('target')?.contentWindow || window;
const original = !!target.document.getElementById('original');
const finished = target.moduleFinished;
target.resumeModule();
await finished;
return JSON.stringify({original,
later: target.document.getElementById('later')?.textContent});
})()"#,
true,
)
.await?;
let observed: serde_json::Value =
serde_json::from_str(observed["value"].as_str().expect("pending TLA write"))?;
assert_eq!(
observed,
serde_json::json!({"original": true, "later": "later"}),
"child={child}"
);
}
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_write_guard_follows_the_document_across_callback_realms() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let child_url = markup_url(
&server,
r#"<!doctype html><body><p id=original>original</p>
<script type=module>parent.tryWrites(document);</script>"#,
);
let parent = format!(
r#"<!doctype html><body><iframe id=other></iframe>
<script>
window.tryWrites = protectedDocument => {{
protectedDocument.write('<p id="blocked">blocked</p>');
protectedDocument.close();
const other = document.getElementById('other').contentDocument;
other.write('<p id="allowed">allowed</p>');
other.close();
window.writeResult = {{protected: !!protectedDocument.getElementById('original'),
blocked: !!protectedDocument.getElementById('blocked'),
other: other.getElementById('allowed')?.textContent}};
}};
</script><iframe src="{}"></iframe>"#,
child_url.replace('&', "&amp;")
);
let mut page = browser.fetch(&markup_url(&server, &parent)).await?;
let observed = page
.evaluate_runtime_expression_with_await_async("JSON.stringify(writeResult)", true)
.await?;
server.shutdown().await;
let observed: serde_json::Value =
serde_json::from_str(observed["value"].as_str().expect("cross-realm writes"))?;
assert_eq!(
observed,
serde_json::json!({"protected": true, "blocked": false, "other": "allowed"})
);
Ok(())
}
+2
View File
@@ -7,6 +7,7 @@ use std::{
use url::Url;
mod destructive_writes;
mod devtools_mutations;
mod document_write;
mod dom_facade;
@@ -774,6 +775,7 @@ pub(super) struct DocumentRuntime {
document_character_set: String,
resource_loader_binding: Option<DocumentResourceLoaderBinding>,
script_context_stack: Vec<CurrentScriptContext>,
destructive_write_counters: destructive_writes::DocumentWriteCounters,
root_document_parser: Option<DocumentParserSession>,
post_parse_schedule_invalidated: bool,
stylesheet_lifecycle: StylesheetLifecycleState,
@@ -0,0 +1,90 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use super::{DocumentRuntime, DomHandle};
#[derive(Debug, Default)]
pub(super) struct DocumentWriteCounters(Rc<RefCell<HashMap<DomHandle, usize>>>);
/// Owns an execute-script-element counter through script cleanup, including
/// its microtask checkpoint, but not subsequent tasks or pending TLA work.
/// The shared state avoids borrowing the runtime across JavaScript reentry.
pub(crate) struct IgnoreDestructiveWritesGuard {
counters: Rc<RefCell<HashMap<DomHandle, usize>>>,
document: DomHandle,
}
impl DocumentWriteCounters {
fn enter(&self, document: DomHandle) -> IgnoreDestructiveWritesGuard {
let mut counters = self.0.borrow_mut();
let counter = counters.entry(document).or_default();
*counter = counter
.checked_add(1)
.expect("document write counter overflow");
IgnoreDestructiveWritesGuard {
counters: Rc::clone(&self.0),
document,
}
}
fn is_active(&self, document: DomHandle) -> bool {
self.0.borrow().contains_key(&document)
}
}
impl Drop for IgnoreDestructiveWritesGuard {
fn drop(&mut self) {
let mut counters = self.counters.borrow_mut();
let counter = counters
.get_mut(&self.document)
.expect("document write guard requires a matching counter");
*counter -= 1;
if *counter == 0 {
counters.remove(&self.document);
}
}
}
impl DocumentRuntime {
pub(crate) fn enter_ignore_destructive_writes(
&self,
document: DomHandle,
) -> IgnoreDestructiveWritesGuard {
self.destructive_write_counters.enter(document)
}
pub(crate) fn has_ignore_destructive_writes_counter(&self, document: DomHandle) -> bool {
self.destructive_write_counters.is_active(document)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn destructive_write_counters_are_nested_and_document_scoped() {
let counters = DocumentWriteCounters::default();
let document = DomHandle::new(0);
let other = DomHandle::new(1);
let outer = counters.enter(document);
assert!(counters.is_active(document));
assert!(!counters.is_active(other));
let inner = counters.enter(document);
let other_guard = counters.enter(other);
drop(outer);
assert!(counters.is_active(document));
drop(inner);
assert!(!counters.is_active(document));
assert!(counters.is_active(other));
drop(other_guard);
assert!(counters.0.borrow().is_empty());
}
#[test]
fn destructive_write_guard_can_outlive_retired_runtime_state() {
let counters = DocumentWriteCounters::default();
let guard = counters.enter(DomHandle::new(0));
drop(counters);
drop(guard);
}
}
@@ -82,6 +82,7 @@ impl DocumentRuntime {
document_character_set: "UTF-8".to_owned(),
resource_loader_binding: None,
script_context_stack: Vec::new(),
destructive_write_counters: Default::default(),
root_document_parser: None,
post_parse_schedule_invalidated: false,
stylesheet_lifecycle,
@@ -215,6 +216,7 @@ impl DocumentRuntime {
document_character_set: _,
resource_loader_binding: _,
script_context_stack: _,
destructive_write_counters: _,
root_document_parser: _,
post_parse_schedule_invalidated: _,
stylesheet_lifecycle: _,
@@ -1,4 +1,3 @@
#[cfg(test)]
use crate::live_document_parser::DocumentParserLifetime;
use crate::{
frame_owner_model::FrameDocumentOwner,
@@ -37,7 +36,6 @@ impl ChildDocumentParserStore {
self.sessions.remove(&owner)
}
#[cfg(test)]
pub(in crate::native_bridge::context_host) fn has_open_stream(
&self,
owner: FrameDocumentOwner,
@@ -346,6 +346,22 @@ fn child_document_write_or_writeln_callback<'s>(
rv.set_undefined();
return;
}
let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document)
else {
rv.set_undefined();
return;
};
let has_open_stream = host
.frame_owner_store
.current_child_document_owner(handle)
.is_some_and(|owner| host.child_document_parsers.has_open_stream(owner));
if host.has_ignore_destructive_writes_counter(document_handle)
&& !has_open_stream
&& !host.child_document_is_executing_parser_script(document_handle)
{
rv.set_undefined();
return;
}
let script_context = if host.child_document_parser_is_active(handle) {
match unsafe { &mut *host_ptr }.ensure_prebootstrapped_child_default_context(scope, handle)
{
@@ -367,11 +383,6 @@ fn child_document_write_or_writeln_callback<'s>(
};
context
};
let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document)
else {
rv.set_undefined();
return;
};
let _ = unsafe { &mut *host_ptr }.pump_child_document_write_parser(
scope,
script_context,
@@ -141,7 +141,8 @@ fn node_document_write_or_writeln_callback<'s>(
let implicit_replacement_session = !runtime.has_active_parser_write_insertion_point()
&& !runtime.host_document().replace_on_close();
if implicit_replacement_session
&& current_script_ignores_document_write_without_parser_insertion_point(runtime)
&& (runtime.has_ignore_destructive_writes_counter(handle)
|| current_script_ignores_document_write_without_parser_insertion_point(runtime))
{
rv.set_undefined();
return;
@@ -3734,6 +3734,14 @@ impl ScriptVm {
})?;
self.document_runtime
.mark_native_module_evaluating(root_entry);
// currentScript is null for modules. The execute-script-element guard
// belongs to its Document and lasts through the cleanup checkpoint,
// not the lifetime of the module's evaluation promise.
let _ignore_destructive_writes =
(owner == NativeModuleEvaluationOwner::Script).then(|| {
self.document_runtime
.enter_ignore_destructive_writes(self.document_runtime.document_handle())
});
let promise = self
.renderer_document_isolate
.with_entered_renderer_document_isolate(|isolate| {
@@ -1042,6 +1042,25 @@ impl ScriptVm {
)
})?;
document_modulator.mark_evaluating(root_entry);
let _ignore_destructive_writes = if owner == NativeModuleEvaluationOwner::Script {
let host = self._context_host.borrow();
let document = host
.frame_owner_current_child_snapshot_for_realm(realm_id)
.filter(|snapshot| {
snapshot.local_window_id == document_owner.local_window_id
&& snapshot.document_id == document_owner.document_id
})
.ok_or_else(|| {
ModuleLoadError::new(
ModuleLoadStage::Evaluate,
"module script has no current child Document for its execution guard",
)
})?;
Some(host.enter_ignore_destructive_writes(document.document_handle))
} else {
// import() is not execution of a script element.
None
};
let promise = self
.renderer_document_isolate
.with_entered_renderer_document_isolate(|isolate| {