From 9369b5c80b6ebcda48b80fbc09aa8b0064e5a4e2 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 7 Nov 2026 15:44:27 +0100 Subject: [PATCH 1/2] Raise the Windows sharing violation in the load-race test's poll The load-race test holds the page's navigation in a route handler and polls the session page until the watch has rewritten it. On Windows an `open` landing while `atomic_write_text` swaps the page in raises PermissionError. The poll does not catch it, so the handler raises, the route is never fulfilled, `Page.goto` times out after 30 s, and `Page.close` re-raises the handler's error at teardown. A Windows CI cell failed exactly that way. The server and the writer already retry this transient; the test's own read does not. The test now has its first read of the page raise PermissionError on every platform, from the test's thread only, and asserts the injection was reached. It fails until the poll outlasts the error. Co-Authored-By: Claude Opus 5.5 --- test/test_live_update.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/test_live_update.py b/test/test_live_update.py index 3482fdb2..83393d51 100644 --- a/test/test_live_update.py +++ b/test/test_live_update.py @@ -14,6 +14,7 @@ import time from contextlib import contextmanager from pathlib import Path +from typing import Any import pytest @@ -150,6 +151,31 @@ def conversation_archive(request: pytest.FixtureRequest, tmp_path: Path): yield url, project, jsonl +def _sharing_violation_once( + monkeypatch: pytest.MonkeyPatch, target: Path +) -> list[Path]: + """Make this thread's first read of ``target`` raise PermissionError. + + That is what Windows raises for an `open` landing while the watch + swaps the page in (`atomic_write_text`); POSIX never does, so a read + racing the watch only meets it on a Windows runner. Other threads — + the watch's conversion, the server — read as usual. Returns the + paths it raised for, so a test can show the injection was reached. + """ + read_text = Path.read_text + owner = threading.get_ident() + raised: list[Path] = [] + + def flaky(self: Path, *args: Any, **kwargs: Any) -> str: + if not raised and self == target and threading.get_ident() == owner: + raised.append(self) + raise PermissionError(13, "Permission denied", str(self)) + return read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", flaky) + return raised + + def _wait_for(page, expression: str, timeout: int = 30000) -> None: page.wait_for_function(expression, timeout=timeout) @@ -332,7 +358,7 @@ def test_the_fold_control_still_works_after_an_update( ) def test_an_update_during_page_load_is_not_absorbed( - self, page, live_archive + self, page, live_archive, monkeypatch: pytest.MonkeyPatch ) -> None: """The first poll cannot adopt whatever the server holds by then. @@ -346,6 +372,9 @@ def test_an_update_during_page_load_is_not_absorbed( url = f"{base}/{project.name}/session-{SESSION_ID}.html" page_file = project / f"session-{SESSION_ID}.html" served_stale: list[bool] = [] + # The poll below reads the page while the watch rewrites it, which + # on Windows can raise mid-swap: have it do so on every platform. + swapped = _sharing_violation_once(monkeypatch, page_file) def hold(route): # Only the navigation: the page's own HEAD and GET share this @@ -370,6 +399,7 @@ def hold(route): page.route(url, hold) self._open(page, base, project) assert served_stale == [True], "the browser was served the new page after all" + assert swapped == [page_file], "the poll never met the sharing violation" _wait_for(page, "() => document.body.innerText.includes('LOAD-RACE-MARKER')") From 4fe922906b1edbd0396d91a264244ff55d2d8488 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 7 Nov 2026 15:48:17 +0100 Subject: [PATCH 2/2] Let the load-race test's poll outlast a Windows sharing violation The poll now reads a PermissionError as "not rewritten yet" and keeps polling under its 20 s deadline, as the server and the writer already do for the same transient. The handler also releases the held navigation in a `finally`. A held route turned any error in the handler into a 30 s `Page.goto` timeout, with the real error surfacing only at teardown from `Page.close`. Now the error fails the test at once and names itself. Co-Authored-By: Claude Opus 5.5 --- test/test_live_update.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/test_live_update.py b/test/test_live_update.py index 83393d51..46f7d8e5 100644 --- a/test/test_live_update.py +++ b/test/test_live_update.py @@ -387,14 +387,25 @@ def hold(route): response = route.fetch() body = response.body() served_stale.append(b"LOAD-RACE-MARKER" not in body) - with jsonl.open("a", encoding="utf-8") as f: - f.write(_entry("load-race", "LOAD-RACE-MARKER")) - deadline = time.time() + 20 - while time.time() < deadline: - if "LOAD-RACE-MARKER" in page_file.read_text(encoding="utf-8"): - break - time.sleep(0.05) - route.fulfill(status=response.status, headers=response.headers, body=body) + try: + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("load-race", "LOAD-RACE-MARKER")) + deadline = time.time() + 20 + while time.time() < deadline: + try: + text = page_file.read_text(encoding="utf-8") + except PermissionError: + # Windows, mid-swap: not rewritten yet. + text = "" + if "LOAD-RACE-MARKER" in text: + break + time.sleep(0.05) + finally: + # Whatever happened above, release the navigation: a held + # route would turn any error here into a goto timeout. + route.fulfill( + status=response.status, headers=response.headers, body=body + ) page.route(url, hold) self._open(page, base, project)