diff --git a/moli-benchmark/moli_benchmark/wpt_cross/case_set.py b/moli-benchmark/moli_benchmark/wpt_cross/case_set.py
index ff687c79a3..a9bbcd6b6d 100644
--- a/moli-benchmark/moli_benchmark/wpt_cross/case_set.py
+++ b/moli-benchmark/moli_benchmark/wpt_cross/case_set.py
@@ -723,6 +723,7 @@ def _xhr_handler_reference_patterns(directory: str) -> tuple[re.Pattern[str], ..
"echo-content-type.py", "corsenabled.py", "win-1252-xml.py", "win-1252-html.py",
"invalid-utf8-html.py", "shift-jis-html.py", "img-utf8-html.py",
"empty-div-utf8-html.py", "status.py", "last-modified.py",
+ "bad-chunk-encoding.py", "infinite-redirects.py",
):
resource = f"xhr/resources/{name}"
relative = posixpath.relpath(resource, directory)
diff --git a/moli-benchmark/moli_benchmark/wpt_cross/server.py b/moli-benchmark/moli_benchmark/wpt_cross/server.py
index a071ba4a1f..80b0a200fa 100644
--- a/moli-benchmark/moli_benchmark/wpt_cross/server.py
+++ b/moli-benchmark/moli_benchmark/wpt_cross/server.py
@@ -89,6 +89,8 @@ XHR_RESOURCE_PATHS = {
"/xhr/resources/echo-content-type.py",
"/xhr/resources/status.py",
"/xhr/resources/last-modified.py",
+ "/xhr/resources/bad-chunk-encoding.py",
+ "/xhr/resources/infinite-redirects.py",
*XHR_DOCUMENT_FIXTURES,
}
FETCH_ABORT_RESOURCE_PATHS = {
@@ -2676,21 +2678,33 @@ def _make_handler(
path = unquote(parsed.path)
if path not in XHR_RESOURCE_PATHS:
return False
+ if path == "/xhr/resources/bad-chunk-encoding.py":
+ self._serve_xhr_bad_chunk_encoding()
+ return True
upload_consumed = False
+ cache_control = "no-store"
try:
if path == "/xhr/resources/requri.py":
params = parse_qs(parsed.query, keep_blank_values=True)
- uri = self.path
- if "full" in params and not uri.startswith("http://"):
- authority = self.headers.get("Host")
- if authority is None:
- authority = _url_host_literal(
- str(self.server.server_address[0])
- )
- if urlsplit("//" + authority).port is None:
- authority += ":" + str(self.server.server_address[1])
- uri = f"http://{authority}{uri}"
+ uri = self._xhr_request_url() if "full" in params else self.path
status, reason, headers, body = 200, None, [], uri.encode("utf-8")
+ elif path == "/xhr/resources/infinite-redirects.py":
+ params = parse_qs(parsed.query, keep_blank_values=True, encoding="latin-1")
+ page = "default" if params.get("page", [None])[0] == "alternate" else "alternate"
+ redirect_type = 301 if params.get("type", [None])[0] == "301" else 302
+ mix = int(params.get("mix", [None])[0] == "1")
+ if mix:
+ redirect_type = 302 if redirect_type == 301 else 301
+ request_url = urlsplit(self._xhr_request_url())
+ location = urlunsplit((
+ request_url.scheme, request_url.netloc, request_url.path,
+ f"page={page}&type={redirect_type}&mix={mix}", "",
+ ))
+ # Upstream returns 301 regardless of the next URL's `type`.
+ status, reason = 301, None
+ headers = [("Pragma", "no-cache"), ("Location", location)]
+ cache_control = "no-cache"
+ body = ("Hello guest. You have been redirected to " + location).encode("utf-8")
elif path == "/xhr/resources/inspect-headers.py":
status, reason = 200, None
headers, body = _xhr_inspect_headers_fixture_response(
@@ -2797,9 +2811,48 @@ def _make_handler(
extra_headers=headers,
status_code=status,
status_text=reason,
+ cache_control=cache_control,
)
return True
+ def _xhr_request_url(self) -> str:
+ if self.path.startswith("http://"):
+ return self.path
+ authority = self.headers.get("Host")
+ if authority is None:
+ authority = _url_host_literal(str(self.server.server_address[0]))
+ if urlsplit("//" + authority).port is None:
+ authority += ":" + str(self.server.server_address[1])
+ return f"http://{authority}{self.path}"
+
+ def _serve_xhr_bad_chunk_encoding(self) -> None:
+ # The upstream explicit writer sends these bytes even for HEAD.
+ # Use raw framing so clients receive data before a decoding error.
+ self.close_connection = True
+ self.protocol_version = self.request_version
+ try:
+ if stopping.wait(0.1):
+ return
+ self.send_response(200)
+ self.send_header("Transfer-Encoding", "chunked")
+ self.send_header("Content-Type", "text/plain")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("Connection", "close")
+ self.end_headers()
+ self.wfile.flush()
+ if stopping.wait(0.1):
+ return
+ for _ in range(5):
+ self.wfile.write(b"a\r\nTEST_CHUNK\r\n")
+ self.wfile.flush()
+ if stopping.wait(0.1):
+ return
+ self.wfile.write(b"garbage")
+ self.wfile.flush()
+ except OSError:
+ # Clients may abort once they receive a partial response.
+ return
+
def _serve_xhr_delay(self, query: str, *, emit_body: bool) -> None:
delay_seconds = _wpt_delay_seconds(query)
if delay_seconds is None:
@@ -3003,6 +3056,7 @@ def _make_handler(
extra_headers: list[tuple[str, str]] | None = None,
status_code: int = 200,
status_text: str | None = None,
+ cache_control: str = "no-store",
) -> None:
if content_type is None:
header_block = list(extra_headers or [])
@@ -3017,7 +3071,7 @@ def _make_handler(
self.send_header(name, value)
if not _headers_include(header_block, "Content-Length"):
self.send_header("Content-Length", str(len(body)))
- self.send_header("Cache-Control", "no-store")
+ self.send_header("Cache-Control", cache_control)
self.end_headers()
if emit_body:
declared_length = next(
diff --git a/moli-benchmark/tests/test_wpt_xhr_network_error_server.py b/moli-benchmark/tests/test_wpt_xhr_network_error_server.py
new file mode 100644
index 0000000000..f90b2ddaaa
--- /dev/null
+++ b/moli-benchmark/tests/test_wpt_xhr_network_error_server.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import socket
+import tempfile
+import unittest
+from contextlib import ExitStack
+from http.client import HTTPConnection, IncompleteRead
+from pathlib import Path
+from unittest.mock import patch
+from urllib.parse import urlsplit
+
+from moli_benchmark.wpt_cross.case_set import enumerate_cases
+from moli_benchmark.wpt_cross.server import WptFixtureServer
+
+
+RESOURCE_DIR = "/xhr/resources/"
+
+
+class XhrNetworkErrorFixtureTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.stack = ExitStack()
+ self.addCleanup(self.stack.close)
+ self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
+ (self.root / "resources").mkdir()
+ (self.root / "resources/testharness.js").write_text("// testharness")
+ (self.root / "xhr/resources").mkdir(parents=True)
+ for name in ("bad-chunk-encoding.py", "infinite-redirects.py"):
+ (self.root / "xhr/resources" / name).write_text("# must not serve Python source")
+ self.stack.enter_context(patch(
+ "moli_benchmark.wpt_cross.server._global_ipv6_address", return_value=None
+ ))
+
+ def server(self) -> WptFixtureServer:
+ return self.stack.enter_context(WptFixtureServer(self.root))
+
+ def test_bad_chunk_response_preserves_wire_framing_and_explicit_head_writes(self) -> None:
+ server = self.server()
+ for method in ("GET", "HEAD", "CHICKEN"):
+ with self.subTest(method=method), socket.create_connection(
+ ("127.0.0.1", server.port), timeout=3
+ ) as connection:
+ connection.sendall((
+ f"{method} {RESOURCE_DIR}bad-chunk-encoding.py HTTP/1.1\r\n"
+ f"Host: localhost:{server.port}\r\n\r\n"
+ ).encode())
+ with connection.makefile("rb") as response:
+ headers, body = response.read().split(b"\r\n\r\n", 1)
+ self.assertTrue(headers.startswith(b"HTTP/1.1 200 "))
+ fields = headers.lower().split(b"\r\n")[1:]
+ for field in (
+ b"transfer-encoding: chunked", b"content-type: text/plain",
+ b"x-content-type-options: nosniff", b"connection: close",
+ ):
+ self.assertIn(field, fields)
+ self.assertFalse(any(field.startswith(b"content-length:") for field in fields))
+ self.assertEqual(body, b"a\r\nTEST_CHUNK\r\n" * 5 + b"garbage")
+
+ def test_http_client_can_read_partial_body_before_chunk_decoding_fails(self) -> None:
+ server = self.server()
+ connection = HTTPConnection("127.0.0.1", server.port, timeout=3)
+ self.addCleanup(connection.close)
+ # This upstream handler never reads the upload, even when it is unfinished.
+ connection.putrequest("POST", RESOURCE_DIR + "bad-chunk-encoding.py")
+ connection.putheader("Content-Length", "1000000")
+ connection.endheaders()
+ response = connection.getresponse()
+ self.assertEqual(response.status, 200)
+ self.assertTrue(response.chunked)
+ self.assertEqual(response.read(10), b"TEST_CHUNK")
+ with self.assertRaises(IncompleteRead) as failure:
+ response.read()
+ self.assertEqual(failure.exception.partial, b"TEST_CHUNK" * 4)
+
+ def redirect(
+ self, port: int, target: str, *, method: str = "GET", host: str | None = None,
+ ) -> tuple[int, dict[str, str], bytes]:
+ connection = HTTPConnection("127.0.0.1", port, timeout=3)
+ try:
+ connection.request(method, target, headers={"Host": host} if host else {})
+ response = connection.getresponse()
+ return response.status, dict(response.getheaders()), response.read()
+ finally:
+ connection.close()
+
+ def test_infinite_redirect_query_parameters_and_status_match_upstream(self) -> None:
+ server = self.server()
+ base = f"http://localhost:{server.port}" + RESOURCE_DIR + "infinite-redirects.py"
+ cases = [
+ ("", "alternate", 302, 0),
+ ("page=alternate", "default", 302, 0),
+ ("page=alternate&page=default&type=301&type=302&mix=1&mix=0", "default", 302, 1),
+ ("page=default&page=alternate&type=301&mix=0", "alternate", 301, 0),
+ ("type=other&mix=1", "alternate", 301, 1),
+ ("page=&type=&mix=", "alternate", 302, 0),
+ ]
+ for query, page, redirect_type, mix in cases:
+ with self.subTest(query=query):
+ status, headers, body = self.redirect(server.port, base + "?" + query)
+ expected = f"{base}?page={page}&type={redirect_type}&mix={mix}"
+ # Upstream always returns 301; `type` only controls the next URL.
+ self.assertEqual(status, 301)
+ self.assertEqual(headers["Location"], expected)
+ self.assertIn("no-cache", headers["Cache-Control"])
+ self.assertEqual(headers["Pragma"], "no-cache")
+ self.assertEqual(body, ("Hello guest. You have been redirected to " + expected).encode())
+
+ def test_infinite_redirects_keep_looping_on_the_requested_origin(self) -> None:
+ server = self.server()
+ for port, host in ((server.port, "www1.localhost"),
+ (server.alternate_port, f"www2.localhost:{server.alternate_port}")):
+ for method in ("GET", "HEAD", "OPTIONS", "CHICKEN"):
+ with self.subTest(port=port, host=host, method=method):
+ target = RESOURCE_DIR + "%69nfinite-redirects.py?mix=1"
+ authority = host if ":" in host else f"{host}:{port}"
+ for hop in range(6):
+ status, headers, body = self.redirect(port, target, method=method, host=host)
+ self.assertEqual(status, 301)
+ next_url = urlsplit(headers["Location"])
+ self.assertEqual((next_url.scheme, next_url.netloc), ("http", authority))
+ self.assertEqual(next_url.path, RESOURCE_DIR + "%69nfinite-redirects.py")
+ self.assertEqual(next_url.query, (
+ "page=alternate&type=301&mix=1" if hop % 2 == 0
+ else "page=default&type=302&mix=1"
+ ))
+ expected_body = ("Hello guest. You have been redirected to " + headers["Location"]).encode()
+ self.assertEqual(headers["Content-Length"], str(len(expected_body)))
+ self.assertEqual(body, b"" if method == "HEAD" else expected_body)
+ target = headers["Location"]
+
+ def test_case_selection_accepts_only_the_supported_error_fixture_paths(self) -> None:
+ sources = {
+ "xhr/error.any.js": "fetch('resources/bad-chunk-encoding.py');",
+ "xhr/loop.html": "fetch('/xhr/resources/infinite-redirects.py');",
+ "xhr/nested/both.html": "fetch('../resources/infinite-redirects.py'); fetch('../resources/bad-chunk-encoding.py');",
+ "xhr/unknown.html": "fetch('resources/infinite-redirects.py'); fetch('resources/unknown.py');",
+ "xhr/suffix.html": "fetch('resources/bad-chunk-encoding.py-extra');",
+ "xhr/prefix.html": "fetch('/wrong/xhr/resources/infinite-redirects.py');",
+ "xhr/wrong-parent.html": "fetch('../resources/bad-chunk-encoding.py');",
+ "xhr/fetch-handler.html": "fetch('/fetch/api/resources/bad-chunk-encoding.py');",
+ }
+ for path, source in sources.items():
+ target = self.root / path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if path.endswith(".html"):
+ source = '"
+ target.write_text(source)
+ self.assertEqual([case.case_path for case in enumerate_cases(
+ self.root, dir_prefixes=("xhr",), any_js_global="both",
+ )], [
+ "xhr/error.any.js?moli-wpt-any=dedicatedworker",
+ "xhr/error.any.js?moli-wpt-any=window",
+ "xhr/loop.html", "xhr/nested/both.html",
+ ])