diff --git a/moli-renderer-v8/src/network_host/headers/methods/iteration.rs b/moli-renderer-v8/src/network_host/headers/methods/iteration.rs index 15d695dfa5..80708ecbe3 100644 --- a/moli-renderer-v8/src/network_host/headers/methods/iteration.rs +++ b/moli-renderer-v8/src/network_host/headers/methods/iteration.rs @@ -126,11 +126,18 @@ pub(in crate::network_host::headers) fn headers_for_each_callback<'s>( .this_arg .unwrap_or_else(|| v8::undefined(scope).into()); let callback = parsed.callback.prepare(scope); - for (name, value) in headers_entries(scope, this) { - let Some(name) = v8_string(scope, &name) else { + let mut index = 0; + loop { + // Script can change both the values and the sorted pair order. + let entries = headers_entries(scope, this); + let Some((name, value)) = entries.get(index) else { + break; + }; + index += 1; + let Some(name) = v8_string(scope, name) else { continue; }; - let Some(value) = v8_string(scope, &value) else { + let Some(value) = v8_string(scope, value) else { continue; }; if invoke_synchronous_webidl_callback_function( diff --git a/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs b/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs index f5e8b6473d..f7c0d0746c 100644 --- a/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs +++ b/moli-renderer-v8/src/script_vm/tests/webidl_fetch.rs @@ -7643,6 +7643,61 @@ fn headers_for_each_uses_webidl_callback_function_semantics() { ); } +#[tokio::test(flavor = "current_thread")] +async fn headers_for_each_visits_live_entries_in_window_and_worker() { + let fixture = include_str!("../../../tests/fixtures/headers-foreach.js"); + for worker in [false, true] { + let loader = static_http_loader([]); + let mut vm = + new_page_task_executor_test_vm_with_loader("https://headers-foreach.test/", &loader); + vm.eval("globalThis.headersForEachResult = null;").unwrap(); + let script = if worker { + let source = format!( + "{fixture}\nheadersForEachProbe().then(postMessage, error => postMessage({{error: String(error.stack || error)}}));" + ); + format!( + r#" + const workerUrl = URL.createObjectURL(new Blob([{}], {{type: 'text/javascript'}})); + const worker = new Worker(workerUrl); + const finish = value => {{ + headersForEachResult = value; + worker.terminate(); + URL.revokeObjectURL(workerUrl); + }}; + worker.onmessage = event => finish(event.data); + worker.onerror = event => {{ finish({{error: event.message}}); event.preventDefault(); }}; + "#, + serde_json::to_string(&source).unwrap() + ) + } else { + format!( + "{fixture}\nheadersForEachProbe().then(value => {{ headersForEachResult = value; }}, error => {{ headersForEachResult = {{error: String(error.stack || error)}}; }});" + ) + }; + vm.eval(&script).unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(headersForEachResult !== null)", + "true", + "Headers.forEach checks should finish", + ) + .await; + let result: serde_json::Value = + serde_json::from_str(&vm.eval("JSON.stringify(headersForEachResult)").unwrap()) + .unwrap(); + let checks = result["checks"] + .as_array() + .unwrap_or_else(|| panic!("worker={worker}: {result}")); + let failures: Vec<_> = checks + .iter() + .filter(|check| check["pass"] != true) + .collect(); + assert_eq!(result["state"], "pass", "worker={worker}: {failures:?}"); + assert_eq!(checks.len(), 36, "worker={worker}"); + } +} + #[tokio::test] async fn headers_for_each_uses_callback_relevant_realm() { let mut vm = new_storage_test_vm("https://headers-callback-realm.test/"); @@ -7688,7 +7743,8 @@ async fn headers_for_each_uses_callback_relevant_realm() { name, value, owner === globalThis.__headersOwner - ].join(':'))` + ].join(':')); + if (name === 'x-realm') owner.append('x-tail', 'tail');` ); headers.forEach(callback, { receiverMarker: 'parent-this' }); return JSON.stringify({ @@ -7702,7 +7758,7 @@ async fn headers_for_each_uses_callback_relevant_realm() { assert_eq!( result, - r#"{"callbackRealm":true,"seen":["child:parent-this:x-realm:ok:true"]}"# + r#"{"callbackRealm":true,"seen":["child:parent-this:x-realm:ok:true","child:parent-this:x-tail:tail:true"]}"# ); } diff --git a/moli-renderer-v8/tests/fixtures/headers-foreach.js b/moli-renderer-v8/tests/fixtures/headers-foreach.js new file mode 100644 index 0000000000..9cba8cccb9 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/headers-foreach.js @@ -0,0 +1,146 @@ +async function headersForEachProbe() { + const checks = []; + const check = (label, actual, wanted) => { + checks.push({label, actual, wanted, pass: JSON.stringify(actual) === JSON.stringify(wanted)}); + }; + const initial = [['a', '1'], ['b', '2'], ['c', '3']]; + const cases = [ + ['delete-next', initial, (headers, key) => { + if (key === 'a') headers.delete('b'); + }, [['a', '1'], ['c', '3']]], + ['delete-current', initial, (headers, key) => { + if (key === 'a') headers.delete('a'); + }, [['a', '1'], ['c', '3']]], + ['delete-previous', [...initial, ['d', '4']], (headers, key) => { + if (key === 'b') headers.delete('a'); + }, [['a', '1'], ['b', '2'], ['d', '4']]], + ['clear', initial, headers => { + for (const key of ['a', 'b', 'c']) headers.delete(key); + }, [['a', '1']]], + ['update-next', initial, (headers, key) => { + if (key === 'a') headers.set('b', '20'); + }, [['a', '1'], ['b', '20'], ['c', '3']]], + ['append-next', initial, (headers, key) => { + if (key === 'a') headers.append('d', '4'); + }, [...initial, ['d', '4']]], + ['append-existing', initial, (headers, key) => { + if (key === 'a') headers.append('b', 'tail'); + }, [['a', '1'], ['b', '2, tail'], ['c', '3']]], + ['insert-before', [['b', '2'], ['c', '3']], (headers, key) => { + if (key === 'b' && !headers.has('a')) headers.append('a', '1'); + }, [['b', '2'], ['b', '2'], ['c', '3']]], + ['reinsert-next', initial, (headers, key) => { + if (key === 'a') { headers.delete('b'); headers.append('b', 'new'); } + }, [['a', '1'], ['b', 'new'], ['c', '3']]], + ['replace-list', initial, (headers, key) => { + if (key === 'a') { + for (const name of ['a', 'b', 'c']) headers.delete(name); + headers.append('d', '4'); + headers.append('e', '5'); + } + }, [['a', '1'], ['e', '5']]], + ['empty-fields', [['a', '1'], ['b', '2'], ['b', ''], ['c', '3']], (headers, key) => { + if (key === 'a') headers.append('b', ''); + }, [['a', '1'], ['b', '2, , '], ['c', '3']]], + ['append-cookie', [['Set-Cookie', 'a=1'], ['set-cookie', 'b=2'], ['x-tail', 'end']], (headers, key, value) => { + if (key === 'set-cookie' && value === 'a=1') headers.append('Set-Cookie', 'c=3'); + }, [['set-cookie', 'a=1'], ['set-cookie', 'b=2'], ['set-cookie', 'c=3'], ['x-tail', 'end']]], + ['delete-cookies', [['Set-Cookie', 'a=1'], ['set-cookie', 'b=2'], ['x-tail', 'end']], (headers, key) => { + if (key === 'set-cookie') headers.delete(key); + }, [['set-cookie', 'a=1']]], + ]; + for (const [label, pairs, mutate, wanted] of cases) { + const headers = new Headers(pairs); + const receiver = {}; + const seen = []; + let argumentsMatch = true; + const result = headers.forEach(function(value, key, owner) { + 'use strict'; + argumentsMatch &&= this === receiver && owner === headers && arguments.length === 3; + seen.push([key, value]); + if (seen.length > 20) throw new Error(label + ' did not finish'); + mutate(owner, key, value); + }, receiver); + check(label, seen, wanted); + check(label + '/callback-contract', [argumentsMatch, result === undefined], [true, true]); + } + + // Reentrant calls have independent positions and observe the same live list. + { + const headers = new Headers(initial); + const outer = []; + const inner = []; + headers.forEach((value, key) => { + outer.push([key, value]); + if (key === 'a') headers.forEach((innerValue, innerKey) => { + inner.push([innerKey, innerValue]); + if (innerKey === 'a') { headers.delete('b'); headers.append('d', '4'); } + }); + }); + check('reentrant/inner', inner, [['a', '1'], ['c', '3'], ['d', '4']]); + check('reentrant/outer', outer, [['a', '1'], ['c', '3'], ['d', '4']]); + } + + // forEach reads internal pairs, independently of author-provided iterators. + { + const headers = new Headers([['a', '1'], ['b', '2']]); + let getterCalls = 0; + for (const key of ['entries', 'keys', 'values', Symbol.iterator]) { + Object.defineProperty(headers, key, {get() { getterCalls++; throw new Error('public iterator'); }}); + } + const seen = []; + headers.forEach((value, key) => { + seen.push([key, value]); + if (key === 'a') { headers.delete('b'); headers.append('c', '3'); } + }); + check('internal-pairs', seen, [['a', '1'], ['c', '3']]); + check('public-iterators-not-read', getterCalls, 0); + } + + // Calling an author Proxy and propagating abrupt completion must still work. + { + const headers = new Headers(initial); + const marker = {}; + const receiver = {}; + const seen = []; + let applyCalls = 0; + let callGetterCalls = 0; + let shapeMatches = true; + const target = function(value, key, owner) { + 'use strict'; + shapeMatches &&= this === receiver && owner === headers && arguments.length === 3; + seen.push([key, value]); + if (key === 'a') headers.set('b', 'new'); + else { headers.append('d', '4'); throw marker; } + }; + Object.defineProperty(target, 'call', {get() { callGetterCalls++; throw new Error('callback.call'); }}); + const callback = new Proxy(target, {apply(fn, receiver, args) { + applyCalls++; + return Reflect.apply(fn, receiver, args); + }}); + let caught; + try { headers.forEach(callback, receiver); } catch (error) { caught = error; } + check('abrupt/seen', seen, [['a', '1'], ['b', 'new']]); + check('abrupt/callback-contract', [caught === marker, shapeMatches, applyCalls, callGetterCalls], [true, true, 2, 0]); + check('abrupt/mutation-retained', headers.get('d'), '4'); + } + + { + const headers = new Headers(initial); + const callback = Proxy.revocable(() => { headers.set('b', 'new'); callback.revoke(); }, {}); + let caught; + try { headers.forEach(callback.proxy); } catch (error) { caught = error; } + check('revoked-callback', [caught instanceof TypeError, headers.get('b')], [true, 'new']); + } + + { + const headers = new Headers(); + let called = false; + const result = headers.forEach(() => { called = true; }); + check('empty-list', [called, result === undefined], [false, true]); + let caught; + try { headers.forEach({}); } catch (error) { caught = error; } + check('empty-list-validates-callback', caught instanceof TypeError, true); + } + return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks}; +}