Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions moli-cdp-smoke/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,16 @@ The current suite is a strong core smoke gate, not a complete Playwright compati
Covered well:

- The default raw `debugger-breakpoints`, `runtime-exception`, and
`file-chooser` groups preserve six raw-CDP contracts covering seven Lexbench
task regressions at the public process boundary, and add two multi-attachment
Runtime exception contracts. They dispatch `Debugger.getPossibleBreakpoints`,
`file-chooser` groups preserve focused Lexbench regressions at the public
process boundary, including multi-attachment Runtime exception contracts.
They dispatch `Debugger.getPossibleBreakpoints`,
`setBreakpoint`, `removeBreakpoint`, and `setBreakpointByUrl` while the Page
is normally running, require an uncaught timer error to publish
`Runtime.exceptionThrown` without a follow-up command, verify that each
Runtime-enabled attachment receives the target-owned exception while a
disabled peer does not, and require a user-gesture file-input activation to
publish the session-scoped `Page.fileChooserOpened` event.
disabled peer does not, keep `Runtime.enable` from making Error stack cost
track JavaScript stack depth, and require a user-gesture file-input activation
to publish the session-scoped `Page.fileChooserOpened` event.
- The default raw `url-policy` group holds the hosted local-file boundary at the
public process edge. It requires an exact session-routed `Page.navigate`
`-32000` error with no lifecycle or document replacement, verifies page
Expand Down
86 changes: 86 additions & 0 deletions moli-cdp-smoke/moli_cdp_smoke/groups/protocol_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ async def run_runtime_exception_group(
# asynchronous exception.
enable_id = await client.send("Runtime.enable", session_id=session_id)
await client.recv_until_id(enable_id, timeout=5)
stack_cost = await _runtime_enable_stack_cost_probe(client, session_id)
record(results, "raw_cdp_runtime_enable_stack_cost", stack_cost)
marker = "moli-smoke-async-exception"
exceptions = await _schedule_async_exception_and_collect(
client,
Expand Down Expand Up @@ -209,6 +211,90 @@ async def run_runtime_exception_group(
await _close_client(client)


async def _runtime_enable_stack_cost_probe(
client: RawCdpClient,
session_id: str,
) -> dict[str, Any]:
evaluate_id = await client.send(
"Runtime.evaluate",
{
"expression": r"""
(() => {
const measureOnce = () => {
let sink = 0;
const sample = depth => {
if (depth > 0) return sample(depth - 1);
const started = performance.now();
for (let index = 0; index < 3; index += 1) {
try {
throw new Error('runtime-stack-cost-probe');
} catch (error) {
sink += error.stack.length;
}
}
return (performance.now() - started) / 3;
};

for (let index = 0; index < 50; index += 1) sample(50);
const depths = [];
const costs = [];
for (let depth = 10; depth <= 200; depth += 10) {
let cost = 0;
for (let repeat = 0; repeat < 4; repeat += 1) cost += sample(depth);
depths.push(depth);
costs.push(cost / 4);
}

const mean = values =>
values.reduce((total, value) => total + value, 0) / values.length;
const meanDepth = mean(depths);
const meanCost = mean(costs);
let covariance = 0;
let depthVariance = 0;
let costVariance = 0;
for (let index = 0; index < depths.length; index += 1) {
const depthDelta = depths[index] - meanDepth;
const costDelta = costs[index] - meanCost;
covariance += depthDelta * costDelta;
depthVariance += depthDelta * depthDelta;
costVariance += costDelta * costDelta;
}
const slope = depthVariance === 0 ? 0 : covariance / depthVariance;
const rSquared = depthVariance === 0 || costVariance === 0
? 0
: (covariance * covariance) / (depthVariance * costVariance);
return {rSquared, slope, sink};
};
return [measureOnce(), measureOnce(), measureOnce()];
})()
""",
"returnByValue": True,
},
session_id=session_id,
)
response, _messages = await client.recv_until_id(evaluate_id, timeout=5)
if "error" in response:
raise SmokeError(f"Runtime stack-cost probe failed: {response}")
probes = response.get("result", {}).get("result", {}).get("value")
if not isinstance(probes, list) or len(probes) != 3:
raise SmokeError(f"Runtime stack-cost probe returned invalid samples: {response}")
try:
ordered_r_squared = sorted(float(probe["rSquared"]) for probe in probes)
except (KeyError, TypeError, ValueError) as error:
raise SmokeError(
f"Runtime stack-cost probe returned invalid regression data: {probes}"
) from error
if ordered_r_squared[1] >= 0.5:
raise SmokeError(
"Runtime.enable exposed a repeatable Error stack-depth timing slope: "
f"{probes}"
)
return {
"probes": probes,
"medianRSquared": ordered_r_squared[1],
}


async def run_file_chooser_group(
endpoint: str,
fixture: str,
Expand Down
2 changes: 1 addition & 1 deletion moli-cdp-smoke/moli_cdp_smoke/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async def _await_group(group: SmokeGroup, awaitable: Awaitable[None]) -> None:
),
SmokeGroup(
"runtime-exception",
"Raw asynchronous Runtime.exceptionThrown delivery without a follow-up command.",
"Raw Runtime.enable stack-cost privacy and asynchronous Runtime.exceptionThrown delivery.",
"raw",
run_runtime_exception_group,
),
Expand Down
80 changes: 80 additions & 0 deletions moli-renderer-v8/src/inspector_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! Synchronous Inspector session commands shared by Page and Worker owners.
//!
//! V8 enables Runtime with a 200-frame exception capture limit. Use a ten-frame
//! default to bound the extra cost of constructing Error objects, while leaving
//! explicit frontend limits and restored session settings under V8's control.

use serde::Deserialize;
use serde_json::{Value, json};

const STACK_CAPTURE_CALL_ID: i32 = -1;
const DEFAULT_RUNTIME_STACK_CAPTURE_DEPTH: i32 = 10;

#[cfg(test)]
pub(crate) mod tests;

/// Adapts the owner's response routing without changing its notification stream.
pub(crate) trait InspectorSessionOutput {
/// Only for synchronous, non-reentrant Inspector settings. Capture this
/// dispatch's response before frontend routing, even if a frontend uses the
/// same ID. Preserve queued responses, callbacks, and notifications.
fn capture_internal_response(&self, call_id: i32, dispatch: impl FnOnce()) -> Option<Value>;
}

/// The caller establishes its usual Inspector isolate/microtask scope.
pub(crate) fn dispatch_with_runtime_defaults(
session: &v8::inspector::V8InspectorSession,
raw_json: &str,
output: &impl InspectorSessionOutput,
) -> Result<(), String> {
let enabling_runtime = serde_json::from_str::<Value>(raw_json).is_ok_and(|message| {
message.get("method").and_then(Value::as_str) == Some("Runtime.enable")
});
let runtime_was_disabled = enabling_runtime && !runtime_enabled(session)?;
session.dispatch_protocol_message(v8::inspector::StringView::from(raw_json.as_bytes()));

// V8 owns enable/restore state. Repeated or failed enables must not reset
// explicit frontend limits. The setter runs synchronously without JS or
// microtasks, so a scoped response capture can safely reuse a fixed ID.
if runtime_was_disabled && runtime_enabled(session)? {
let request = json!({
"id": STACK_CAPTURE_CALL_ID,
"method": "Runtime.setMaxCallStackSizeToCapture",
"params": {"size": DEFAULT_RUNTIME_STACK_CAPTURE_DEPTH}
})
.to_string();
let response = output
.capture_internal_response(STACK_CAPTURE_CALL_ID, || {
session
.dispatch_protocol_message(v8::inspector::StringView::from(request.as_bytes()));
})
.ok_or("Runtime stack-capture default produced no Inspector response")?;
if response != json!({"id": STACK_CAPTURE_CALL_ID, "result": {}}) {
return Err(format!(
"Runtime stack-capture default returned an unexpected response: {response}"
));
}
}
Ok(())
}

fn runtime_enabled(session: &v8::inspector::V8InspectorSession) -> Result<bool, String> {
// V8 owns this serialized state and uses these same fields on reconnect.
// Decode it only for Runtime.enable, never on the Runtime.evaluate hot path.
#[derive(Deserialize)]
struct SessionState {
#[serde(rename = "Runtime")]
runtime: RuntimeState,
}
#[derive(Deserialize)]
struct RuntimeState {
#[serde(default, rename = "runtimeEnabled")]
enabled: bool,
}

let state = v8::crdtp::cbor_to_json(&session.state())
.ok_or("Inspector session state is not valid CBOR")?;
let state: SessionState = serde_json::from_slice(&state)
.map_err(|error| format!("invalid Inspector Runtime state: {error}"))?;
Ok(state.runtime.enabled)
}
103 changes: 103 additions & 0 deletions moli-renderer-v8/src/inspector_session/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use serde_json::{Value, json};

fn stack_probe() -> String {
json!({
"id": 100,
"method": "Runtime.evaluate",
"params": {
"expression": "(function recurse(n) { if (n) return recurse(n - 1); throw new Error('stack depth'); })(80)"
}
})
.to_string()
}

fn assert_frontend_response(messages: &[Value], request: &Value) {
let responses = messages
.iter()
.filter(|message| message.get("id").is_some())
.collect::<Vec<_>>();
assert_eq!(
responses,
vec![&json!({"id": request["id"], "result": {}})],
"only the frontend response may escape, including when its ID is negative: {request}"
);
}

fn assert_stack_depth(messages: &[Value], expected: usize, request: &Value) {
let response = messages
.iter()
.find(|message| message["id"] == json!(100))
.expect("stack probe response");
let exception = &response["result"]["exceptionDetails"];
assert!(exception.is_object(), "probe must throw: {response}");
let depth = exception["stackTrace"]["callFrames"]
.as_array()
.map_or(0, Vec::len);
assert_eq!(
depth, expected,
"Inspector exception stack depth after {request}: {response}"
);
}

pub(crate) fn assert_session_lifecycle(mut dispatch: impl FnMut(&str) -> Vec<Value>) {
for request in [
json!({"id": 9, "method": "Runtime.enable", "params": "invalid"}),
json!({"id": 9, "method": "Runtime.setMaxCallStackSizeToCapture", "params": {"size": 50}}),
] {
let messages = dispatch(&request.to_string());
assert!(
messages
.iter()
.any(|message| message["id"] == json!(9) && message.get("error").is_some()),
"failed requests must retain their protocol error and must not enable Runtime: {messages:?}"
);
}
let probe = stack_probe();
for (request, expected_depth) in [
(json!({"id": -1, "method": "Runtime.enable"}), Some(10)),
(
json!({"id": 2, "method": "Runtime.setMaxCallStackSizeToCapture", "params": {"size": 50}}),
Some(50),
),
(json!({"id": -1, "method": "Runtime.enable"}), Some(50)),
(
json!({"id": 3, "method": "Runtime.setMaxCallStackSizeToCapture", "params": {"size": 0}}),
Some(0),
),
(json!({"id": -1, "method": "Runtime.enable"}), Some(0)),
(json!({"id": 4, "method": "Runtime.disable"}), None),
(json!({"id": -1, "method": "Runtime.enable"}), Some(10)),
] {
assert_frontend_response(&dispatch(&request.to_string()), &request);
if let Some(expected_depth) = expected_depth {
assert_stack_depth(&dispatch(&probe), expected_depth, &request);
}
}
}

pub(crate) fn assert_multiple_sessions(mut dispatch: impl FnMut(&str, &str) -> Vec<Value>) {
let probe = stack_probe();
for (session, request, expected_depth) in [
("A", json!({"id": -1, "method": "Runtime.enable"}), Some(10)),
(
"A",
json!({"id": 2, "method": "Runtime.setMaxCallStackSizeToCapture", "params": {"size": 50}}),
Some(50),
),
("B", json!({"id": -1, "method": "Runtime.enable"}), Some(50)),
("A", json!({"id": -1, "method": "Runtime.enable"}), Some(50)),
("A", json!({"id": 3, "method": "Runtime.disable"}), Some(10)),
(
"B",
json!({"id": 4, "method": "Runtime.setMaxCallStackSizeToCapture", "params": {"size": 0}}),
Some(0),
),
("B", json!({"id": -1, "method": "Runtime.enable"}), Some(0)),
("B", json!({"id": 5, "method": "Runtime.disable"}), None),
] {
assert_frontend_response(&dispatch(session, &request.to_string()), &request);
if let Some(expected_depth) = expected_depth {
assert_stack_depth(&dispatch(session, &probe), expected_depth, &request);
}
}
}
1 change: 1 addition & 0 deletions moli-renderer-v8/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ mod frame_owner_model;
mod host;
mod host_bindings;
mod inspector_microtasks;
mod inspector_session;
mod javascript_url;
mod layout_renderer;
mod link_as;
Expand Down
Loading
Loading