From 539b91d30bd768b2a3153341df9cd46fea1262bb Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 24 Jul 2026 15:57:48 -0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20E7=20destroy-invalidation=20?= =?UTF-8?q?=E2=80=94=20first=20browser=20coverage=20of=20the=20factory=20t?= =?UTF-8?q?eardown=20(#900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other interactive scenario mounts the Pane class directly, so the factory closure that owns destroy() had no browser coverage at all — and that is exactly where #900's largest hole lived. E7 mounts through createInteractivePane (scenario-scoped: the factory owns its own connect/recover-beat lifecycle, so switching the others would change what they test), holds the first /history at the fault layer, destroys the controller mid-flight, and lets the load resolve into the void. Detector is a fault-layer non-occurrence: events_requests still 0, pane detached, _visHandler null behind it. Without the bump it stamps sse1-vis0 — the .finally reopens an EventSource on the detached pane and re-registers the document-level visibilitychange listener destroy just removed, which is the leak, now observed rather than traced. --- scripts/recovery_e2e.py | 133 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 5 deletions(-) diff --git a/scripts/recovery_e2e.py b/scripts/recovery_e2e.py index 7701ab5d..3f112322 100644 --- a/scripts/recovery_e2e.py +++ b/scripts/recovery_e2e.py @@ -25,6 +25,7 @@ Usage:: python3 scripts/recovery_e2e.py --scenario stale-backstop # E4 (#890) python3 scripts/recovery_e2e.py --scenario hidden-retry # E5 (#900) python3 scripts/recovery_e2e.py --scenario await-window-gate # E6 (#900) + python3 scripts/recovery_e2e.py --scenario destroy-invalidation # E7 (#900) python3 scripts/recovery_e2e.py --scenario coord-rewind-window # G1 (#894) python3 scripts/recovery_e2e.py --scenario coord-rewind-failed-window # G2 (#894) python3 scripts/recovery_e2e.py --scenario coord-stale-backstop # G3 (#894) @@ -424,7 +425,10 @@ PAGE_HTML = r""" window.showLogin = function () {}; @@ -2884,6 +2937,71 @@ def run_await_window_gate(chrome: str) -> str: node.stop() +def run_destroy_invalidation(chrome: str) -> str: + """Scenario E7 — destroy()'s load-token bump (#900). The ONLY interactive + scenario that mounts through ``createInteractivePane``: every other one + drives the ``Pane`` class directly, so the factory closure that owns + ``destroy()`` had no browser coverage at all — and destroy() is where the + #900 backport's largest hole lived. + + ``destroy()`` bumped no load token, so a ``/history`` still in flight at + teardown kept passing every post-await gate. The worst leg is + ``_loadHistoryThenConnect``'s ``.finally``: it reopened an ``EventSource`` + on the DETACHED pane and re-registered the document-level + ``visibilitychange`` listener ``_removeVisibilityHandler`` had just + dropped — and that stream's ``onerror`` re-armed the host recover beat + indefinitely, because it gives up only on ``dead``, which ``destroy()`` + never sets. A closed tab kept a node connection and a 5s reconnect beat + for the life of the page. + + The runner holds the FIRST ``/history`` open at the fault layer, destroys + the controller mid-flight, and lets the load resolve into the void. + DETECTOR: ``events_requests`` must still be 0 — the load resolved without + reconnecting — with the pane detached and ``_visHandler`` null behind it. + Remove the bump and the ``.finally`` fires: sse1, and a non-null handler + still bound to the document.""" + node, ws_id = _seed_three_completed_turns("browser-destroy-invalidation") + profile = Path(_scratch()) / "chrome-destroy-invalidation" + proc, cdp_port = _launch_chrome(chrome, profile) + cdp: CDP | None = None + try: + cdp = CDP(_page_ws_url(cdp_port)) + # Hold the FIRST /history — the one connect() dispatches — so the + # teardown lands with the load genuinely outstanding. + node.delay_history(4000) + url = f"{node.base_url}/recovery?ws_id={ws_id}&scenario=destroy-invalidation" + _set_cookie_and_navigate(cdp, node.base_url, node.token, url) + # history_requests counts on ARRIVAL, before the hold sleeps. + if not _poll_until(lambda: node.history_requests >= 1, 20, 0.05): + raise AssertionError("destroy-invalidation: the first /history never arrived") + # Non-vacuity: nothing may have connected yet, or the .finally under + # test has already run and the scenario proves nothing. + if node.events_requests != 0: + raise AssertionError( + f"destroy-invalidation: stream opened before teardown " + f"(events_requests={node.events_requests})" + ) + if not cdp.evaluate("!!window.__ctl"): + raise AssertionError("destroy-invalidation: page did not mount through the factory") + cdp.evaluate("window.__ctl.destroy()") + if not _poll_until(lambda: cdp.evaluate("!window.__pane.el.parentNode"), 5, 0.05): + raise AssertionError("destroy-invalidation: destroy() never detached the pane") + # Let the held fetch resolve and its .finally run. The wait must + # outlast the hold, or a 0 here would only mean "not yet". + node.delay_history(0) + _poll_until(lambda: node.events_requests != 0, 8, 0.1) + sse_opens = node.events_requests + vis_null = cdp.evaluate("window.__pane._visHandler === null") + print(f" destroy-invalidation sse_opens={sse_opens} vis_null={vis_null}") + cdp.evaluate(f"window.__verifyDestroyInvalidation({sse_opens})") + return _poll_title(cdp, 15) + finally: + if cdp is not None: + cdp.close() + _kill(proc) + node.stop() + + _COORD_ROWS_JS = "document.getElementById('coord-messages').querySelectorAll('.msg.user').length" @@ -3703,6 +3821,7 @@ def main() -> None: "stale-backstop", "hidden-retry", "await-window-gate", + "destroy-invalidation", "coord-rewind-window", "coord-rewind-failed-window", "coord-stale-backstop", @@ -3771,6 +3890,10 @@ def main() -> None: verdict = run_await_window_gate(chrome) print(f"scenario E6 (awaitgate): {verdict}") failures += 0 if verdict.startswith("RECOVERY-READY") else 1 + if args.scenario in ("destroy-invalidation", "all"): + verdict = run_destroy_invalidation(chrome) + print(f"scenario E7 (destroyinval): {verdict}") + failures += 0 if verdict.startswith("RECOVERY-READY") else 1 if args.scenario in ("coord-rewind-window", "all"): verdict = run_coord_rewind_window(chrome) print(f"scenario G1 (coord-rewindwin): {verdict}")