diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5469d..fdd1de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 8.0.4 - 2026-03-26 + +### Fixed + +- Loaders: enforce a hard wall-clock timeout on the Playwright render worker thread (2× browser timeout + 30 s); raises a retryable `LoaderRuntimeError` if Chromium becomes permanently unresponsive (stuck WebSocket, no CDP response). +- Loaders: render thread is now always a daemon thread so an abandoned timeout does not block process exit. + ## 8.0.3 - 2026-03-26 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 880e261..1bc280e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wordlift-sdk" -version = "8.0.3" +version = "8.0.4" description = "Python toolkit for orchestrating WordLift imports and structured data workflows." authors = ["David Riccitelli "] readme = "README.md" diff --git a/tests/ingestion/test_loaders.py b/tests/ingestion/test_loaders.py index 12edd77..b939b3c 100644 --- a/tests/ingestion/test_loaders.py +++ b/tests/ingestion/test_loaders.py @@ -2,6 +2,7 @@ import asyncio import threading +import time import urllib.error from io import BytesIO from types import SimpleNamespace @@ -207,6 +208,14 @@ async def _run() -> None: assert seen_thread_ids[0] != main_thread_id +def test_run_in_worker_thread_raises_on_hard_timeout() -> None: + with pytest.raises(LoaderRuntimeError) as exc: + loaders_module._run_in_worker_thread(lambda: time.sleep(10), timeout=0.05) + + assert exc.value.code == "INGEST_LOAD_BROWSER_TIMEOUT" + assert exc.value.retryable is True + + def test_playwright_loader_wraps_non_runtime_exceptions( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9431a63 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" diff --git a/wordlift_sdk/ingestion/loaders.py b/wordlift_sdk/ingestion/loaders.py index 7c83d6d..8878147 100644 --- a/wordlift_sdk/ingestion/loaders.py +++ b/wordlift_sdk/ingestion/loaders.py @@ -261,12 +261,19 @@ def _is_running_in_event_loop() -> bool: def _render_with_loop_safety( render_fn: Callable[[RenderOptions], Any], options: RenderOptions ) -> Any: - if not _is_running_in_event_loop(): - return render_fn(options) - return _run_in_worker_thread(lambda: render_fn(options)) - - -def _run_in_worker_thread(fn: Callable[[], Any]) -> Any: + # Always delegate to a dedicated daemon worker thread so that: + # 1. Playwright's greenlet event loop does not run on the calling thread + # (avoids blocking the asyncio executor thread introduced in 8.0.3). + # 2. A hard wall-clock timeout can be enforced even when the browser + # subprocess becomes permanently unresponsive (stuck WebSocket, no + # CDP response to page.content() or browser.close(), etc.). + hard_timeout = options.timeout_ms / 1000 * 2 + 30 + return _run_in_worker_thread(lambda: render_fn(options), timeout=hard_timeout) + + +def _run_in_worker_thread( + fn: Callable[[], Any], *, timeout: float | None = None +) -> Any: result: dict[str, Any] = {} error: dict[str, BaseException] = {} @@ -276,9 +283,20 @@ def target() -> None: except BaseException as exc: # pragma: no cover - asserted via caller paths error["exc"] = exc - thread = threading.Thread(target=target, name="playwright-render-worker") + # daemon=True: if this thread is abandoned on timeout it will not block + # process exit — Chromium subprocesses it owns are cleaned up by the OS. + thread = threading.Thread( + target=target, name="playwright-render-worker", daemon=True + ) thread.start() - thread.join() + thread.join(timeout=timeout) + + if thread.is_alive(): + raise LoaderRuntimeError( + f"Playwright render hard timeout after {timeout:.0f}s for render operation", + code="INGEST_LOAD_BROWSER_TIMEOUT", + retryable=True, + ) exc = error.get("exc") if exc is not None: