diff --git a/docs/changelog.md b/docs/changelog.md index 1e31d89..b7b78a1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,6 +22,8 @@ Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include - **`Accept: text/markdown` negotiation now follows RFC 9110.** The check used to be a substring match on the raw `Accept` header, which ignored q-values entirely — `text/html, text/markdown;q=0` served Markdown even though `q=0` means *not acceptable*, and `text/html;q=0.9, text/markdown;q=0.8` served Markdown even though HTML was preferred. The header is now properly parsed: media types match by exact type/subtype token (no substrings — `text/markdownish` no longer counts), q-values are honoured (`q=0` excludes; default `1.0`, clamped to `0..1`), wildcards like `*/*` never select Markdown, ties go to Markdown, and malformed headers never raise — they fall back to HTML. Since negotiated responses carry `Vary: Accept`, shared caches now key on the correct variant. New public helper: `pyxle.devserver.llms.markdown_is_acceptable(accept)`. See [AI accessibility → Negotiation rules](guides/llms.md#negotiation-rules). - **The generated `/llms.txt` no longer advertises Markdown that isn't there.** The default index used to emit relative links and to link every concrete page's `.md` URL — including pages whose `.md` URL just `307`-redirects to HTML. It now emits **absolute URLs** derived from the request's scheme and host (proxy headers respected), links `.md` only for pages whose Markdown actually resolves (a co-located `.md`, a `to_markdown` handler, an ancestor `llms.py` — or every page when `autoConvert` is on), and lists the rest at their canonical HTML URL. Custom `llms_txt` hooks are unaffected; `ctx.render_default()` returns the improved index. See [AI accessibility → The /llms.txt index](guides/llms.md#the-llmstxt-index). - **Converted Markdown keeps agents on the Markdown channel** (breaking behavior change). `autoConvert` output — and the public `html_to_markdown()` helper — now rewrites internal page links to their `.md` renditions: `[About](/about)` becomes `[About](/about.md)`, preserving query strings and fragments (`/about?x=1#y` → `/about.md?x=1#y`). External URLs, `mailto:`/`tel:` links, `/api/` routes, asset paths with a file extension, and links already ending in `.md` are untouched, and `wrap_markdown` hooks receive the rewritten Markdown. **Breaking:** `html_to_markdown()` rewrites by default — pass `rewrite_links=False` for the old verbatim behavior. See [AI accessibility → autoConvert](guides/llms.md#autoconvert-the-lossy-fallback). +- **Fix: a burst of rapid file saves can no longer kill the dev server.** Two dev-server robustness fixes land together. First, build passes are now serialized and `meta.json` is written atomically and only when its content changed — previously a debounce-timer race could run two rebuilds at once, and one pass reading the build metadata mid-write misdiagnosed it as a schema mismatch and wiped the whole `.pyxle-build` cache, deleting and recreating `vite.config.js`. Vite answers a config change with a full restart, and a restart racing the next rebuild could die (`[vite] process exited`), leaving nothing listening until a manual restart. Second, the Vite subprocess is now properly supervised: **any** exit the dev server didn't initiate — including a "clean" exit code 0 — logs a clear warning and relaunches Vite with a short exponential backoff (0.5s, doubling) and a bounded budget (3 consecutive attempts). If relaunching keeps failing, a structured `ViteSupervisionError` is surfaced loudly in the terminal instead of a silently dead asset server. +- **Fix: touching `request.state.` without the providing plugin now explains itself.** A loader or `@action` that reads a `request.state` attribute nothing populated (most commonly `request.state.db` without the pyxle-db plugin) used to 500 with a bare `AttributeError: 'State' object has no attribute 'db'` and a raw traceback that pointed nowhere. The framework now detects exactly this case and reports it as a structured error with guidance: state attributes are provided by plugins or middleware — e.g. `request.state.db` requires the pyxle-db plugin in `pyxle.config.json` (`"plugins": ["pyxle-db"]`). In dev the message appears in the error overlay, the action's JSON error, and the server log (with the original traceback chained); in production the response stays generic, as always. Any other `AttributeError` from your code flows through unchanged. ## 0.6.1 — 2026-07-01 diff --git a/pyxle/devserver/build.py b/pyxle/devserver/build.py index 052ec93..1c57aab 100644 --- a/pyxle/devserver/build.py +++ b/pyxle/devserver/build.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import shutil from dataclasses import dataclass from pathlib import Path @@ -127,11 +128,33 @@ def _load_metadata(path: Path) -> BuildMetadata | None: def _write_metadata(path: Path, metadata: BuildMetadata) -> None: - payload = metadata.to_dict() + """Persist ``metadata`` to ``path`` — write-if-changed and atomic. + + Identical content is never rewritten, so a no-op build pass leaves the + file's mtime untouched (the same guard every other stable generated + artifact uses). Changed content lands via a temp file + ``os.replace`` so + a concurrent reader can never observe a truncated ``meta.json`` — a torn + read here used to be misdiagnosed as a schema mismatch, wiping the entire + build cache. + """ + payload = json.dumps(metadata.to_dict(), indent=2, sort_keys=True) + "\n" + + if path.exists(): + try: + current = path.read_text(encoding="utf-8") + except OSError: # pragma: no cover - unreadable cache file; rewrite it + current = None + if current == payload: + return + path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as file: - json.dump(payload, file, indent=2, sort_keys=True) - file.write("\n") + temp_path = path.with_name(path.name + ".tmp") + try: + temp_path.write_text(payload, encoding="utf-8") + os.replace(temp_path, path) + finally: + if temp_path.exists(): # pragma: no cover - only on a failed write + temp_path.unlink(missing_ok=True) def load_build_metadata(build_root: Path, *, schema_version: str = BUILD_CACHE_SCHEMA_VERSION) -> BuildMetadata: diff --git a/pyxle/devserver/builder.py b/pyxle/devserver/builder.py index 07fb73f..1579630 100644 --- a/pyxle/devserver/builder.py +++ b/pyxle/devserver/builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import shutil +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Dict @@ -47,9 +48,33 @@ def any_changes(self) -> bool: ) +#: Serializes build passes within the process. The watcher debounces +#: filesystem events, but a debounce timer that fires while a previous build +#: is still running starts a second pass on another thread +#: (``threading.Timer.cancel()`` cannot stop a timer that has already fired). +#: Two interleaved passes read and rewrite the same ``meta.json`` and +#: generated artifacts; a torn read used to make +#: :func:`ensure_fresh_build_cache` mistake a mid-write ``meta.json`` for a +#: schema mismatch and wipe the whole build cache — deleting and recreating +#: ``vite.config.js``, which Vite answers with a full (and racy) dev-server +#: restart. Running one pass at a time keeps every pass' view of the cache +#: consistent. +_BUILD_PASS_LOCK = threading.Lock() + + def build_once(settings: DevServerSettings, *, force_rebuild: bool = False) -> BuildSummary: - """Run a single build pass for the project located at ``settings``.""" + """Run a single build pass for the project located at ``settings``. + + Passes are serialized process-wide (see :data:`_BUILD_PASS_LOCK`): a call + that arrives while another pass is running blocks until that pass + finishes, then rebuilds against the fresh metadata it left behind. + """ + + with _BUILD_PASS_LOCK: + return _build_once_locked(settings, force_rebuild=force_rebuild) + +def _build_once_locked(settings: DevServerSettings, *, force_rebuild: bool) -> BuildSummary: paths, previous_metadata = ensure_fresh_build_cache(settings) sources = scan_source_tree(settings) summary = BuildSummary() diff --git a/pyxle/devserver/starlette_app.py b/pyxle/devserver/starlette_app.py index 1509a91..963cb57 100644 --- a/pyxle/devserver/starlette_app.py +++ b/pyxle/devserver/starlette_app.py @@ -1390,6 +1390,26 @@ async def _dispatch_action( payload["fields"] = exc.fields return JSONResponse(payload, status_code=exc.status_code) except Exception as exc: + from pyxle.ssr.view import ( # noqa: PLC0415 + MissingRequestStateError, + missing_state_attribute, + ) + + # A read of an unset ``request.state`` attribute means a plugin or + # middleware isn't configured — wrap the bare AttributeError with + # guidance (chained, so the original traceback stays in the log). + # Every other exception flows through unchanged. + attribute = missing_state_attribute(exc) + if attribute is not None: + import logging as _logging # noqa: PLC0415 + + state_error = MissingRequestStateError(attribute) + state_error.__cause__ = exc + _logging.getLogger(__name__).error( + "Action '%s' failed: %s", action_name, state_error, exc_info=state_error + ) + error_msg = str(state_error) if debug else "Internal server error" + return JSONResponse({"ok": False, "error": error_msg}, status_code=500) error_msg = str(exc) if debug else "Internal server error" return JSONResponse({"ok": False, "error": error_msg}, status_code=500) diff --git a/pyxle/devserver/vite.py b/pyxle/devserver/vite.py index 6aa929c..3e326ab 100644 --- a/pyxle/devserver/vite.py +++ b/pyxle/devserver/vite.py @@ -17,9 +17,51 @@ _ViteProbe = Callable[[str, int], Awaitable[bool]] +#: Maximum consecutive relaunch attempts after an unexpected Vite exit. +#: Three attempts (with the exponential backoff below) are enough to ride out +#: transient causes — e.g. a rebuild burst rewriting generated files while +#: Vite is mid config-restart — while a persistently broken setup (bad +#: ``vite.config.js``, missing dependency) still fails fast and loudly instead +#: of crash-looping forever. +DEFAULT_RESTART_ATTEMPTS = 3 + +#: Base delay in seconds before relaunching Vite after an unexpected exit. +#: Doubles on every consecutive failure (0.5s → 1s → 2s) so the condition that +#: killed Vite (an in-flight rebuild, a port not yet released) has time to +#: clear, while the first relaunch is quick enough that HMR is back before the +#: developer notices. +DEFAULT_RESTART_DELAY = 0.5 + + +class ViteSupervisionError(RuntimeError): + """Raised when Vite exits unexpectedly and cannot be relaunched. + + Produced after the supervisor exhausts its relaunch budget + (:data:`DEFAULT_RESTART_ATTEMPTS` by default). The dev server is still + running at that point but can no longer serve client assets, so the error + is surfaced prominently instead of leaving a silently dead asset server. + """ + + def __init__(self, attempts: int) -> None: + super().__init__( + "Vite dev server exited unexpectedly and could not be relaunched " + f"after {attempts} attempt(s). Client assets can no longer be " + "served — restart `pyxle dev`. Check the [vite] log output above " + "for the underlying failure." + ) + self.attempts = attempts + class ViteProcess: - """Launch and supervise the Vite dev server.""" + """Launch and supervise the Vite dev server. + + Supervision: any exit the supervisor did not initiate via :meth:`stop` — + including a "clean" exit code 0, which Vite produces when e.g. its config + file disappears mid config-reload — is treated as unexpected and answered + with a bounded, exponentially backed-off relaunch. Exhausting the relaunch + budget records a :class:`ViteSupervisionError` (see :attr:`fatal_error`) + and logs it as a fatal error. + """ def __init__( self, @@ -32,7 +74,8 @@ def __init__( readiness_timeout: float = 10.0, readiness_interval: float = 0.1, probe: _ViteProbe | None = None, - restart_delay: float = 0.5, + restart_delay: float = DEFAULT_RESTART_DELAY, + max_restart_attempts: int = DEFAULT_RESTART_ATTEMPTS, ) -> None: self._settings = settings self._logger = logger or ConsoleLogger() @@ -48,6 +91,9 @@ def __init__( self._stopping: bool = False self._restart_task: asyncio.Task[None] | None = None self._restart_delay = restart_delay + self._max_restart_attempts = max_restart_attempts + self._restart_attempts = 0 + self._fatal_error: ViteSupervisionError | None = None self._command_override: list[str] | None = None self._npm_install_attempted = False @@ -56,6 +102,11 @@ def running(self) -> bool: process = self._process return process is not None and process.returncode is None + @property + def fatal_error(self) -> ViteSupervisionError | None: + """The supervision failure that ended relaunch attempts, if any.""" + return self._fatal_error + async def start(self) -> None: if self.running: return @@ -68,6 +119,13 @@ async def start(self) -> None: with suppress(asyncio.CancelledError): await restart_task self._restart_task = None + if current_task is not restart_task or restart_task is None: + # A manual (re)start expresses fresh intent: clear any previous + # supervision failure so the relaunch budget starts over. The + # supervisor's own relaunch loop must NOT reset the counter here, + # or the retry budget could never be exhausted. + self._fatal_error = None + self._restart_attempts = 0 command = self._build_launch_command() self._logger.info("Launching Vite dev server: " + " ".join(command)) @@ -319,16 +377,25 @@ async def _monitor_process(self, process: asyncio.subprocess.Process) -> None: await task returncode = await process.wait() - crashed = returncode not in (0, None) and not self._stopping if returncode not in (0, None): self._logger.error(f"[vite] process exited with code {returncode}") else: self._logger.info("[vite] process exited") - if crashed: - self._logger.warning("Vite process exited unexpectedly; attempting restart") - self._process = None + if self._stopping: + return + + # Any exit the supervisor did not initiate is unexpected — including a + # "clean" exit code 0 (Vite exits 0 when, e.g., its config file + # vanishes during a config-reload). Without a relaunch the dev server + # would keep running with no asset server behind it, which looks like + # a dead page in the browser. + self._logger.warning("Vite process exited unexpectedly; attempting restart") + self._process = None + if self._fatal_error is None and ( + self._restart_task is None or self._restart_task.done() + ): self._restart_task = asyncio.create_task(self._restart_after_exit()) async def _pipe_stream(self, stream: asyncio.StreamReader, *, is_error: bool) -> None: @@ -345,18 +412,73 @@ async def _pipe_stream(self, stream: asyncio.StreamReader, *, is_error: bool) -> self._logger.info(f"[vite] {message}") async def _restart_after_exit(self) -> None: + """Relaunch Vite after an unexpected exit, with backoff and a budget. + + Each consecutive failure doubles the delay; a relaunch that reaches + readiness resets the budget (the supervisor is healthy again). + Exhausting :attr:`_max_restart_attempts` records a + :class:`ViteSupervisionError` and logs it as fatal. + """ try: - await asyncio.sleep(self._restart_delay) - if self._stopping: + while not self._stopping: + self._restart_attempts += 1 + if self._restart_attempts > self._max_restart_attempts: + self._fatal_error = ViteSupervisionError(self._max_restart_attempts) + self._logger.error(str(self._fatal_error)) + # Never leave a hung child running unsupervised past the + # budget — it would hold the port and confuse the next + # manual start(). + await self._terminate_unready_child() + return + delay = self._restart_delay * (2 ** (self._restart_attempts - 1)) + self._logger.warning( + f"Relaunching Vite dev server in {delay:.1f}s " + f"(attempt {self._restart_attempts}/{self._max_restart_attempts})" + ) + await asyncio.sleep(delay) + if self._stopping: + return + try: + await self.start() + await self.wait_until_ready() + except Exception as exc: + self._logger.warning(f"Vite relaunch attempt failed: {exc}") + # A child that launched but never became ready (hung Vite) + # must not survive the attempt: ``start()`` would no-op on + # it next iteration and the budget would burn re-probing + # the same dead-end process. + await self._terminate_unready_child() + continue + # The relaunched process accepts connections again; treat the + # supervisor as healthy and forget the failure streak. + self._restart_attempts = 0 return - await self.start() - await self.wait_until_ready() - except asyncio.CancelledError: # pragma: no cover - cancellation path - raise - except Exception as exc: # pragma: no cover - defensive - self._logger.error(f"Failed to restart Vite dev server: {exc}") finally: - self._restart_task = None + if self._restart_task is asyncio.current_task(): + self._restart_task = None + + async def _terminate_unready_child(self) -> None: + """Terminate a live child without entering shutdown state. + + Used only by the supervisor when a relaunch left Vite running but not + accepting connections. ``_stopping`` stays False (this is not a + shutdown) — the monitor task skips rescheduling because the restart + task is still the current task. + """ + process = self._process + if process is None or process.returncode is not None: + return + self._logger.warning( + "Vite is running but not accepting connections; terminating the hung process" + ) + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=self._stop_timeout) + except asyncio.TimeoutError: + self._logger.warning("Hung Vite process did not exit after SIGTERM; killing") + process.kill() + await process.wait() + self._process = None @staticmethod async def _default_probe(host: str, port: int) -> bool: @@ -371,4 +493,9 @@ async def _default_probe(host: str, port: int) -> bool: return True -__all__ = ["ViteProcess"] +__all__ = [ + "DEFAULT_RESTART_ATTEMPTS", + "DEFAULT_RESTART_DELAY", + "ViteProcess", + "ViteSupervisionError", +] diff --git a/pyxle/ssr/view.py b/pyxle/ssr/view.py index 97ca226..944ce21 100644 --- a/pyxle/ssr/view.py +++ b/pyxle/ssr/view.py @@ -6,6 +6,7 @@ import importlib.util import inspect import logging +import re import secrets import sys import time @@ -37,6 +38,64 @@ class LoaderExecutionError(RuntimeError): """Raised when a page loader returns an unexpected value.""" +#: The exact ``AttributeError`` message Starlette's ``State`` raises when +#: server code reads an attribute nothing populated (``request.state.db`` +#: without the pyxle-db plugin, ``request.state.user`` without an auth +#: middleware, ...). Anchored so only genuine ``State`` misses match. +_STATE_ATTRIBUTE_MESSAGE_RE = re.compile( + r"^'State' object has no attribute '(?P[^']+)'$" +) + + +def missing_state_attribute(error: BaseException) -> str | None: + """Return the missing ``request.state`` attribute name for ``error``. + + Matches exactly the ``AttributeError`` Starlette's ``State`` raises when a + loader or action reads a ``request.state`` attribute that no plugin or + middleware provided. Any other exception — including other + ``AttributeError``\\ s — returns ``None`` so it flows through the normal + error path untouched. + """ + if not isinstance(error, AttributeError): + return None + match = _STATE_ATTRIBUTE_MESSAGE_RE.match(str(error)) + return match.group("name") if match else None + + +def _missing_state_message(attribute: str) -> str: + if attribute == "db": + return ( + "request.state.db is not set — it is provided by the pyxle-db " + 'plugin. Add it to pyxle.config.json ("plugins": ["pyxle-db"]) ' + "and restart the dev server." + ) + return ( + f"request.state.{attribute} is not set — state attributes are " + "provided by plugins or middleware (for example, request.state.db " + 'requires the pyxle-db plugin: "plugins": ["pyxle-db"] in ' + "pyxle.config.json). Check your plugin/middleware configuration." + ) + + +class MissingRequestStateError(LoaderExecutionError): + """Raised when server code reads an unset ``request.state`` attribute. + + Wraps the bare ``AttributeError: 'State' object has no attribute ''`` + Starlette raises in that case with actionable guidance (state attributes + are provided by plugins or middleware; ``request.state.db`` needs the + pyxle-db plugin). Subclasses :class:`LoaderExecutionError` so every loader + pipeline (buffered, streaming, navigation) reports it as a loader-stage + failure: guidance in the dev overlay and server log, the generic sanitized + response in production. The original ``AttributeError`` stays reachable + through ``__cause__``. Also used by the ``@action`` dispatcher for the + same diagnosis on action requests. + """ + + def __init__(self, attribute: str) -> None: + super().__init__(_missing_state_message(attribute)) + self.attribute = attribute + + class HeadEvaluationError(RuntimeError): """Raised when HEAD cannot be resolved at runtime.""" @@ -737,15 +796,33 @@ async def _execute_loader( _loader_start = time.perf_counter() with span("loader"): - result = loader(request) - if hasattr(result, "__await__"): - result = await result # type: ignore[assignment] + result = await _invoke_loader_callable(loader, request) _record_render_metric(request, "loader", (time.perf_counter() - _loader_start) * 1000.0) payload, status_code, revalidate = _normalize_loader_result(result, page) return payload, status_code, revalidate, module +async def _invoke_loader_callable(loader: Callable[..., Any], request: Request) -> Any: + """Call a ``@server`` loader (sync or async) and return its result. + + A read of an unset ``request.state`` attribute is translated into + :class:`MissingRequestStateError` (guidance instead of a bare + ``AttributeError``), chaining the original exception; every other + exception propagates untouched. + """ + try: + result = loader(request) + if hasattr(result, "__await__"): + result = await result + except AttributeError as exc: + attribute = missing_state_attribute(exc) + if attribute is None: + raise + raise MissingRequestStateError(attribute) from exc + return result + + async def _execute_layout_loaders( *, settings: DevServerSettings, @@ -770,9 +847,7 @@ async def _execute_layout_loaders( if loader_fn is None: continue - result = loader_fn(request) - if hasattr(result, "__await__"): - result = await result + result = await _invoke_loader_callable(loader_fn, request) # Layout loaders return a plain dict (no status code). if isinstance(result, tuple) and result: @@ -1294,6 +1369,8 @@ def _normalize_head_entries(page: PageRoute, value: Any) -> tuple[str, ...]: __all__ = [ "LoaderExecutionError", + "MissingRequestStateError", + "missing_state_attribute", "build_page_response", "build_page_navigation_response", "build_not_found_response", diff --git a/tests/devserver/test_action_routes.py b/tests/devserver/test_action_routes.py index 41b35d9..b4787cd 100644 --- a/tests/devserver/test_action_routes.py +++ b/tests/devserver/test_action_routes.py @@ -1225,3 +1225,109 @@ def test_action_router_without_hooks_still_dispatches(tmp_path: Path) -> None: response = client.post("/api/__actions/nohooks_dispatch/go", json={}) assert response.status_code == 200 assert response.json() == {"ok": True, "value": 1} + + +# --------------------------------------------------------------------------- +# Missing request.state attribute guidance +# --------------------------------------------------------------------------- + + +def _state_action_app(module_path: Path, *, debug: bool) -> TestClient: + route = ActionRoute( + path="/api/__actions/state/save", + page_path="/state", + action_name="save", + server_module_path=module_path, + module_key=f"pyxle.server.pages.state_{'dev' if debug else 'prod'}", + ) + router = build_action_router([route], debug=debug) + + from starlette.applications import Starlette + + app = Starlette() + app.router.routes.extend(router.routes) + return TestClient(app, raise_server_exceptions=False) + + +def test_action_dispatch_missing_state_guidance_in_debug(tmp_path: Path) -> None: + """request.state.db without the plugin → actionable guidance, not a bare + "'State' object has no attribute 'db'".""" + module_path = _write_module( + tmp_path / "server" / "pages" / "state_dev.py", + """ + from pyxle.runtime import action + + @action + async def save(request): + request.state.db.execute("SELECT 1") + return {} + """, + ) + + client = _state_action_app(module_path, debug=True) + response = client.post("/api/__actions/state/save", json={}) + + assert response.status_code == 500 + data = response.json() + assert data["ok"] is False + assert "request.state.db is not set" in data["error"] + assert "pyxle-db" in data["error"] + + +def test_action_dispatch_missing_state_generic_in_production(tmp_path: Path) -> None: + """Production responses never leak the guidance (or any internals).""" + module_path = _write_module( + tmp_path / "server" / "pages" / "state_prod.py", + """ + from pyxle.runtime import action + + @action + async def save(request): + request.state.db.execute("SELECT 1") + return {} + """, + ) + + client = _state_action_app(module_path, debug=False) + response = client.post("/api/__actions/state/save", json={}) + + assert response.status_code == 500 + data = response.json() + assert data["ok"] is False + assert data["error"] == "Internal server error" + + +def test_action_dispatch_other_attribute_error_flows_through(tmp_path: Path) -> None: + """A non-State AttributeError keeps the existing generic error path.""" + module_path = _write_module( + tmp_path / "server" / "pages" / "attr_boom.py", + """ + from pyxle.runtime import action + + @action + async def save(request): + raise AttributeError("boom") + """, + ) + + route = ActionRoute( + path="/api/__actions/attr-boom/save", + page_path="/attr-boom", + action_name="save", + server_module_path=module_path, + module_key="pyxle.server.pages.attr_boom", + ) + router = build_action_router([route], debug=True) + + from starlette.applications import Starlette + + app = Starlette() + app.router.routes.extend(router.routes) + client = TestClient(app, raise_server_exceptions=False) + + response = client.post("/api/__actions/attr-boom/save", json={}) + + assert response.status_code == 500 + data = response.json() + assert data["ok"] is False + assert data["error"] == "boom" diff --git a/tests/devserver/test_build.py b/tests/devserver/test_build.py index dd91ca5..3450c20 100644 --- a/tests/devserver/test_build.py +++ b/tests/devserver/test_build.py @@ -131,3 +131,48 @@ def test_save_build_metadata_writes_payload(tmp_path: Path) -> None: payload = json.load(file) assert payload == {"schema_version": "9", "sources": {}} + + +def test_save_build_metadata_skips_identical_content(tmp_path: Path) -> None: + """Rewriting unchanged metadata must not touch the file (mtime stays).""" + import time + + settings = make_settings(tmp_path) + initialize_build_directories(settings) + metadata = BuildMetadata( + schema_version=BUILD_CACHE_SCHEMA_VERSION, + sources={}, + ) + + save_build_metadata(settings.build_root, metadata) + meta_path = settings.build_root / "meta.json" + before = meta_path.stat().st_mtime_ns + time.sleep(0.02) + + save_build_metadata(settings.build_root, metadata) + + assert meta_path.stat().st_mtime_ns == before + assert not (settings.build_root / "meta.json.tmp").exists() + + +def test_save_build_metadata_replaces_changed_content_atomically(tmp_path: Path) -> None: + """Changed metadata is rewritten (via temp + rename) with no temp leftover.""" + from pyxle.devserver.build import CachedSourceRecord + + settings = make_settings(tmp_path) + initialize_build_directories(settings) + save_build_metadata( + settings.build_root, + BuildMetadata(schema_version=BUILD_CACHE_SCHEMA_VERSION, sources={}), + ) + + updated = BuildMetadata( + schema_version=BUILD_CACHE_SCHEMA_VERSION, + sources={"index.pyxl": CachedSourceRecord(kind="page", content_hash="abc")}, + ) + save_build_metadata(settings.build_root, updated) + + meta_path = settings.build_root / "meta.json" + payload = json.loads(meta_path.read_text(encoding="utf-8")) + assert payload["sources"] == {"index.pyxl": {"kind": "page", "hash": "abc"}} + assert not (settings.build_root / "meta.json.tmp").exists() diff --git a/tests/devserver/test_builder.py b/tests/devserver/test_builder.py index 351b042..d952036 100644 --- a/tests/devserver/test_builder.py +++ b/tests/devserver/test_builder.py @@ -211,3 +211,84 @@ def test_build_once_syncs_global_scripts(tmp_path: Path) -> None: summary = build_once(settings) assert summary.synced_scripts == ["scripts/analytics.js"] assert "updated" in generated.read_text(encoding="utf-8") + + +def test_build_once_leaves_unchanged_generated_files_untouched( + project: DevServerSettings, +) -> None: + """A rebuild must not rewrite stable generated artifacts. + + Rewriting ``vite.config.js`` with identical content still bumps its mtime, + and Vite answers a config-file change with a full dev-server restart — so + an unchanged config (and unchanged ``meta.json``) must keep its mtime. + """ + import time + + from pyxle.devserver.client_files import VITE_CONFIG_FILENAME + + build_once(project, force_rebuild=True) + + vite_config = project.client_build_dir / VITE_CONFIG_FILENAME + meta_json = project.build_root / "meta.json" + assert vite_config.exists() + assert meta_json.exists() + + before = { + path: path.stat().st_mtime_ns for path in (vite_config, meta_json) + } + time.sleep(0.02) + + build_once(project) + + for path, mtime in before.items(): + assert path.stat().st_mtime_ns == mtime, f"{path.name} was rewritten" + + +def test_build_once_serializes_concurrent_invocations( + project: DevServerSettings, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Overlapping build_once calls (debounce-timer race) run one at a time. + + A concurrent pass used to read ``meta.json`` while another pass was + mid-write, misdiagnose the torn read as a schema mismatch, and wipe the + build cache (recreating ``vite.config.js`` → spurious Vite restart). + """ + import threading + import time + + from pyxle.devserver import builder as builder_module + + active = 0 + max_active = 0 + gauge_lock = threading.Lock() + real_scan = builder_module.scan_source_tree + + def tracking_scan(settings: DevServerSettings): + nonlocal active, max_active + with gauge_lock: + active += 1 + max_active = max(max_active, active) + time.sleep(0.05) + with gauge_lock: + active -= 1 + return real_scan(settings) + + monkeypatch.setattr(builder_module, "scan_source_tree", tracking_scan) + + errors: list[BaseException] = [] + + def run_build() -> None: + try: + build_once(project) + except BaseException as exc: # pragma: no cover - fails the assertion below + errors.append(exc) + + threads = [threading.Thread(target=run_build) for _ in range(3)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert errors == [] + assert max_active == 1, "build passes overlapped" diff --git a/tests/devserver/test_vite.py b/tests/devserver/test_vite.py index 818b251..cc4c372 100644 --- a/tests/devserver/test_vite.py +++ b/tests/devserver/test_vite.py @@ -8,7 +8,7 @@ from pyxle.cli.logger import ConsoleLogger from pyxle.devserver.settings import DevServerSettings -from pyxle.devserver.vite import ViteProcess +from pyxle.devserver.vite import ViteProcess, ViteSupervisionError pytestmark = pytest.mark.anyio("asyncio") @@ -669,3 +669,257 @@ async def succeeding_open_connection(host: str, port: int): assert results == [False, True] + +async def test_vite_process_relaunches_after_unexpected_clean_exit(settings: DevServerSettings) -> None: + """An exit the supervisor didn't initiate — even code 0 — is relaunched.""" + + class Factory: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, *cmd: str, **_: Any) -> FakeProcess: + if self.calls == 0: + process = FakeProcess(stdout_lines=None, stderr_lines=None, exit_code=0) + else: + process = FakeProcess( + stdout_lines=None, + stderr_lines=None, + exit_code=0, + auto_exit=False, + ) + process.start() + self.calls += 1 + return process + + factory = Factory() + capture: list[str] = [] + logger = ConsoleLogger(secho=lambda msg, **_: capture.append(msg)) + + async def probe(host: str, port: int) -> bool: + return True + + vite = ViteProcess( + settings, + logger=logger, + process_factory=factory, + restart_delay=0, + readiness_interval=0.01, + probe=probe, + stop_timeout=0.1, + ) + + await vite.start() + + async def wait_for_relaunch() -> None: + while not (factory.calls >= 2 and vite.running and vite._restart_task is None): + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_relaunch(), timeout=1) + + assert factory.calls == 2 + assert vite.running is True + assert vite.fatal_error is None + assert vite._restart_attempts == 0 # a ready relaunch resets the budget + assert any("exited unexpectedly" in msg for msg in capture) + + await vite.stop() + + +async def test_vite_process_shutdown_exit_does_not_relaunch(settings: DevServerSettings) -> None: + """A stop()-initiated exit must never schedule a relaunch.""" + + class Factory: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, *cmd: str, **_: Any) -> FakeProcess: + process = FakeProcess( + stdout_lines=None, + stderr_lines=None, + exit_code=0, + auto_exit=False, + ) + process.start() + self.calls += 1 + return process + + factory = Factory() + capture: list[str] = [] + logger = ConsoleLogger(secho=lambda msg, **_: capture.append(msg)) + + vite = ViteProcess(settings, logger=logger, process_factory=factory, stop_timeout=0.1) + + await vite.start() + await asyncio.sleep(0) + await vite.stop() + await asyncio.sleep(0.05) + + assert factory.calls == 1 + assert vite._restart_task is None + assert vite.fatal_error is None + assert not any("attempting restart" in msg for msg in capture) + + +async def test_vite_process_restart_exhaustion_surfaces_structured_error( + settings: DevServerSettings, +) -> None: + """Relaunch attempts are bounded; exhaustion records a ViteSupervisionError.""" + + class CrashingFactory: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, *cmd: str, **_: Any) -> FakeProcess: + process = FakeProcess(stdout_lines=None, stderr_lines=None, exit_code=1) + process.start() + self.calls += 1 + return process + + factory = CrashingFactory() + capture: list[str] = [] + logger = ConsoleLogger(secho=lambda msg, **_: capture.append(msg)) + + async def probe(host: str, port: int) -> bool: + return False + + vite = ViteProcess( + settings, + logger=logger, + process_factory=factory, + restart_delay=0, + readiness_interval=0.01, + readiness_timeout=0.2, + probe=probe, + max_restart_attempts=2, + stop_timeout=0.1, + ) + + await vite.start() + + async def wait_for_fatal() -> None: + while vite.fatal_error is None: + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_fatal(), timeout=5) + + error = vite.fatal_error + assert isinstance(error, ViteSupervisionError) + assert error.attempts == 2 + assert "could not be relaunched" in str(error) + assert "pyxle dev" in str(error) + assert factory.calls == 3 # the initial launch + 2 relaunch attempts + assert vite.running is False + assert any("could not be relaunched" in msg for msg in capture) + assert any("relaunch attempt failed" in msg.lower() for msg in capture) + + await vite.stop() + + +async def test_vite_process_manual_start_clears_fatal_error(settings: DevServerSettings) -> None: + """After exhaustion, an explicit start() resets the supervision budget.""" + + class Factory: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, *cmd: str, **_: Any) -> FakeProcess: + process = FakeProcess( + stdout_lines=None, + stderr_lines=None, + exit_code=0, + auto_exit=False, + ) + process.start() + self.calls += 1 + return process + + factory = Factory() + vite = ViteProcess(settings, process_factory=factory, stop_timeout=0.1) + vite._fatal_error = ViteSupervisionError(3) + vite._restart_attempts = 4 + + await vite.start() + + assert vite.fatal_error is None + assert vite._restart_attempts == 0 + assert vite.running is True + + await vite.stop() + + +async def test_vite_process_restart_loop_no_ops_when_already_stopping( + settings: DevServerSettings, +) -> None: + """A relaunch racing stop() exits immediately without spawning anything.""" + stub = ProcessStub() + vite = ViteProcess(settings, process_factory=stub) + vite._stopping = True + + await vite._restart_after_exit() + + assert stub.created_commands == [] + assert vite._restart_task is None + assert vite.fatal_error is None + + +async def test_vite_supervisor_terminates_hung_child_and_stops_leak( + settings: DevServerSettings, +) -> None: + """A relaunched Vite that stays alive but never listens is terminated on + each failed attempt (so every attempt launches a FRESH process) and again + on budget exhaustion — no orphan survives the supervisor.""" + + class HungFactory: + def __init__(self) -> None: + self.calls = 0 + self.processes: list[FakeProcess] = [] + + async def __call__(self, *cmd: str, **_: Any) -> FakeProcess: + # First process crashes to trigger the supervisor; every relaunch + # hangs: alive, but the probe below never reports ready. + auto_exit = self.calls == 0 + process = FakeProcess( + stdout_lines=None, + stderr_lines=None, + exit_code=1 if auto_exit else 0, + auto_exit=auto_exit, + ) + process.start() + self.calls += 1 + self.processes.append(process) + return process + + factory = HungFactory() + capture: list[str] = [] + logger = ConsoleLogger(secho=lambda msg, **_: capture.append(msg)) + + async def never_ready(host: str, port: int) -> bool: + return False + + vite = ViteProcess( + settings, + logger=logger, + process_factory=factory, + restart_delay=0, + readiness_timeout=0.05, + readiness_interval=0.01, + probe=never_ready, + stop_timeout=0.1, + ) + + await vite.start() + + async def wait_for_exhaustion() -> None: + while vite.fatal_error is None: + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_exhaustion(), timeout=5) + + # One fresh process per attempt: the hung child never survives into the + # next iteration, and none survives exhaustion. + assert factory.calls == 1 + vite._max_restart_attempts + assert all(process.returncode is not None for process in factory.processes) + assert vite.running is False + assert any("terminating the hung process" in msg for msg in capture) + + await vite.stop() diff --git a/tests/ssr/test_view.py b/tests/ssr/test_view.py index 5276bcc..12189cd 100644 --- a/tests/ssr/test_view.py +++ b/tests/ssr/test_view.py @@ -2701,3 +2701,136 @@ async def test_nav_payload_carries_error_asset_when_boundary_present( ) body = json.loads((await _read_response_body(response)).decode()) assert body["page"]["errorAssetPath"] == "/pages/error.jsx" + + +# --------------------------------------------------------------------------- +# Missing request.state attribute guidance +# --------------------------------------------------------------------------- + + +def test_missing_state_attribute_matches_starlette_state_error() -> None: + """Only Starlette's exact State AttributeError pattern is recognized.""" + from starlette.datastructures import State + + try: + State().db + except AttributeError as exc: + state_error = exc + + assert ssr_view.missing_state_attribute(state_error) == "db" + assert ssr_view.missing_state_attribute(AttributeError("boom")) is None + assert ( + ssr_view.missing_state_attribute( + ValueError("'State' object has no attribute 'db'") + ) + is None + ) + + +def test_missing_request_state_error_messages() -> None: + """'db' gets the pyxle-db pointer; other names get the generic guidance.""" + db_error = ssr_view.MissingRequestStateError("db") + assert db_error.attribute == "db" + assert "request.state.db is not set" in str(db_error) + assert "pyxle-db" in str(db_error) + assert '"plugins": ["pyxle-db"]' in str(db_error) + + generic = ssr_view.MissingRequestStateError("session") + assert generic.attribute == "session" + assert "request.state.session is not set" in str(generic) + assert "plugins or middleware" in str(generic) + assert "pyxle-db" in str(generic) # the example still shows the pattern + + +def _state_page(tmp_path: Path, loader_body: str) -> PageRoute: + server_module = tmp_path / "server" / "state_page.py" + server_module.parent.mkdir(parents=True, exist_ok=True) + server_module.write_text( + f"async def load_home(request):\n {loader_body}\n", + encoding="utf-8", + ) + page = _page_route(tmp_path, loader_name="load_home") + return replace(page, server_module_path=server_module, module_key="pyxle.server.pages.state_page") + + +@pytest.mark.anyio +async def test_execute_loader_wraps_missing_state_with_guidance(tmp_path: Path) -> None: + """request.state.db without a provider → MissingRequestStateError, chained.""" + page = _state_page(tmp_path, "return {'rows': request.state.db}") + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + + with pytest.raises(ssr_view.MissingRequestStateError) as excinfo: + await ssr_view._execute_loader(page, request, module=None, debug=True) + + assert excinfo.value.attribute == "db" + assert isinstance(excinfo.value.__cause__, AttributeError) + assert "'State' object has no attribute 'db'" in str(excinfo.value.__cause__) + + +@pytest.mark.anyio +async def test_execute_loader_other_attribute_error_flows_through(tmp_path: Path) -> None: + """Any other AttributeError is re-raised untouched (no wrapping).""" + page = _state_page(tmp_path, "raise AttributeError('boom')") + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + + with pytest.raises(AttributeError) as excinfo: + await ssr_view._execute_loader(page, request, module=None, debug=True) + + assert not isinstance(excinfo.value, ssr_view.MissingRequestStateError) + assert str(excinfo.value) == "boom" + + +@pytest.mark.anyio +async def test_build_page_response_missing_state_shows_guidance_in_dev( + settings: DevServerSettings, tmp_path: Path +) -> None: + page = _state_page(tmp_path, "return {'rows': request.state.db}") + renderer = StubRenderer() + overlay = StubOverlay() + request = Request( + {"type": "http", "http_version": "1.1", "method": "GET", "path": "/", "root_path": "", "headers": []} + ) + + response = await build_page_response( + request=request, + settings=settings, + page=page, + renderer=renderer, + overlay=overlay, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "MissingRequestStateError" in body + assert "pyxle-db" in body + + # The overlay breadcrumb carries the same guidance for the dev overlay. + assert overlay.events and overlay.events[0][0] == "error" + breadcrumbs = overlay.events[0][2] + assert breadcrumbs[0]["status"] == "failed" + assert "request.state.db is not set" in breadcrumbs[0]["detail"] + + +@pytest.mark.anyio +async def test_build_page_response_missing_state_stays_generic_in_production( + settings: DevServerSettings, tmp_path: Path +) -> None: + prod_settings = replace(settings, debug=False) + page = _state_page(tmp_path, "return {'rows': request.state.db}") + renderer = StubRenderer() + request = Request( + {"type": "http", "http_version": "1.1", "method": "GET", "path": "/", "root_path": "", "headers": []} + ) + + response = await build_page_response( + request=request, + settings=prod_settings, + page=page, + renderer=renderer, + ) + + body = (await _read_response_body(response)).decode() + assert response.status_code == 500 + assert "pyxle-db" not in body + assert "request.state" not in body + assert "MissingRequestStateError" not in body