Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/request_hold.py
T
Vito 5123654b0d fix(admin): start no list or stats read while the admin page leaves (#290)
On WebKit, a reload cancels the page's reads between beforeunload and
pagehide. After an in-place edit, finishEdit awaits loadStatistics, which
swallows its cancelled read, and then calls refreshList. That /recent read
starts while the page is leaving, and WebKit fails it with "Fetch API cannot
load ... due to access control checks." Playwright reports that console
error as a page error. This failed workflows.spec on WebKit in CI.

It is not an unhandled rejection: the page catches the failure. The read
should never start.

The admin page now has a lifetime (AbortController) that ends on
beforeunload, unless a running scan asks to stay, and on pagehide.
- ApiClient.latestOnly({lifetime}) starts no request once the lifetime has
  ended, aborts the one in flight, and resolves both to STALE.
- loadStatistics skips and silences a read that ended with the page.
- Real errors still propagate and show as before.
- A page restored from the back/forward cache, or one whose navigation never
  left, gets a new lifetime on pageshow or its next pointer or key input.

Tests:
- The test server can hold a real request at the server (request_hold.py),
  because a page.route hold is cancelled differently by WebKit.
- A WebKit regression test holds /prompt_manager/stats after an in-place
  edit and reloads. It failed 5/5 before the fix with the CI message and
  passes 5/5 after it.
- A test checks that a called-off leave reads the list again.
- api-client unit tests cover the lifetime.
2026-10-22 19:49:14 -07:00

107 lines
3.8 KiB
Python

"""Hold real requests at this test server, so a page can leave while one is in flight.
A Playwright ``page.route`` hold answers inside the browser's interception layer,
which WebKit cancels differently from a request the server has not answered yet.
These commands hold the request at the server itself:
- ``POST /__test__/hold`` with ``{"path": "/prompt_manager/..."}`` holds every
later request for that path until it is released. With ``"stage": "body"``
the server answers first and holds after sending the headers and the first
half of the body, so the browser has a response whose body is still loading.
- ``GET /__test__/hold`` answers ``{"held": {path: count}}``: how many requests
for each held path have arrived and are waiting.
- ``POST /__test__/release`` lets every held request go on, and stops holding.
"""
import asyncio
from aiohttp import web
HOLDABLE_PREFIX = "/prompt_manager/"
class RequestHolds:
def __init__(self) -> None:
self._gates: dict[str, asyncio.Event] = {}
self._waiting: dict[str, int] = {}
self._stages: dict[str, str] = {}
def hold(self, path: str, stage: str = "request") -> None:
self._gates.setdefault(path, asyncio.Event())
self._waiting.setdefault(path, 0)
self._stages[path] = stage
def stage(self, path: str) -> str | None:
return self._stages.get(path) if path in self._gates else None
def held(self) -> dict[str, int]:
return dict(self._waiting)
def release(self) -> None:
for gate in self._gates.values():
gate.set()
self._gates.clear()
self._waiting.clear()
self._stages.clear()
async def wait(self, path: str) -> None:
gate = self._gates.get(path)
if gate is None:
return
self._waiting[path] = self._waiting.get(path, 0) + 1
try:
await gate.wait()
finally:
if path in self._waiting:
self._waiting[path] -= 1
def register_request_holds(app: web.Application, routes) -> RequestHolds:
holds = RequestHolds()
@web.middleware
async def hold_middleware(request: web.Request, handler):
stage = holds.stage(request.path)
if stage == "request":
await holds.wait(request.path)
response = await handler(request)
if stage != "body" or not isinstance(response, web.Response):
return response
body = response.body if isinstance(response.body, bytes) else b""
streamed = web.StreamResponse(status=response.status, headers=response.headers)
streamed.content_length = len(body)
await streamed.prepare(request)
await streamed.write(body[: len(body) // 2])
await holds.wait(request.path)
await streamed.write(body[len(body) // 2 :])
await streamed.write_eof()
return streamed
app.middlewares.append(hold_middleware)
@routes.post("/__test__/hold")
async def hold(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
path = data.get("path") if isinstance(data, dict) else None
stage = data.get("stage", "request") if isinstance(data, dict) else None
if (
not isinstance(path, str)
or not path.startswith(HOLDABLE_PREFIX)
or set(data) - {"path", "stage"}
or stage not in ("request", "body")
):
raise web.HTTPBadRequest()
holds.hold(path, stage)
return web.json_response({"success": True})
@routes.get("/__test__/hold")
async def held(request: web.Request) -> web.Response:
return web.json_response({"success": True, "held": holds.held()})
@routes.post("/__test__/release")
async def release(request: web.Request) -> web.Response:
holds.release()
return web.json_response({"success": True})
return holds