fix(webidl): complete sequence conversion before validating pairs

Propagate WebIDL sequence conversion errors without IteratorClose. Convert all inner sequences before validating URLSearchParams pair lengths or normalizing Headers, preserving getter order and conversion exceptions.

Source: afe693c9a3, 4a420981c3
This commit is contained in:
ldm0
2026-09-16 17:02:20 +08:00
committed by Donough Liu
parent b47fab8bcd
commit 18bab6d67b
6 changed files with 304 additions and 280 deletions
@@ -1,32 +1,6 @@
use crate::webidl;
use moli_url::search_params::{SearchParamPair, parse_search_params};
struct UrlSearchParamsSequencePair(SearchParamPair);
impl<'s> webidl::WebIdlConverter<'s> for UrlSearchParamsSequencePair {
type Options = webidl::StringOptions;
fn convert(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
context: webidl::Context,
options: &Self::Options,
) -> Result<Self, webidl::WebIdlError> {
let pair = <webidl::Sequence<webidl::UsvString> as webidl::WebIdlConverter>::convert(
scope, value, context, options,
)?;
if pair.0.len() != 2 {
return Err(webidl::WebIdlError::custom_message(
"URLSearchParams sequence pairs must contain exactly two items",
));
}
let mut values = pair.0.into_iter();
let key = values.next().expect("validated sequence pair key").0;
let value = values.next().expect("validated sequence pair value").0;
Ok(Self((key, value)))
}
}
pub(super) fn url_search_params_pairs_from_constructor<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
@@ -35,7 +9,7 @@ pub(super) fn url_search_params_pairs_from_constructor<'s>(
return Some(Vec::new());
}
if let Ok(object) = v8::Local::<v8::Object>::try_from(value) {
let sequence = match webidl::convert_optional_sequence::<UrlSearchParamsSequencePair>(
let sequence = match webidl::convert_optional_sequence::<webidl::Sequence<webidl::UsvString>>(
scope,
value,
webidl::Context::argument("URLSearchParams", 1),
@@ -48,7 +22,21 @@ pub(super) fn url_search_params_pairs_from_constructor<'s>(
}
};
if let Some(sequence) = sequence {
return Some(sequence.0.into_iter().map(|pair| pair.0).collect());
// Pair lengths are checked after the complete WebIDL conversion.
let mut pairs = Vec::with_capacity(sequence.0.len());
for pair in sequence.0 {
let Ok([key, value]) = <[_; 2]>::try_from(pair.0) else {
webidl::throw_error(
scope,
&webidl::WebIdlError::custom_message(
"URLSearchParams sequence pairs must contain exactly two items",
),
);
return None;
};
pairs.push((key.0, value.0));
}
return Some(pairs);
}
return record_string_pairs(scope, object.into());
}
@@ -20,7 +20,28 @@ pub(in crate::network_host) fn headers_entries_from_init<'s>(
));
};
if let Some(entries) = headers_entries_from_iterable_init(scope, init_arg, init_obj)? {
// Convert every inner sequence before validating or normalizing any header.
if let Some(sequence) = webidl::convert_optional_sequence::<webidl::Sequence<webidl::ByteString>>(
scope,
init_arg,
webidl::Context::argument("Headers", 1),
&webidl::StringOptions::default(),
)? {
let mut entries = Vec::with_capacity(sequence.0.len());
for pair in sequence.0 {
let [key, value] = <[_; 2]>::try_from(pair.0).map_err(|_| {
webidl::WebIdlError::custom_message(
"Headers sequence initializer pairs must have length 2",
)
})?;
let entry = normalized_header_entry_or_throw(scope, key.into(), value.into())
.ok_or_else(|| {
webidl::WebIdlError::custom_message(
"Headers initializer contains an invalid header",
)
})?;
entries.push(entry);
}
return Ok(entries);
}
@@ -40,186 +61,3 @@ pub(in crate::network_host) fn headers_entries_from_init<'s>(
}
Ok(entries)
}
fn headers_entries_from_iterable_init<'s>(
scope: &mut v8::PinScope<'s, '_>,
init_arg: v8::Local<'s, v8::Value>,
init_obj: v8::Local<'s, v8::Object>,
) -> Result<Option<Vec<(String, String)>>, webidl::WebIdlError> {
let iterator_key = v8::Symbol::get_iterator(scope);
let Some(iterator_value) = webidl::symbol_property_result(
scope,
init_obj,
iterator_key,
webidl::Context::member("Headers", "@@iterator"),
)?
else {
return Ok(None);
};
if iterator_value.is_null_or_undefined() {
return Ok(None);
}
let Ok(iterator_method) = v8::Local::<v8::Function>::try_from(iterator_value) else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer @@iterator must be callable",
));
};
let Some(iterator_value) = call_function_result(
scope,
iterator_method,
init_arg,
&[],
webidl::Context::member("Headers", "@@iterator"),
)?
else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer did not return an iterator",
));
};
let Ok(iterator) = v8::Local::<v8::Object>::try_from(iterator_value) else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer did not return an iterator",
));
};
let Some(next_method) = webidl::property_result(
scope,
iterator,
"next",
webidl::Context::member("Headers", "next"),
)?
else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer iterator must have next()",
));
};
let Ok(next_method) = v8::Local::<v8::Function>::try_from(next_method) else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer iterator must have next()",
));
};
let mut entries = Vec::new();
loop {
let Some(step_value) = call_function_result(
scope,
next_method,
iterator.into(),
&[],
webidl::Context::member("Headers", "next"),
)?
else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer next() must return an object",
));
};
let Ok(step) = v8::Local::<v8::Object>::try_from(step_value) else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer next() must return an object",
));
};
let done = webidl::property_result(
scope,
step,
"done",
webidl::Context::member("Headers", "done"),
)?
.is_some_and(|value| value.boolean_value(scope));
if done {
break;
}
let Some(pair) = webidl::property_result(
scope,
step,
"value",
webidl::Context::member("Headers", "value"),
)?
else {
return Err(webidl::WebIdlError::custom_message(
"Headers iterable initializer could not read an entry",
));
};
entries.push(header_sequence_pair_from_value(scope, pair)?);
}
Ok(Some(entries))
}
fn header_sequence_pair_from_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
pair_val: v8::Local<'s, v8::Value>,
) -> Result<(String, String), webidl::WebIdlError> {
let Ok(pair) = v8::Local::<v8::Object>::try_from(pair_val) else {
return Err(webidl::WebIdlError::custom_message(
"Headers sequence initializer must contain pairs",
));
};
if webidl::property_result(
scope,
pair,
"length",
webidl::Context::member("Headers", "length"),
)?
.and_then(|value| value.uint32_value(scope))
!= Some(2)
{
return Err(webidl::WebIdlError::custom_message(
"Headers sequence initializer pairs must have length 2",
));
}
let key_value = get_index_result(scope, pair, 0)?;
let key = header_init_byte_string(scope, key_value, "name")?;
let value_value = get_index_result(scope, pair, 1)?;
let value = header_init_byte_string(scope, value_value, "value")?;
normalized_header_entry_or_throw(scope, key, value).ok_or_else(|| {
webidl::WebIdlError::custom_message("Headers initializer contains an invalid header")
})
}
fn header_init_byte_string<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: Option<v8::Local<'s, v8::Value>>,
member: &'static str,
) -> Result<String, webidl::WebIdlError> {
let value = value.ok_or_else(|| {
webidl::WebIdlError::custom_message("Headers initializer could not read a value")
})?;
webidl::convert::<webidl::ByteString>(scope, value, webidl::Context::member("Headers", member))
.map(Into::into)
}
fn get_index_result<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
index: u32,
) -> Result<Option<v8::Local<'s, v8::Value>>, webidl::WebIdlError> {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let mut scope = try_catch.init();
match object.get_index(&scope, index) {
Some(value) => Ok(Some(value)),
None if scope.has_caught() => {
let _ = scope.rethrow();
Err(webidl::WebIdlError::pending_exception(
webidl::Context::member("Headers", "sequence item"),
))
}
None => Ok(None),
}
}
fn call_function_result<'s>(
scope: &mut v8::PinScope<'s, '_>,
function: v8::Local<'s, v8::Function>,
receiver: v8::Local<'s, v8::Value>,
args: &[v8::Local<'s, v8::Value>],
context: webidl::Context,
) -> Result<Option<v8::Local<'s, v8::Value>>, webidl::WebIdlError> {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let mut scope = try_catch.init();
match function.call(&scope, receiver, args) {
Some(value) => Ok(Some(value)),
None if scope.has_caught() => {
let _ = scope.rethrow();
Err(webidl::WebIdlError::pending_exception(context))
}
None => Ok(None),
}
}
@@ -6108,7 +6108,7 @@ fn webidl_sequence_conversion_uses_iterator_without_mutable_array_from() {
assert_eq!(
result,
"arrayFrom:0:0.25,0.75|arrayIterator:0:throw:RangeError:array-iterator-used:1|order:iterator,next:0,done:0,value:0,toString:0,next:1,done:1,value:1,toString:1,next:2,done:2|nextThrow:throw:RangeError:next-boom|doneThrow:throw:TypeError:done-boom|valueThrow:throw:SyntaxError:value-boom|elementThrow:throw:URIError:string-boom:true"
"arrayFrom:0:0.25,0.75|arrayIterator:0:throw:RangeError:array-iterator-used:1|order:iterator,next:0,done:0,value:0,toString:0,next:1,done:1,value:1,toString:1,next:2,done:2|nextThrow:throw:RangeError:next-boom|doneThrow:throw:TypeError:done-boom|valueThrow:throw:SyntaxError:value-boom|elementThrow:throw:URIError:string-boom:false"
);
}
@@ -6152,8 +6152,13 @@ fn url_search_params_sequence_discrimination_reads_iterator_once() {
try {
new URLSearchParams({
[Symbol.iterator]() {
let done = false;
return {
next() { return { done: false, value: ['short'] }; },
next() {
if (done) return { done: true };
done = true;
return { done: false, value: ['short'] };
},
return() {
outerClosed = true;
throw new SyntaxError('close error');
@@ -6204,7 +6209,10 @@ fn url_search_params_sequence_discrimination_reads_iterator_once() {
)
.expect("URLSearchParams sequence discrimination probe should evaluate");
assert_eq!(result, "1|one|two|TypeError|TypeError:true|RangeError:true");
assert_eq!(
result,
"1|one|two|TypeError|TypeError:false|RangeError:false"
);
}
#[test]
@@ -9859,3 +9867,238 @@ fn fetch_body_json_removes_initial_utf8_bom_and_preserves_syntax_errors() {
fn fetch_body_binary_and_form_methods_preserve_bom_bytes() {
assert_body_utf8_bom_probe("bytes");
}
#[test]
fn webidl_sequences_propagate_abrupt_completion_without_closing_iterators() {
let mut vm = new_storage_test_vm("https://sequence-abrupt.test/");
let result = vm.eval(r#"
(() => {
const check = (condition, label) => { if (!condition) throw new Error(label); };
const consumers = [
['USVString', input => new URLSearchParams([input])],
['DOMString', input => new PerformanceObserver(() => {}).observe({entryTypes: input})]
];
if (typeof IntersectionObserver === 'function') consumers.push(
['double', input => new IntersectionObserver(() => {}, {threshold: input})]);
for (const [name, consume] of consumers) {
for (const stage of ['next', 'done', 'value', 'convert', 'symbol']) {
const marker = {};
const log = [];
const fail = () => { log.push(stage); throw marker; };
const input = {[Symbol.iterator]() {
let finished = false;
return {
next() {
if (finished) return {done: true};
finished = true;
if (stage === 'next') fail();
return {
get done() { if (stage === 'done') fail(); return false; },
get value() {
if (stage === 'value') fail();
return stage === 'symbol' ? Symbol() : {[Symbol.toPrimitive]: fail};
}
};
},
get return() { log.push('get:return'); throw new Error('return must not be read'); }
};
}};
let caught;
try { consume(input); } catch (error) { caught = error; }
check(stage === 'symbol' ? caught instanceof TypeError : caught === marker, name + ' ' + stage + ' exception');
const expected = stage === 'symbol' ? [] : [stage];
check(JSON.stringify(log) === JSON.stringify(expected), name + ' ' + stage + ': ' + JSON.stringify(log));
}
}
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn webidl_nested_and_interface_sequences_do_not_read_iterator_return_on_errors() {
let mut vm = new_storage_test_vm("https://nested-sequence-abrupt.test/");
let result = vm.eval(r#"
(() => {
const check = (condition, label) => { if (!condition) throw new Error(label); };
for (const stage of ['iterator', 'next', 'value', 'convert']) {
const marker = {};
let returnReads = 0;
const fail = () => { throw marker; };
const wrap = value => ({[Symbol.iterator]() {
let finished = false;
return {
next() {
if (finished) return {done: true};
finished = true;
return {done: false, value};
},
get return() { returnReads++; throw new Error('outer return'); }
};
}});
const pair = {get [Symbol.iterator]() {
if (stage === 'iterator') fail();
return function() {
let finished = false;
return {
next() {
if (finished) return {done: true};
finished = true;
if (stage === 'next') fail();
return {done: false, get value() {
if (stage === 'value') fail();
return {[Symbol.toPrimitive]: fail};
}};
},
get return() { returnReads++; throw new Error('inner return'); }
};
};
}};
let caught;
try { new URLSearchParams(wrap(pair)); } catch (error) { caught = error; }
check(caught === marker && returnReads === 0, stage + ' must propagate without closing either iterator');
}
for (const member of ['coalescedEvents', 'predictedEvents']) {
let returnReads = 0;
const events = {[Symbol.iterator]() {
let finished = false;
return {
next() {
if (finished) return {done: true};
finished = true;
return {done: false, value: new Event('invalid')};
},
get return() { returnReads++; throw new Error('interface sequence return'); }
};
}};
let caught;
try { new PointerEvent('pointermove', {[member]: events}); } catch (error) { caught = error; }
check(caught instanceof TypeError && returnReads === 0, member + ' must reject the interface without closing');
}
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn initializer_sequences_convert_all_entries_before_validating_pairs() {
let mut vm = new_storage_test_vm("https://initializer-sequence.test/");
let result = vm.eval(r#"
(() => {
const check = (condition, label) => { if (!condition) throw new Error(label); };
const consumers = [
['Headers', input => new Headers(input)],
['URLSearchParams', input => new URLSearchParams(input)],
['Request', input => new Request('https://initializer-sequence.test/', {headers: input})],
['Response', input => new Response(null, {headers: input})]
];
for (const [name, consume] of consumers) {
const invalidPairs = [['short'], ['key', 'value', 'extra']];
if (name !== 'URLSearchParams') invalidPairs.push(['', 'value'], ['key', 'bad\nvalue']);
for (const bad of invalidPairs) {
for (const stage of ['complete', 'next', 'done', 'value', 'convert']) {
const marker = {};
const log = [];
const fail = () => { log.push('fail:' + stage); throw marker; };
const input = {[Symbol.iterator]() {
let index = 0;
return {
next() {
log.push('next:' + index);
if (index++ === 0) return {done: false, value: bad};
if (index === 2) {
if (stage === 'next') fail();
return {
get done() { if (stage === 'done') fail(); return false; },
get value() {
if (stage === 'value') fail();
return ['later', {toString() {
if (stage === 'convert') fail();
log.push('convert:later');
return 'value';
}}];
}
};
}
return {done: true};
},
get return() { log.push('return'); throw new Error('return must not be read'); }
};
}};
let caught;
try { consume(input); } catch (error) { caught = error; }
const label = name + ' ' + JSON.stringify(bad) + ' ' + stage;
check(stage === 'complete' ? caught instanceof TypeError : caught === marker, label + ' exception');
const expected = stage === 'complete' ? ['next:0', 'next:1', 'convert:later', 'next:2'] :
['next:0', 'next:1', 'fail:' + stage];
check(JSON.stringify(log) === JSON.stringify(expected), label + ': ' + JSON.stringify(log));
}
}
}
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
#[test]
fn initializer_pairs_use_inner_iterators_and_convert_extra_elements() {
let mut vm = new_storage_test_vm("https://initializer-inner-sequence.test/");
let result = vm.eval(r#"
(() => {
const check = (condition, label) => { if (!condition) throw new Error(label); };
for (const Constructor of [Headers, URLSearchParams]) {
let iteratorReads = 0;
const pair = {
get length() { throw new Error('length must not be read'); },
get 0() { throw new Error('indexed properties must not be read'); },
get [Symbol.iterator]() {
iteratorReads++;
return function*() {
check(this === pair, 'inner iterator receiver');
yield 'X-Key';
yield ' one ';
};
}
};
const expected = Constructor === Headers ? [['x-key', 'one']] : [['X-Key', ' one ']];
const actual = Array.from(new Constructor([pair]));
check(JSON.stringify(actual) === JSON.stringify(expected) && iteratorReads === 1, Constructor.name + ' inner iteration');
for (const invalid of ['ab', {0: 'key', 1: 'value', length: 2}]) {
let caught;
try { new Constructor([invalid]); } catch (error) { caught = error; }
check(caught instanceof TypeError, Constructor.name + ' requires object iterables');
}
const marker = {};
let conversions = 0;
let caught;
try {
new Constructor([['key', 'value', {toString() { conversions++; throw marker; }}]]);
} catch (error) { caught = error; }
check(caught === marker && conversions === 1, Constructor.name + ' must convert the extra element');
const log = [];
const input = {[Symbol.iterator]() {
let index = 0;
return {next() {
log.push('next:' + index);
if (index++ > 0) return {done: true};
return {done: false, value: ['key', 'value', {toString() {
log.push('extra');
return '\u0100';
}}]};
}};
}};
caught = undefined;
try { new Constructor(input); } catch (error) { caught = error; }
check(caught instanceof TypeError, Constructor.name + ' rejects the invalid initializer');
const expectedLog = Constructor === Headers ? ['next:0', 'extra'] : ['next:0', 'extra', 'next:1'];
check(JSON.stringify(log) === JSON.stringify(expectedLog), Constructor.name + ': ' + JSON.stringify(log));
}
return 'ok';
})()
"#).unwrap();
assert_eq!(result, "ok");
}
@@ -239,11 +239,6 @@ const DIRECT_V8_CALL_ALLOWLIST: &[AllowedDirectCallFile] = &[
1,
DirectCallOwner::NativeForwardingOrScript,
),
allowed(
"network_host/headers/store/init.rs",
1,
DirectCallOwner::NativeForwardingOrScript,
),
allowed(
"script_vm/frame_script_jobs.rs",
1,
+2 -43
View File
@@ -352,28 +352,8 @@ where
sequence_iterator_from_method(scope, value, iterator_method, context)?;
let mut values = Vec::new();
while let Some(item) = sequence_iterator_next(scope, iterator, next_method, context)? {
let (converted, caught_exception) = {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let mut conversion_scope = try_catch.init();
let converted = T::convert(&mut conversion_scope, item, context, options);
let caught_exception = conversion_scope
.has_caught()
.then(|| conversion_scope.exception())
.flatten()
.map(|exception| v8::Global::new(&conversion_scope, exception));
(converted, caught_exception)
};
match converted {
Ok(value) => values.push(value),
Err(error) => {
sequence_iterator_close_ignoring_errors(scope, iterator);
if let Some(exception) = caught_exception {
let exception = v8::Local::new(scope, &exception);
scope.throw_exception(exception);
}
return Err(error);
}
}
// WebIDL sequence conversion propagates errors without IteratorClose.
values.push(T::convert(scope, item, context, options)?);
}
Ok(Some(Sequence(values)))
}
@@ -1157,27 +1137,6 @@ fn sequence_iterator_next<'s>(
Ok(Some(value))
}
fn sequence_iterator_close_ignoring_errors<'s>(
scope: &mut v8::PinScope<'s, '_>,
iterator: v8::Local<'s, v8::Object>,
) {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let scope = try_catch.init();
let Some(return_key) = v8::String::new(&scope, "return") else {
return;
};
let Some(return_method) = iterator.get(&scope, return_key.into()) else {
return;
};
if return_method.is_null_or_undefined() {
return;
}
let Ok(return_method) = v8::Local::<v8::Function>::try_from(return_method) else {
return;
};
let _ = return_method.call(&scope, iterator.into(), &[]);
}
fn call_sequence_function<'s>(
scope: &mut v8::PinScope<'s, '_>,
function: v8::Local<'s, v8::Function>,
@@ -431,9 +431,9 @@ test(function () {
},
});
}, "top-level iterator should propagate pair conversion errors");
assert_true(
assert_false(
closedAfterStringification,
"pair conversion errors should close the top-level iterator",
"pair conversion errors must not close the top-level iterator",
);
let closedAfterInvalidPair = false;
@@ -444,10 +444,8 @@ test(function () {
return {
next: function () {
count += 1;
return {
done: false,
value: count === 1 ? ["short"] : ["unreached", "value"],
};
if (count === 1) return { done: false, value: ["short"] };
return { done: true };
},
return: function () {
closedAfterInvalidPair = true;
@@ -457,11 +455,11 @@ test(function () {
},
});
}, "top-level iterator should propagate invalid pair errors");
assert_true(
assert_false(
closedAfterInvalidPair,
"invalid pair errors should close the top-level iterator",
"invalid pair errors must not close the top-level iterator",
);
}, "URLSearchParams constructor closes top-level iterators on pair conversion errors");
}, "URLSearchParams constructor does not close top-level iterators on pair errors");
test(function () {
let innerClosedAfterValueConversion = false;
@@ -497,11 +495,11 @@ test(function () {
assert_throws_name("RangeError", function () {
new URLSearchParams([pair]);
}, "inner pair iterator should propagate element conversion errors");
assert_true(
assert_false(
innerClosedAfterValueConversion,
"pair element conversion errors should close the inner pair iterator",
"pair element conversion errors must not close the inner pair iterator",
);
}, "URLSearchParams constructor closes inner pair iterators on element conversion errors");
}, "URLSearchParams constructor does not close inner pair iterators on conversion errors");
test(function () {
let topLevelReturnCalled = false;
@@ -527,7 +525,7 @@ test(function () {
},
});
}, "top-level iterator return errors should not replace the original conversion error");
assert_true(topLevelReturnCalled, "top-level iterator return should be called");
assert_false(topLevelReturnCalled, "top-level iterator return must not be called");
let innerReturnCalled = false;
const pair = {
@@ -558,16 +556,19 @@ test(function () {
assert_throws_name("RangeError", function () {
new URLSearchParams([pair]);
}, "inner pair iterator return errors should not replace the original conversion error");
assert_true(innerReturnCalled, "inner pair iterator return should be called");
}, "URLSearchParams constructor preserves original errors when iterator return throws");
assert_false(innerReturnCalled, "inner pair iterator return must not be called");
}, "URLSearchParams constructor does not call throwing return methods after conversion errors");
test(function () {
let returnCalled = false;
assert_throws_name("TypeError", function () {
new URLSearchParams({
[Symbol.iterator]: function () {
let done = false;
return {
next: function () {
if (done) return { done: true };
done = true;
return { done: false, value: ["short"] };
},
return: function () {
@@ -578,8 +579,8 @@ test(function () {
},
});
}, "top-level iterator return errors should not replace invalid pair length errors");
assert_true(returnCalled, "top-level iterator return should be called");
}, "URLSearchParams constructor preserves invalid pair length errors when iterator return throws");
assert_false(returnCalled, "top-level iterator return must not be called");
}, "URLSearchParams constructor does not call throwing return methods for invalid pairs");
test(function () {
let topLevelReturnCalled = false;