Preserve layout metrics and browsing-context state - #853
lanyue-llk wants to merge 30 commits into
Conversation
Spider Bench A/B✅ All benchmark browser service runs completed; results are informational. Public HEAD: 184 / 240 rows; 38 / 48 sites produced rows; 10 unexpected empty sites. ✅ Deterministic fixture contract is clean. Common ancestor Public 48-site run · informational
Public-site content, timing, and memory are noisy. This single A/B run reports evidence only; site outcome counts explain missing rows without treating them as a deterministic regression. CPU and memory timelinesThese are bounded 40-point views of the same complete process-tree samples used by the HTML report. CPU uses 100% per occupied logical core; memory lines are RSS first and PSS second when complete PSS samples are available. Basexychart-beta
title "Base CPU"
x-axis "Elapsed seconds" [1.536, 3.037, 4.038, 5.541, 7.043, 8.545, 10.047, 11.049, 12.552, 14.053, 15.555, 17.057, 18.058, 19.558, 21.061, 22.561, 24.063, 24.563, 25.565, 26.565, 28.067, 29.568, 31.069, 32.571, 33.572, 34.572, 35.073, 36.576, 38.077, 39.578, 40.58, 42.082, 43.583, 44.083, 45.085, 46.587, 47.588, 49.089]
y-axis "CPU percent" 0 --> 200
line [124.01, 125.9, 156.08, 177.67, 135.76, 153.86, 173.84, 167.62, 67.86, 135.97, 105.83, 119.9, 137.89, 56, 109.73, 131.97, 99.96, 110.01, 109.78, 119.99, 105.72, 153.79, 137.86, 73.93, 12, 2, 3.99, 111.87, 85.91, 7.99, 2, 115.85, 137.92, 185.86, 55.97, 99.72, 143.91, 23.98]
xychart-beta
title "Base memory: RSS then PSS"
x-axis "Elapsed seconds" [0.035, 1.536, 3.037, 4.038, 5.541, 7.043, 8.545, 10.047, 11.049, 12.552, 14.053, 15.555, 17.057, 18.058, 19.558, 21.061, 22.561, 24.063, 24.563, 25.565, 26.565, 28.067, 29.568, 31.069, 32.571, 33.572, 34.572, 35.073, 36.576, 38.077, 39.578, 40.58, 42.082, 43.583, 44.083, 45.085, 46.587, 47.588, 49.089, 50.098]
y-axis "Memory MiB" 0 --> 500
line [12.29, 97.01, 116.71, 135.47, 162.78, 166.96, 160.79, 179.09, 154.21, 154.38, 170.1, 171.7, 172.85, 150.45, 143.84, 179.46, 163.29, 252.79, 280.08, 197.17, 163.17, 161.04, 173.81, 151.64, 148.72, 153.43, 146.9, 146.22, 219.57, 183.06, 166.58, 158.13, 168.72, 162.71, 166.07, 156.95, 159.34, 164.28, 160.41, 0]
line [9.13, 93.54, 112.85, 131.98, 159.3, 163.35, 157.3, 175.59, 150.72, 150.88, 166.63, 168.05, 169.35, 146.95, 140.35, 175.98, 159.76, 249.33, 276.99, 193.71, 159.66, 157.54, 170.45, 148.14, 145.18, 149.93, 143.4, 142.69, 216.26, 181.08, 162.58, 154.6, 165.25, 159.19, 162.26, 153.43, 155.82, 160.77, 156.89, 0]
HEADxychart-beta
title "HEAD CPU"
x-axis "Elapsed seconds" [1.036, 2.037, 4.04, 6.542, 7.044, 8.544, 10.548, 12.549, 14.553, 16.557, 19.058, 21.059, 23.06, 25.062, 27.063, 29.065, 30.067, 31.568, 33.57, 35.574, 37.577, 39.577, 41.581, 44.083, 46.084, 48.087, 50.089, 52.091, 54.093, 56.597, 58.598, 60.601, 62.602, 64.605, 66.607, 69.11, 71.114, 73.116]
y-axis "CPU percent" 0 --> 500
line [0, 78.05, 105.87, 169.86, 195.45, 157.87, 175.75, 103.96, 73.86, 125.94, 2, 47.99, 13.97, 26.01, 76.01, 151.78, 107.85, 103.83, 3.99, 0, 0, 0, 0, 0, 0, 59.98, 113.87, 31.98, 77.86, 13.99, 6, 45.89, 2, 103.81, 135.88, 99.8, 119.88, 2]
xychart-beta
title "HEAD memory: RSS then PSS"
x-axis "Elapsed seconds" [0.035, 1.036, 2.037, 4.04, 6.542, 7.044, 8.544, 10.548, 12.549, 14.553, 16.557, 19.058, 21.059, 23.06, 25.062, 27.063, 29.065, 30.067, 31.568, 33.57, 35.574, 37.577, 39.577, 41.581, 44.083, 46.084, 48.087, 50.089, 52.091, 54.093, 56.597, 58.598, 60.601, 62.602, 64.605, 66.607, 69.11, 71.114, 73.116, 74.739]
y-axis "Memory MiB" 0 --> 500
line [12.08, 53.77, 93.31, 130.14, 153.59, 162.58, 173.9, 189.5, 181.77, 183.63, 172.93, 147.15, 155.27, 141, 139.62, 147.01, 181.7, 280.46, 221.63, 147.7, 147.45, 147.45, 147.45, 147.45, 147.44, 147.41, 162.97, 159.54, 143.07, 143.91, 144.33, 153.08, 142.62, 141.31, 160.82, 148.84, 150.55, 161.88, 141.18, 0]
line [8.92, 50.29, 89.81, 126.6, 150.07, 159.07, 170.5, 185.9, 178.54, 180.05, 169.31, 143.54, 151.64, 137.32, 136.15, 143.4, 178.1, 277.94, 218.12, 144.07, 143.84, 143.84, 143.84, 143.8, 143.79, 143.8, 159.36, 156.02, 139.46, 140.24, 140.72, 149.47, 139.01, 137.7, 157.2, 144.89, 146.94, 158.29, 137.56, 0]
Deterministic fixture · required contractThe fixture exercises 15 routes: 8 are expected to emit rows and 7 intentionally exercise empty, loading, or timeout behavior.
Full HTML, JSON, CSV, logs, and page snapshots: workflow run and full The public-web A/B is informational. The exact HEAD fixture contract runs as a separate required CI check; timing and public-site content deltas remain non-blocking. |
CI Regression ReportSource CI run · source state at render:
Release regression — ✅ HEAD/base failures 0/0; raw binary +0.013958%Package and image size
Startup latency and PSS
Concurrency matrix
Frontend differential — ✅ 1,020/1,020 cases matched; 0 issues
Agent episodes · Moli vs Chromium — ✅ 8/8 Moli episodes passed; 0 failures
Runtime and CDP session contracts — ✅ 26 contract cases; 0 failures
CDP smoke — ✅ 48/48 groups passed; 535 scenariosWorkers: 4 · cumulative group time: 147.74 s · failed groups: 0
WebMainBench · 545 pages — ⚪ artifact unavailable or invalidArtifact unavailable or invalid. See the source CI run for infrastructure details. All artifact fields are parsed by the trusted default-branch renderer; missing or invalid inputs remain visible as unavailable. |
Sequential Navigation Soak A/B✅ HEAD completed the 200-navigation resilience and memory observation. One browser process, one target, and one CDP session navigate CSDN → SegmentFault → Huaban → example.com repeatedly. This run issued 200 Common ancestor Session resilience
A public-page failure is reported separately from an unrecoverable session. The soak requires all navigation attempts, zero failed recovery, zero lifecycle/network ordering violations, and complete resource evidence. Process-tree memory
HEAD memory by 50-navigation quarter
Raw reports and per-navigation boundary samples: workflow run and full Public-site timing and memory are observational. Use the A/B deltas and trend shape as evidence, not as a deterministic performance threshold. |
lanyue-llk
left a comment
There was a problem hiding this comment.
审查结论:需要修改后再验收。本次使用 3 个子 agent 分别深入检查布局发布、窗口状态生命周期和目标路由,并以本地 Rust 测试及 Chrome 对照复核结论。
审查版本:HEAD 266b076af7e3fecb381e6d8913b0ac466595ce35;实际 PR 基线 66a74a67542140f0679a001bf22a5187d37bd850(#731),共 31 个变更文件。最新 main 已同步到 80660fb6d。未把堆叠 PR 的上游变更算成本 PR 引入的问题。
按需求评估
| 分类 | 原问题与方案判断 | 处理建议 |
|---|---|---|
| 无截图获取真实布局尺寸 | 该前提在当前基线上已解决。 普通 metrics 冷读会生成无 paint 的快照,暖读复用,DOM 变化后重建。本 PR 新测试却反向要求冷读报错,实际失败。 | 对顶层尺寸需求复用已有接口;如保留显式发布,需要明确其额外的强制刷新/递归 iframe 几何语义,并用对应场景证明必要性,不能以旧分支行为为依据。 |
| 同一顶层窗口跨文档保存 name | 将状态由 Document 移到 browsing context 的方向合理;原有跨源导航、父子窗口名称与新窗口初始名称隔离用例本次通过。 | PR 应区分同源、跨端口同站、跨站及 history 场景,说明兼容目标;当前描述仅写跨源,边界不清。 |
| 命名 popup 改名后复用、旧名失效 | 读取 live name 的目标合理;扫描整个 BrowserContext 的实现越过了独立窗口边界。真实回归已复现。 | 查询必须携带来源及可导航目标范围,不能只按字符串全局匹配。见 P1 行内意见。 |
| 无 paint 发布与资源约束 | 复用既有布局/嵌套 frame 组合链路是合理方案;但遗漏 Mock policy 检查,造成真实布局成本与模拟返回值混用。 | 在统一 renderer 入口遵守 layout policy,不要在各协议适配层分别打补丁。见 P2 行内意见。 |
代码与测试问题
已提交 4 条行内意见:独立标签页被错误命名导航、Mock 模式越界布局、非法 Window receiver 的错误测试预期、过时的普通 metrics 报错断言。
另外,既有 window_open_target_registry_preserves_named_target_bytes 也在此 HEAD 失败:fixture 登记了尚不存在的 TID-spaced / TID-exact,新增存活目标检查返回 None。这里应先创建真实 target 再检查名称空白/大小写的精确匹配;不应为让 fixture 通过而撤销存活目标检查。这属于必须修正的测试适配问题,不是额外的产品缺陷。
实测证据
- PR 原有两个 WebDriver Classic 正向测试:2/2 通过;补充独立 tab 场景后,该回归测试失败,窗口数实际 2、预期 3。Chrome 154.0.8037.57 对照为 3,原标签页未被导航。
- Renderer:原有 OnDemand publication 测试通过;新增 Mock 场景失败,真实 pass 0 → 1,2300×1500 内容返回 1920×1080 模拟尺寸。
- 新增普通 metrics 对照测试通过:冷读 1 次布局且 paint=0,暖读不新增 pass,DOM 变化后重新布局。这直接验证当前基线已支持原先宣称需要新增接口才能完成的顶层尺寸需求。
- Protocol:原始 HEAD 相关 8 项测试 6 通过、2 失败,失败就是上面的过时断言与不存在 target 的 fixture。仅临时修正这两处测试前提、不改生产逻辑后,8/8 通过,也到达并通过了显式 publication 的后续断言。临时编辑已还原。
- Chrome 的 Window name accessor 对普通对象调用会抛
TypeError: Illegal invocation;本 PR 新增的“成功存取 receiver-only-name”断言与此相反,不能作为健壮性的证据。
PR 当前 Verification 主要是通过数和概括性描述,没有按每个原问题提供 base 红 / HEAD 绿的对应证据;当前提交的两项仓库内测试失败也说明旧验证记录不能替代 retarget 后复测。本次是针对性审查,未将局部通过表述为全 workspace 通过。
描述建议按上面四类补齐“触发条件 → 原行为 → 预期行为 → 所属状态/生命周期 → 对应回归用例”。尤其需要先重写布局需求前提,再决定是否保留新增协议参数。Moli 面向智能体、多页面自动化和按需资源消费,本次关键验收点应是目标隔离、单一状态来源、默认模式不意外布局,以及可泛化的行为测试。
可复测补丁:独立窗口隔离、Mock 模式、普通 metrics 冷/暖/失效行为
在上述 HEAD 应用以下补丁后运行:
cargo nextest run -p moli-protocol-server -E 'test(review_853_)' --no-fail-fast
cargo nextest run -p moli-renderer-v8 -E 'test(review_853_)' --no-fail-fast预期:独立窗口隔离和 Mock 两项红测,普通 metrics 对照绿测。
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -17944,3 +17944,92 @@
"non-Classic router errors must not be relabeled as JSON: {content_type}"
);
}
+
+#[tokio::test]
+async fn review_853_named_target_does_not_capture_independent_tab() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ let window_path = format!("/session/{session_id}/window");
+ let url_path = format!("/session/{session_id}/url");
+ let execute_path = format!("/session/{session_id}/execute/sync");
+ let victim_url = classic_data_url("<!doctype html><title>independent victim</title>");
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &url_path,
+ json!({"url": victim_url})
+ )
+ .await,
+ json!({"value": null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &execute_path,
+ json!({"script": "window.name = 'independent-report'; return window.name;", "args": []})
+ )
+ .await,
+ json!({"value": "independent-report"})
+ );
+ let new_window = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/window/new"),
+ json!({"type": "tab"}),
+ )
+ .await;
+ let new_handle = new_window["value"]["handle"].as_str().unwrap();
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &window_path,
+ json!({"handle": new_handle})
+ )
+ .await,
+ json!({"value": null})
+ );
+ let source_url = classic_data_url(
+ "<!doctype html><button id='open' onclick=\"window.open('about:blank', 'independent-report')\">open</button>",
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &url_path,
+ json!({"url": source_url})
+ )
+ .await,
+ json!({"value": null})
+ );
+ let button = classic_find_css_element_id(app.clone(), session_id, "#open").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{button}/click")
+ )
+ .await,
+ json!({"value": null})
+ );
+ let handles = classic_request_json(
+ app.clone(),
+ Method::GET,
+ &format!("/session/{session_id}/window/handles"),
+ )
+ .await;
+ assert_eq!(
+ handles["value"].as_array().unwrap().len(),
+ 3,
+ "a named popup must not navigate an unrelated tab with the same window.name: {handles}"
+ );
+ let _ = classic_request_json(
+ app.clone(),
+ Method::DELETE,
+ &format!("/session/{session_id}"),
+ )
+ .await;
+}
--- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
@@ -6185,3 +6185,56 @@
.await
.expect("rendering-update post-checkpoint child synchronization test should run");
}
+
+#[tokio::test(flavor = "current_thread")]
+async fn review_853_mock_publication_does_not_run_real_layout() {
+ run_page_vm_async_test(async move {
+ let loader =
+ crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
+ let mut page_vm = test_page_vm_with_loader_and_document_url(
+ &loader,
+ Vec::new(),
+ Url::parse("https://example.com/mock-publication.html")?,
+ );
+ page_vm.layout_policy = moli_page_types::LayoutPolicy::Mock;
+ page_vm
+ .vm_mut()
+ .set_layout_policy(moli_page_types::LayoutPolicy::Mock);
+ page_vm
+ .vm_mut()
+ .eval("document.body.innerHTML = '<div style=\"width:2300px;height:1500px\"></div>'")?;
+ let before = page_vm.vm().layout_pass_observability_for_test().1;
+ let result = page_vm.publish_layout_metrics();
+ let after = page_vm.vm().layout_pass_observability_for_test().1;
+ eprintln!("review853 Mock publication before={before} after={after} result={result:?}");
+ assert_eq!(after, before, "Mock policy must not run real layout");
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .expect("mock publication regression");
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn review_853_ordinary_metrics_already_publish_without_paint() {
+ run_page_vm_async_test(async move {
+ let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
+ let mut page = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), Url::parse("https://example.com/ordinary-publication.html")?);
+ page.vm_mut().eval("document.body.innerHTML = '<div id=target style=\"width:2300px;height:1500px\"></div>'")?;
+ let before = page.vm().layout_pass_observability_for_test().1;
+ let first = page.layout_metrics()?;
+ let after = page.vm().layout_pass_observability_for_test();
+ assert_eq!(after.1, before + 1);
+ assert!(first.content_width >= 2300.0 && first.content_height >= 1500.0);
+ assert_eq!(after.3.unwrap().paint_operation_count, 0);
+ assert_eq!(page.layout_metrics()?, first);
+ assert_eq!(page.vm().layout_pass_observability_for_test().1, after.1);
+ page.vm_mut().eval("document.getElementById('target').style.width = '2600px'")?;
+ let changed = page.layout_metrics()?;
+ assert!(changed.content_width >= 2600.0);
+ let after_mutation = page.vm().layout_pass_observability_for_test();
+ assert_eq!(after_mutation.1, after.1 + 1);
+ assert_eq!(after_mutation.3.unwrap().paint_operation_count, 0);
+ eprintln!("review853 ordinary metrics: cold={first:?}, changed={changed:?}, passes={before}/{} /{}; paint=0", after.1, after_mutation.1);
+ Ok::<(), anyhow::Error>(())
+ }).await.expect("ordinary metrics already publish");
+}仅修正两处测试前提的绿测对照补丁(未改生产逻辑)
diff --git a/moli-protocol/src/conn/page_state/page_targets.rs b/moli-protocol/src/conn/page_state/page_targets.rs
index 154a6b8fe..6cb045169 100644
--- a/moli-protocol/src/conn/page_state/page_targets.rs
+++ b/moli-protocol/src/conn/page_state/page_targets.rs
@@ -1373,6 +1373,11 @@ mod tests {
);
let mut context = BrowserContext::new("BC-window-name".to_owned());
+ for target_id in ["TID-spaced", "TID-exact"] {
+ context.stage_background_target(
+ target_id.to_owned(), None, "about:blank".to_owned(), None, None,
+ );
+ }
context.remember_target_window_name(" ReportWindow ", "TID-spaced");
context.remember_target_window_name("ReportWindow", "TID-exact");
assert_eq!(
diff --git a/moli-protocol/src/domains/page/tests/capture.rs b/moli-protocol/src/domains/page/tests/capture.rs
index 7642828e4..123d181d5 100644
--- a/moli-protocol/src/domains/page/tests/capture.rs
+++ b/moli-protocol/src/domains/page/tests/capture.rs
@@ -1135,11 +1135,9 @@ async fn get_layout_metrics_can_explicitly_publish_without_a_screenshot() {
}))
.await;
let missing = take_response_by_id(&mut ctx, 126);
- assert!(
- missing["error"]["message"]
- .as_str()
- .is_some_and(|message| message.contains("no published layout")),
- "ordinary Page.getLayoutMetrics must remain a side-effect-free read: {missing:?}"
+ assert_eq!(
+ missing["result"]["contentSize"],
+ json!({ "x": 0, "y": 0, "width": 2300.0, "height": 1500.0 })
);
ctx.process_async(json!({cargo nextest run -p moli-protocol -E 'test(get_layout_metrics_) | test(window_open_target_registry_preserves_named_target_bytes)' --no-fail-fast原 HEAD:6/8;仅应用此补丁:8/8。
| if let Some(target) = self.page_targets.iter().find(|target| { | ||
| target | ||
| .navigation_engine() | ||
| .is_some_and(|engine| engine.top_level_window_name() == name) |
There was a problem hiding this comment.
[P1] 不要按名称命中无关联的独立标签页
这里扫描整个 BrowserContext,但它是 profile/storage 隔离域,并不表示所有标签页都属于可按名称互相导航的 browsing-context group。复现:A 设置 window.name = 'independent-report',通过 WebDriver window/new 创建独立 B,B 点击执行 window.open('about:blank', 'independent-report')。本 HEAD 的实测回归用例期望 3 个窗口,实际只有 ["TID-1","TID-2"]:A 被选为现有目标;Chrome 154.0.8037.57 同场景创建第三个窗口并保留 A。旧实现不会在 popup registry 中找到脚本自行命名的 A,因此这是新增扫描引入的回归。
请让查询携带来源 owner,优先当前 navigable,并限制到可导航的关联目标/正确的 browsing-context group;覆盖独立 tab、同名时当前窗口优先、关联 popup 改名复用和旧名失效。HTML 目标名称查找规则。
There was a problem hiding this comment.
已按 browsing-context group 修复。名称查找现在带来源 group,只在同组目标中复用;独立 tab 的同名窗口不会被劫持,相关 opener 创建并改名的 popup 仍可复用,旧名失效。对应独立 tab 与 related popup 场景均实测通过。
| self.flush_page_action_window(moli_action_window::ActionBarrier::Explicit)?; | ||
| if !self | ||
| .vm_mut() | ||
| .publish_layout_snapshot() |
There was a problem hiding this comment.
[P2] 显式布局发布必须先检查 LayoutPolicy
默认 LayoutPolicy::Mock 下,这里仍无条件执行真实布局及嵌套 frame 投影,随后 self.layout_metrics() 经 geometry/provider.rs 又走 Mock 查询。这既绕过无 --layout 模式的资源约束,又不能返回刚生成的真实尺寸。对 2300×1500 的内容,应在未启用真实布局时明确拒绝该操作,而不是耗费真实布局后返回模拟数据。
实际红测:Mock 下 pass count 0 → 1,返回 Ok(content_width:1920, content_height:1080);同批 OnDemand 原有测试通过。
请在统一 renderer 入口检查 policy,沿用 screenshot 的 layout-disabled 处理原则;补充默认 Mock 模式不创建真实 pass、OnDemand 正常发布且 paint=0 的测试。
There was a problem hiding this comment.
已在统一显式发布入口执行 LayoutPolicy 检查。Mock 模式明确拒绝且不创建 layout pass;OnDemand 可发布,paint 计数保持 0。嵌套 iframe 尺寸变化后会由现有递归 layout owner 重建并重新发布;原 PR 中重复的 projection 开关已删除。3 个布局发布场景均通过。
| .await; | ||
| assert_eq!( | ||
| forged_receiver, | ||
| json!({ "value": ["", "receiver-only-name"] }) |
There was a problem hiding this comment.
[P2] 不要把非法 Window receiver 的成功写入作为正确预期
新增断言要求 Object.getOwnPropertyDescriptor(window, 'name').set.call({}, 'receiver-only-name') 成功,并允许 getter 在普通对象上读回值;Chrome 154.0.8037.57 实测抛出 TypeError: Illegal invocation。普通对象不具有 Window 的 Web IDL 接收者身份。这个实现问题可能早已存在,但本次新增测试会将错误行为固定成必须维持的契约。
请将用例改为验证非法 receiver 抛 TypeError、原窗口名称保持不变,并让 getter/setter 两条路径都满足该契约。需要区分“原窗口未被污染”与“伪造 receiver 合法”,后者不是本需求。
There was a problem hiding this comment.
已改为 Web IDL receiver 校验:getter/setter 用普通对象调用均抛 TypeError,且原窗口名称不变。非法 receiver 场景实测通过。
| })) | ||
| .await; | ||
| let missing = take_response_by_id(&mut ctx, 126); | ||
| assert!( |
There was a problem hiding this comment.
[P2] 不要以已经失效的“普通 metrics 必须报错”作为发布功能前提
本测试在当前 HEAD 稳定失败:第一次普通 Page.getLayoutMetrics 已返回真实 2300×1500,而不是 no published layout。这与紧邻的既有 get_layout_metrics_queries_live_renderer_for_loaded_pages 一致,也与实际基线 #731 的按需布局契约一致:answer_layout_at_viewport 冷读时通过 LayoutPassRequest::new 生成并发布无 paint 的快照,暖读复用,输入变化后重建;这些链路本 PR 未修改。
请保留普通 metrics 的现有语义,修正这个相反的断言。就“无需截图取得真实尺寸”而言,现有接口已满足需求,不能用过时前提证明新增全栈参数的必要性。若保留强制 publication,需把其真正新增的整棵 iframe 几何发布语义写清楚,并以嵌套 frame / 失效重建场景证明差异。不要通过让普通读取重新报错来使本测试变绿。
There was a problem hiding this comment.
已保留普通 getLayoutMetrics 的既有按需布局语义,不再要求冷读报错。显式 publishLayout 仅承担递归刷新整棵 iframe 几何且不 paint 的新增语义,并由嵌套 frame 尺寸变化/重发布场景覆盖。普通 metrics 现有 7 项合同和显式 publishLayout 协议场景均通过。
lanyue-llk
left a comment
There was a problem hiding this comment.
复审结论:新版仍需修改;建议收敛为下面三项根因修复,不按每个失败场景继续添加特判。
审查 HEAD 4cb4eef7cc96ee4bda57fc10850a235f82138955,实际基线仍为 #731 的 66a74a67542140f0679a001bf22a5187d37bd850。3 个子 agent 分别覆盖窗口路由/状态生命周期、Window 绑定契约、布局及实际消费者。检查了完整 PR 差异及新版相对 266b076af 的 4 个文件变更。
先确认已修部分:上轮错误的“普通 metrics 必须报未发布布局”断言、名称注册表中不存在 target 的 fixture 已修正;新增 staged→engine 名称迁移和嵌套 frame 冻结测试通过。独立窗口查找范围、Mock 模式和非法 receiver 的生产行为/错误预期仍未修改。PR 描述及 Verification 也没有更新为新版的需求边界和证据。
| PR 要解决的问题 | 当前方案 | 本次判断 |
|---|---|---|
| 无截图获得真实 metrics、保留 iframe 几何 | 增加 publishLayout,跨协议/命令/renderer 触发强制整树布局 |
原有按需接口已经能完成指标读取、子 frame 几何和实际点击;新增能力是提前构建完整树,不能继续表述为修复“否则无法取得几何”。 |
| 顶层 name 跨 Document 保留,新窗口独立 | NavigationEngine 持有共享 name state,传给每个 Document | 状态跨文档共享的方向正确;独立 engine 初始化、导航延续及 child-context 区分路径未发现需另列的问题。 |
| 改名后命名窗口复用、旧名失效 | 全 BrowserContext 扫描 engine 的 live name | 缺少 source/group 约束,且只覆盖窗口自己写 name,未覆盖 opener 持有的 popup 代理。需求没有闭环。 |
| 尚未安装 engine 的目标也保留名称 | 先存 HashMap,bind 时查找、复制、删除 | 新测试能通过,但仍把一个窗口状态分成“engine 安装前/后”两套存储;维护成本来自状态归属问题,应在同一目标身份上消除这个分叉。 |
1. [P1] 窗口身份、名称来源和查找范围需要一起修正。
位置:目标查找、新增迁移、popup 代理名称维护。
实际复现的三种表现属于同一个模型缺口:
- 独立 A 设置 name,独立 B 按该名称打开:HEAD 错误导航 A,只有 2 个窗口;Chrome 创建第三个。此项是上轮已经确认、至今未修的 PR 回归。
- A/B 同名,B 按自己的名称打开:HEAD 导航更早创建的 A,B 留在原页面;Chrome 导航 B。不能靠调整遍历顺序修正,查询必须有来源身份。
- opener 保存
popup = window.open(..., 'old'),执行popup.name = 'new'再按 new 打开:HEAD 产生第三窗口,Chrome 复用原 popup。lightweight_popup_window_names/代理 slot 与 engine state 仍是不同的名称来源。此能力缺口原本就存在,但 PR 声称同步 live names 后仍未覆盖它;这里将其纳入同一修复的验收,不冒充另一个新增回归。
建议的最小完整结构:
- 现有
PageTargetHost/稳定目标身份从创建起持有同一个名称 state,engine 和后续 Document 接入同一 handle;删除target_window_names及 bind 时的字符串迁移。无需新增一套通用注册中心。 - popup 代理与实际目标共享同一名称来源。现有
RendererPendingPopupActivation已能携带共享 storage handle,可以沿既有 owner/source 受约束的通道接入名称 state;不能用全局裸 popup ID 转发。 - 复用既有目标时,代理必须绑定那个既有 owner/state,不能把新代理的状态覆盖给目标。 现有
remember_resolved_popup_target只记 ID,尚没有这种 state 回绑;只向前多传一个 Arc 不能算完整修复。 - 解析入口带 source owner,先当前目标,再同一 browsing-context group 中允许的目标。BrowserContext 是 profile/storage 域,不是这个选择范围。group identity 应是现有目标上的稳定字段,独立窗口分配独立身份、关联 popup 继承;不要临时沿 opener 边猜 group,因为关闭 opener 的清理会删除这些边。
这样消除的是多份状态和错误选择范围,不是为独立 tab、同名 tab、代理改名各加一个 if。
2. [P2] Window.name getter/setter 应满足统一的 receiver 与转换契约。
位置:runtime_state.rs、仍然错误的 receiver 断言。
新版继续使用 to_string(...).unwrap_or_default(),转换失败仍向 name owner 写空串。两项实测红测:
name='kept'后赋值一个toString抛错的对象,结果为[false, ""],预期原异常按预期传播且名称保持kept。toString内先成功写inner再抛错,最终名称为"",预期inner。
非法 receiver 的原用例仍要求普通对象能成功存取 name;Chrome 会在转换参数之前抛 TypeError。这些应作为一个绑定契约修正,不要按 Symbol、抛错对象、重入对象分别特判。
最小正确顺序是:验证真实 Window 并解析对应 owner → DOMString 转换 → 失败直接返回 → 成功后写入该 owner。 不能在失败后“回滚到转换前的名字”,那会抹去重入转换中已经成功完成的内层写入。临时仅将当前转换改为失败立即返回,两项转换测试已从 2 红变为 2 绿;实验代码已还原。非法 receiver 校验仍须一并完成,不能把这个小补丁当完整修复。
3. [P2] 按当前需求,建议删除 publishLayout 的全栈扩展,复用已有按需布局入口。
位置:显式发布入口。新增嵌套测试证明它会构建完整私有树,但没有证明用户操作需要这条新协议路径。
本次补充了真实消费者测试:普通 metrics → 孙 frame 命中 → child JS 几何读取 → 子文档样式变化 → 真正 mousedown/mouseup 触发 onclick。不使用 publishLayout 也全部通过;现有 HitTest 会发现 embedded_frames_complete 不满足并按需升级,暖读复用,变更后自动重建,paint 为 0。显式路径也通过,并在该 fixture 中省去一次后续几何补齐:普通路径 pass 为 1→2→3,显式路径为 1→1→2。这是提前构建带来的差别,不是正确性缺失。
与之同时,Mock 缺陷仍复现:2300×1500 内容触发真实 pass 0→1,最后返回 1920×1080 的模拟尺寸。按当前写明的功能需求,移除新增参数及跨层命令分支,保留按需布局消费者回归测试,是更小且完整的方案,也避免继续补这条无必要路径的模式特判。若未来确实需要独立的整树预热能力,应有明确调用方、资源契约及收益证据;目前仓库内 publishLayout 的调用只有测试,PR 未提供这样的需求依据。
验证结果与收敛验收:
- 完整运行 renderer、protocol、protocol-server 三套测试:仓库原有用例 12,381 通过、4 跳过(renderer 8,332;protocol 3,559;server 490),当前 HEAD 的 GitHub CI 也全部通过。
- 最终按 Chrome 校准预期的额外场景矩阵:8 项,6 红、2 绿。三项窗口场景、两项转换场景和 Mock 场景为红;普通布局完成嵌套交互、已有命名 popup 的 noopener 行为为绿。
noopener的最初假设已被 Chrome 实测排除,不计为缺陷;不要为了迎合这一错误预期去改实现。- 名称转换的最小纠正实验:2/2 绿。没有把临时测试或实验生产修改提交到 PR。
后续验收应使用同一矩阵,覆盖创建前后同一 state、双方改名读回、已有目标复用、同名 source 优先、独立目标隔离、关闭 opener 后关联目标仍保持身份、转换异常/重入,以及普通按需布局的冷/暖/失效与嵌套输入。先完成这三项根因修复,再更新 PR 标题/描述与实际验证记录;不应只继续修正断言或增加同步分支。
最终 8 项复现场景补丁及运行命令
应用到本审查 HEAD:
cargo nextest run -p moli-protocol -p moli-protocol-server -p moli-renderer-v8 -E 'test(/review_?853/)' --no-fail-fast --success-output immediate当前结果:6 failed / 2 passed。
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -17944,3 +17944,330 @@
"non-Classic router errors must not be relabeled as JSON: {content_type}"
);
}
+
+#[tokio::test]
+async fn review_853_named_target_does_not_capture_independent_tab() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ let window_path = format!("/session/{session_id}/window");
+ let url_path = format!("/session/{session_id}/url");
+ let execute_path = format!("/session/{session_id}/execute/sync");
+ let victim_url = classic_data_url("<!doctype html><title>independent victim</title>");
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &url_path,
+ json!({"url": victim_url})
+ )
+ .await,
+ json!({"value": null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &execute_path,
+ json!({"script": "window.name = 'independent-report'; return window.name;", "args": []})
+ )
+ .await,
+ json!({"value": "independent-report"})
+ );
+ let new_window = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/window/new"),
+ json!({"type": "tab"}),
+ )
+ .await;
+ let new_handle = new_window["value"]["handle"].as_str().unwrap();
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &window_path,
+ json!({"handle": new_handle})
+ )
+ .await,
+ json!({"value": null})
+ );
+ let source_url = classic_data_url(
+ "<!doctype html><button id='open' onclick=\"window.open('about:blank', 'independent-report')\">open</button>",
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &url_path,
+ json!({"url": source_url})
+ )
+ .await,
+ json!({"value": null})
+ );
+ let button = classic_find_css_element_id(app.clone(), session_id, "#open").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{button}/click")
+ )
+ .await,
+ json!({"value": null})
+ );
+ let handles = classic_request_json(
+ app.clone(),
+ Method::GET,
+ &format!("/session/{session_id}/window/handles"),
+ )
+ .await;
+ assert_eq!(
+ handles["value"].as_array().unwrap().len(),
+ 3,
+ "a named popup must not navigate an unrelated tab with the same window.name: {handles}"
+ );
+ let _ = classic_request_json(
+ app.clone(),
+ Method::DELETE,
+ &format!("/session/{session_id}"),
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn review_853_noopener_matches_chrome_named_popup_reuse() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let sid = session["value"]["sessionId"].as_str().unwrap();
+ let original =
+ classic_request_json(app.clone(), Method::GET, &format!("/session/{sid}/window")).await;
+ let page = classic_data_url(
+ "<!doctype html><button id='first' onclick=\"window.open('about:blank', 'report')\">first</button><button id='open' onclick=\"window.open('about:blank', 'report', 'noopener')\">open</button>",
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/url"),
+ json!({"url":page})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let first = classic_find_css_element_id(app.clone(), sid, "#first").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/element/{first}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window"),
+ json!({"handle":original["value"]})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let button = classic_find_css_element_id(app.clone(), sid, "#open").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/element/{button}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ let handles = classic_request_json(
+ app.clone(),
+ Method::GET,
+ &format!("/session/{sid}/window/handles"),
+ )
+ .await;
+ let _ = classic_request_json(app.clone(), Method::DELETE, &format!("/session/{sid}")).await;
+ assert_eq!(
+ handles["value"].as_array().unwrap().len(),
+ 2,
+ "Chrome retains the existing named auxiliary context for this noopener call: {handles}"
+ );
+}
+
+#[tokio::test]
+async fn review_853_same_name_prefers_source_tab() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let sid = session["value"]["sessionId"].as_str().unwrap();
+ let first =
+ classic_request_json(app.clone(), Method::GET, &format!("/session/{sid}/window")).await;
+ let first_url = classic_data_url("<!doctype html><title>first tab</title>");
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/url"),
+ json!({"url":first_url})
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/execute/sync"),
+ json!({"script":"window.name='shared'; return window.name;", "args":[]})
+ )
+ .await,
+ json!({"value":"shared"})
+ );
+ let second = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window/new"),
+ json!({"type":"tab"}),
+ )
+ .await;
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window"),
+ json!({"handle":second["value"]["handle"]})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let source = classic_data_url(
+ "<!doctype html><button id='open' onclick=\"window.open('about:blank','shared')\">open</button>",
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/url"),
+ json!({"url":source})
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/execute/sync"),
+ json!({"script":"window.name='shared'; return window.name;", "args":[]})
+ )
+ .await,
+ json!({"value":"shared"})
+ );
+ let button = classic_find_css_element_id(app.clone(), sid, "#open").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/element/{button}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window"),
+ json!({"handle":first["value"]})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let first_after =
+ classic_request_json(app.clone(), Method::GET, &format!("/session/{sid}/url")).await;
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window"),
+ json!({"handle":second["value"]["handle"]})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let second_after =
+ classic_request_json(app.clone(), Method::GET, &format!("/session/{sid}/url")).await;
+ let _ = classic_request_json(app.clone(), Method::DELETE, &format!("/session/{sid}")).await;
+ assert_eq!(
+ json!([first_after["value"], second_after["value"]]),
+ json!([first_url, "about:blank"]),
+ "window.open using the source's own name must prefer the source over an earlier same-name tab"
+ );
+}
+
+#[tokio::test]
+async fn review_853_popup_proxy_rename_routes_same_target() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let sid = session["value"]["sessionId"].as_str().unwrap();
+ let original =
+ classic_request_json(app.clone(), Method::GET, &format!("/session/{sid}/window")).await;
+ let page = classic_data_url(
+ "<!doctype html><button id='first' onclick=\"window.popup=window.open('about:blank','oldName')\">first</button><button id='rename' onclick=\"window.popup.name='newName'; window.open('about:blank','newName')\">rename and reuse</button>",
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/url"),
+ json!({"url":page})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let first = classic_find_css_element_id(app.clone(), sid, "#first").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/element/{first}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/window"),
+ json!({"handle":original["value"]})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let rename = classic_find_css_element_id(app.clone(), sid, "#rename").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{sid}/element/{rename}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ let handles = classic_request_json(
+ app.clone(),
+ Method::GET,
+ &format!("/session/{sid}/window/handles"),
+ )
+ .await;
+ let _ = classic_request_json(app.clone(), Method::DELETE, &format!("/session/{sid}")).await;
+ assert_eq!(
+ handles["value"].as_array().unwrap().len(),
+ 2,
+ "renaming the popup WindowProxy must update the browser's target name: {handles}"
+ );
+}
--- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
@@ -6267,3 +6267,80 @@
.await
.expect("rendering-update post-checkpoint child synchronization test should run");
}
+
+#[tokio::test(flavor = "current_thread")]
+async fn review853_nested_input_upgrades_ordinary_metrics_without_publication() {
+ run_page_vm_async_test(async move {
+ let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
+ for explicit in [false, true] {
+ let mut page_vm = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), Url::parse("https://example.com/nested-demand.html")?);
+ page_vm.vm_mut().eval(
+ r#"
+document.head.innerHTML = '<style>html,body{margin:0;padding:0}iframe{border:0}</style>';
+document.body.innerHTML = '<iframe id="outer" style="position:absolute;left:20px;top:30px;width:240px;height:140px"></iframe>';
+const outer = document.getElementById('outer');
+outer.contentDocument.head.innerHTML = '<style>html,body{margin:0;padding:0}iframe{border:0}</style>';
+outer.contentDocument.body.innerHTML = '<div id="child-target" style="position:absolute;left:11px;top:13px;width:70px;height:25px"></div><iframe id="inner" style="position:absolute;left:90px;top:40px;width:100px;height:60px"></iframe>';
+const inner = outer.contentDocument.getElementById('inner');
+inner.contentDocument.head.innerHTML = '<style>html,body{margin:0;padding:0}</style>';
+inner.contentDocument.body.innerHTML = '<div id="grandchild-target" style="position:absolute;left:7px;top:9px;width:30px;height:18px"></div>';
+'installed'
+"#,
+ )?;
+ page_vm.vm_mut().sync_live_document_style_sources();
+ let before = page_vm.vm().layout_pass_observability_for_test().1;
+ if explicit { page_vm.publish_layout_metrics()?; } else { page_vm.layout_metrics()?; }
+ let after_metrics = page_vm.vm().layout_pass_observability_for_test().1;
+ assert_eq!(after_metrics, before + 1);
+ let point = moli_layout::LayoutPoint::new(120.0, 82.0);
+ let hit = page_vm.vm_mut().observable_deep_hit_test_for_current_document(point, false)?.expect("nested point has a target");
+ let host = page_vm.vm().context_host_weak_for_test().upgrade().unwrap();
+ assert_eq!(host.borrow().dom_host().get_attribute(hit, "id").as_deref(), Some("grandchild-target"));
+ let after_hit = page_vm.vm().layout_pass_observability_for_test();
+ assert_eq!(after_hit.1, after_metrics + if explicit { 0 } else { 1 });
+ assert_eq!(after_hit.3.unwrap().paint_operation_count, 0);
+ assert_eq!(page_vm.vm_mut().observable_deep_hit_test_for_current_document(point, false)?, Some(hit));
+ assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, after_hit.1);
+ let rect = page_vm.vm_mut().eval("outer.contentDocument.getElementById('inner').contentDocument.getElementById('grandchild-target').getBoundingClientRect().width")?;
+ assert_eq!(rect, "30");
+ assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, after_hit.1, "child JS geometry reuses the complete tree");
+ page_vm.vm_mut().eval("inner.contentDocument.getElementById('grandchild-target').style.width = '35px'")?;
+ assert_eq!(page_vm.vm_mut().observable_deep_hit_test_for_current_document(point, false)?, Some(hit));
+ assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, after_hit.1 + 1, "dirty child input updates geometry automatically");
+ eprintln!("review853 explicit={explicit}: metrics then input correct; cold metrics pass={} input pass={} dirty pass={}", after_metrics, after_hit.1, after_hit.1+1);
+ page_vm.vm_mut().eval("globalThis.__review853Clicks = 0; inner.contentDocument.getElementById('grandchild-target').onclick = () => globalThis.__review853Clicks++")?;
+ page_vm.vm_mut().dispatch_mouse_event_at_point(120.0, 82.0, "mousedown", 0, None, 0.0, 0.0)?;
+ page_vm.vm_mut().dispatch_mouse_event_at_point(120.0, 82.0, "mouseup", 0, Some(0), 0.0, 0.0)?;
+ assert_eq!(page_vm.vm_mut().eval("String(globalThis.__review853Clicks)")?, "1", "actual coordinate input reaches the nested frame");
+ }
+ Ok::<(), anyhow::Error>(())
+ }).await.expect("nested demand upgrades automatically");
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn review_853_mock_publication_does_not_run_real_layout() {
+ run_page_vm_async_test(async move {
+ let loader =
+ crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
+ let mut page_vm = test_page_vm_with_loader_and_document_url(
+ &loader,
+ Vec::new(),
+ Url::parse("https://example.com/mock-publication.html")?,
+ );
+ page_vm.layout_policy = moli_page_types::LayoutPolicy::Mock;
+ page_vm
+ .vm_mut()
+ .set_layout_policy(moli_page_types::LayoutPolicy::Mock);
+ page_vm
+ .vm_mut()
+ .eval("document.body.innerHTML = '<div style=\"width:2300px;height:1500px\"></div>'")?;
+ let before = page_vm.vm().layout_pass_observability_for_test().1;
+ let result = page_vm.publish_layout_metrics();
+ let after = page_vm.vm().layout_pass_observability_for_test().1;
+ eprintln!("review853 Mock publication before={before} after={after} result={result:?}");
+ assert_eq!(after, before, "Mock policy must not run real layout");
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .expect("mock publication regression");
+}
--- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs
@@ -157,6 +157,7 @@
mod popup_document_completion;
mod preferred_aspect_ratio;
mod rendering_update;
+mod review_window_name_contract;
mod service_worker;
mod service_worker_client_message;
mod service_worker_internal;
--- /dev/null
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/review_window_name_contract.rs
@@ -0,0 +1,57 @@
+use super::*;
+
+#[tokio::test(flavor = "current_thread")]
+async fn review_853_window_name_failed_conversion_preserves_owner_state() {
+ run_page_vm_async_test(async move {
+ let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
+ let mut page_vm = test_page_vm_with_loader_and_document_url(
+ &loader,
+ Vec::new(),
+ Url::parse("https://example.com/window-name.html")?,
+ );
+ let result = page_vm.vm_mut().eval(
+ r#"
+window.name = 'kept';
+let caught = false;
+try { window.name = {toString() { throw new Error('conversion failed'); }}; }
+catch (e) { caught = e.message === 'conversion failed'; }
+JSON.stringify([caught, window.name]);
+"#,
+ )?;
+ assert_eq!(result, r#"[true,"kept"]"#);
+ assert_eq!(
+ page_vm
+ .vm()
+ .top_level_browsing_context_state()
+ .window_name(),
+ "kept"
+ );
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .expect("window name conversion contract");
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn review_853_window_name_reentrant_conversion_preserves_completed_inner_write() {
+ run_page_vm_async_test(async move {
+ let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
+ let mut page_vm = test_page_vm_with_loader_and_document_url(
+ &loader,
+ Vec::new(),
+ Url::parse("https://example.com/window-name.html")?,
+ );
+ let result = page_vm.vm_mut().eval(
+ r#"
+window.name = 'before';
+try { window.name = {toString() { window.name = 'inner'; throw new Error('conversion failed'); }}; }
+catch (e) {}
+window.name;
+"#,
+ )?;
+ assert_eq!(result, "inner");
+ Ok::<(), anyhow::Error>(())
+ })
+ .await
+ .expect("reentrant window name conversion contract");
+}两项转换用例的最小绿测实验(仅验证转换根因,不代表完整修复)
diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs
index d942d938d..c94b4c8e3 100644
--- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs
+++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs
@@ -900,11 +900,10 @@ fn window_name_runtime_setter<'s>(
_rv: v8::ReturnValue<'s, v8::Value>,
) {
let receiver = callback_this_object(scope, &args);
- let next = args
- .get(0)
- .to_string(scope)
- .map(|value| value.to_rust_string_lossy(scope))
- .unwrap_or_default();
+ let Some(next) = args.get(0).to_string(scope) else {
+ return;
+ };
+ let next = next.to_rust_string_lossy(scope);
if let Some(handle) = child_context_handle_from_owner(scope, receiver)
&& let Some(host_ptr) = context_host_ptr_from_global_bridge(scope)
{cargo nextest run -p moli-protocol -p moli-protocol-server -p moli-renderer-v8 -E 'test(review_853_window_name_)' --no-fail-fast结果:2 passed;所有临时编辑已还原。
93a9bdb to
7bc4360
Compare
lanyue-llk
left a comment
There was a problem hiding this comment.
本轮复审结论:仍需修改;剩余业务问题收敛为“窗口名称状态没有贯通”和“Window.name 绑定契约不完整”两类,不按每个失败表现继续拆分意见。 以下只引用原始 HEAD 的已完成复测,不把临时修复实验当成当前 PR 的通过证据。
审查 HEAD:7bc43604fa85d79be03410a51361a3e1d32b2849;实际堆叠基线:b1fe8592246c6f859a25c293232159db694dd48f(#731)。已同步 main,并由 3 个子 agent 分别检查路由与身份、Window 绑定、布局与实际消费者。发布前已再次确认 HEAD/base 未变。
| 需求 | 新版实现判断 | 验收结果 |
|---|---|---|
| 同一窗口跨 Document 保留 name、新窗口独立 | engine 持有共享 state 的方向正确,但新版创建请求丢失该 state | data→data、HTTP 页面替换均丢名;命名空 popup 初始 name 也为空 |
| live name 路由、关联窗口复用、独立窗口隔离 | source 优先及稳定 group 过滤合理;真实页面与代理仍没有统一名称来源 | 独立窗口隔离通过;自身同名导航、代理改名复用失败 |
| 无截图更新布局、保留嵌套 frame 几何 | 当前基线下显式刷新已有快照有实际作用;复用现有 publication 链路合理 | Mock 限制、无 paint、嵌套重发布通过;实际孙 frame 命中、尺寸更新和点击也通过 |
| Web IDL 的 Window 接收者及参数转换 | 新 guard 只认当前 realm 的 global,仍用空字符串兜底转换失败 | 合法跨 realm Window 被拒绝;异常转换会破坏已有名称 |
1. [P1] 把同一个名称状态贯通 engine → 页面 → popup 代理,先修新引入的创建请求断链。
新版在 HTML 创建路径第 928 行和 streaming 创建路径第 1229 行填入 top_level_browsing_context: Default::default()。NavigationEngine 虽然已向 RendererDocumentOptions 传入共享 state,但两个 request 类型和 builder 没有携带它,实际 Document 因此拿到另一个 state。编译通过并不代表 ownership 传递正确。
原始 HEAD 实测:
- data 文档设置
inline-context,替换文档后读到空字符串。 - HTTP 同源页面设置
streaming-context,导航到另一个 query 的文档后读到空字符串。 window.open('', 'html-context-name'),切换到 popup 读取其真实window.name,得到空字符串。- 两个窗口都自行设置
shared,当前窗口按自己的名称打开时,当前窗口没有被导航。source 优先的查找结构已经正确,但 engine 读不到页面实际写入的 name。
这些场景的 Chrome 154.0.8037.58 对照符合测试期望。另一个仍未闭环的路径是 轻量 popup 代理 getter/setter:它仍更新代理 slot/局部名称表,路由则读 engine state。opener 保存 popup,执行 popup.name='new' 后按 new 打开,Moli 产生 3 个窗口,Chrome 为 2 个。这个代理缺口上轮已经指出,不算本轮新发明的问题。
最小完整修法:恢复两条 options → request → PageVmEnv 的 state 传递,不能在新 Document 创建处重新分配;再沿现有 popup activation 通道把代理关联到真实目标的同一名称状态。复用已有 target 时,需要把代理绑定回已有 state,不能用新代理的状态覆盖旧目标。保留已修好的 source/group 约束。暂存表可以继续承担 engine 安装前的暂存职责,不因为仍有 HashMap 就强制重构或新建全局注册中心。
测试覆盖还发生了回退:上一版 4cb4eef7c 在跨源窗口引用测试和命名 popup 测试中加入的 name 持久化、初始名、改名及旧名失效断言,没有随本次文件拆分保留下来。当前同名测试主要只验证窗口句柄或页面文本,不能证明 name 契约;应恢复这些行为断言。
2. [P2] 统一按真实 Window receiver 解析 owner,再进行可失败的字符串转换。
第 921–949 行把“等于当前 realm 的 global”当作顶层 Window 品牌。这修复了普通对象调用,却误拒绝合法的同源跨 realm 接收者:在 iframe 内取得自己的 name descriptor,调用 descriptor.get.call(parent)、descriptor.set.call(parent, 'parent-after'),Moli 两次抛 TypeError,父窗口保持原值;Chrome 两次成功,父窗口变为 parent-after。这是新 guard 引入的回归,不能通过只覆盖 {} 接收者来验收。
第 951–963 行的旧问题也仍在:to_string() 失败后 unwrap_or_default() 继续写 owner。本轮实际结果为:
- 原名
kept,toString()抛错:异常被捕获,但 name 变成'',应仍为kept。 toString()先成功写入inner再抛错:最终变成'',应保留已完成的inner写入。
修法应是一个一致的绑定流程:通过真实 Window 身份解析 receiver 所属 owner(包括合法跨 realm Window)→ 拒绝非法 receiver → 转换一次 → 转换失败立即返回 → 成功后只更新该 owner。不要按调用 realm 猜 owner,不要将失败转换当作空字符串,也不要用回滚原值破坏重入期间已经完成的写入。
3. CI 的 12 项失败也已归因,需收敛基线整合,不能靠删除场景或放宽断言过关。
| 失败组 | 根因与归属 | 最小处理方向 |
|---|---|---|
| 8 项 dirty geometry/grid/offsetParent/CSSOM + 2 项 frame click | 已有快照后 ensure_initial_layout() 直接复用,DOM/style 变化未刷新。相关生产路径及测试文件与本 PR 当前基线相同,属于继承的契约冲突 |
README 仍承诺 dirty demand 自动刷新,应在统一 geometry/输入 owner 边界保证新鲜布局,保留正确尺寸、遮挡和点击次数断言,不给每个用例加私有 CDP 调用掩盖问题 |
stop_loading_before_response_preserves_document_and_allows_next_navigation |
基线 typed cancellation 早退成 Err,materializer 保留了旧 Document,却把结果映射成顶层 protocol error;测试要求的是带 net::ERR_ABORTED 的导航结果 |
在统一结果映射边界保留 typed cause,同时输出 canonical cancellation 和正确 CDP result 形状;同步修正要求完整 anyhow 文本/top-level error 的冲突测试 |
same_context_named_popup_reuse_navigates_and_activates_loaded_owner |
fixture 用 Target.createTarget 创建独立窗口,仅赋名后就要求另一个窗口复用它;与本 PR 正确的新 group 约束冲突 |
用真正关联的 popup 构造复用场景,保留独立窗口隔离对照,不回退 group 过滤 |
两个点击失败是相反方向的同一问题:新增遮挡 iframe 后错误返回成功;隐藏遮挡后仍错误返回拦截。它们不能因“手动发布后可点击”就视为已修好,标准 WebDriver 调用者不应额外知道私有 publication 参数。
同时纠正上一轮布局建议:当前 base 已变化。普通 geometry 首次仍会构建快照,但已有快照不会因 dirty 自动更新,显式 publishLayout 因而确实提供无 paint 刷新的作用。本轮不再建议仅以“首次普通 metrics 已足够”为由删除该接口。 应把这个真实需求、与普通读取的区别、基线契约冲突写清楚。当前 PR body/Verification 仍沿用旧描述和通过数,未反映这些边界。
删除代码的核查结果:5 个 navigation 测试以及 fixed_inline_font 原来各有两份,逐个函数全文相同,本次各保留一份;这一部分是去重,没有削减不同场景的覆盖。
已完成的验证:
- 原始 HEAD 的
moli-protocol、moli-protocol-server、moli-renderer-v8全套加 6 项审查探针:12,650 run / 12,634 pass / 16 fail / 4 skip。其中仓库原有测试失败正是上述 CI 的 12 项,另 4 项为审查补充场景。 - 扩展后的独立场景矩阵:11 项,3 通过、8 失败。8 个失败按上面两类业务问题处理;3 个通过为独立窗口隔离、Chrome 对照校准的既有 named popup/noopener 复用、嵌套 publication 实际消费者。
- 嵌套消费者实测:孙 frame 命中正确,重新 publication 后宽度 30→35,两次 paint=0,真实 mousedown/mouseup 触发 onclick=1。
- 本轮新增的导航/初始名及跨 realm receiver 期望均用 Chrome 154.0.8037.58 核验。Mock 拒绝真实布局、无 paint 发布、嵌套 frame 重发布三项仓库测试通过。
修复验收应让这些真实红测在不降低期望的情况下转绿,并消除 CI 的契约冲突。本轮只交付审查意见;所有本地临时修改已还原,没有提交或推送代码。
Summary
window.namein browsing-context state across document and cross-origin navigationStack
This change is based on #731, which is based on #640.
Verification
cargo nextest run -p moli-renderer-v8 --no-fail-fast: 8,331 passed, 4 skippedwindow.name, named popup initialization, rename reuse, and stale-name isolation: passedcargo clippy --workspace --all-targets --all-features -- -D warnings: passed with incremental compilation disabled after clearing a Rust compiler cache ICEcargo fmt --all -- --check: passedgit diff --check: passed