Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -4183,6 +4183,22 @@
"en": "Unpinned",
"zh": "已取消固定"
},
"gui.update.behind": {
"en": "Stale page build",
"zh": "页面构建落后于源码"
},
"gui.update.behind_body": {
"en": "ui-web/src or i18n/messages.json changed after dist/index.html was built, so this page is older than the code it runs on. In the repository root run make build-ui, then reload this page.",
"zh": "ui-web/src 或 i18n/messages.json 在 dist/index.html 构建之后有改动,这个页面比它背后的代码旧。在仓库根目录运行 make build-ui,然后刷新本页。"
},
"gui.update.behind_how": {
"en": "How to rebuild",
"zh": "如何重建"
},
"gui.update.behind_title": {
"en": "Rebuild the page",
"zh": "重建页面"
},
"gui.update.note": {
"en": "UI updated on disk",
"zh": "界面有更新"
Expand Down
5 changes: 4 additions & 1 deletion raven/cli/_gateway_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ async def mount_page(agent_loop: Any, preferred_port: int) -> PageMount | None:
_announce_updates,
_write_serve_state,
adopt_stored_cookie,
page_behind_sources,
port_strict,
resolve_ui_dist,
)
Expand Down Expand Up @@ -135,11 +136,13 @@ async def mount_page(agent_loop: Any, preferred_port: int) -> PageMount | None:
stack = await build_rpc_stack(ws_gateway.broadcast, agent_loop=agent_loop)
ws_gateway.dispatcher = stack.dispatcher

dist = resolve_ui_dist()
app = build_app(
ws_gateway,
resolve_ui_dist(),
dist,
deliverables=stack.deliverables,
agent_loop_factory=lambda: stack.agent_loop,
page_behind=lambda: page_behind_sources(dist),
)
runner = web.AppRunner(app)
await runner.setup()
Expand Down
55 changes: 52 additions & 3 deletions raven/cli/serve_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,60 @@ def port_strict() -> bool:


def resolve_ui_dist() -> Optional[Path]:
"""Locate the built ui page: wheel-packaged copy first, then source tree."""
"""Locate the built ui page: wheel-packaged copy first, then source tree.

A source-tree page older than what it is built from is reported here on
every resolve, because every path that serves or opens the page resolves
it once -- `raven serve`, the gateway's page mount and `raven web` alike --
and the terminal is where the rebuild happens.
"""
for candidate in (_PACKAGED_UI_DIST, _UI_DIR / "dist"):
if (candidate / "index.html").exists():
if page_behind_sources(candidate):
from loguru import logger

logger.warning(
"the built page is older than ui-web/src or i18n/messages.json; run `make build-ui` to rebuild it"
)
return candidate
return None


_PAGE_SOURCE_SKIP = frozenset({"test", "__snapshots__", "__golden__"})
_PAGE_BUILD_FILES = ("build.py", "vite.config.ts", "package.json", "package-lock.json", "icon/raven.svg")


def page_behind_sources(dist: Optional[Path]) -> bool:
"""Whether the source-tree page was built before its inputs last changed.

Only the checkout's own ``ui-web/dist`` can fall behind: the wheel's copy
ships beside the code it was built with. Judged by mtime, the way make
would -- a pull that touches ``ui-web/src`` or the message catalogue leaves
those files newer than ``dist/index.html`` until the next build. The
build's own files count too (the assembler, the bundler config, the
dependency lock, the icon it copies): a pull that moves only those changes
the page as surely as a source edit. Tests, the test harness layer and
snapshots are left out: they change without changing the page.
"""
if dist is None or dist != _UI_DIR / "dist":
return False
try:
built = (dist / "index.html").stat().st_mtime
except OSError:
return False
inputs = [_UI_DIR.parent / "i18n" / "messages.json", *(_UI_DIR / name for name in _PAGE_BUILD_FILES)]
inputs.extend(
path
for path in (_UI_DIR / "src").rglob("*")
if path.is_file() and ".test." not in path.name and not _PAGE_SOURCE_SKIP.intersection(path.parts)
)
newest = 0.0
for path in inputs:
with suppress(OSError):
newest = max(newest, path.stat().st_mtime)
return newest > built


def _state_path() -> Path:
"""Where the running gateway leaves its port and token.

Expand Down Expand Up @@ -385,14 +432,16 @@ async def _serve_main(port: int, open_browser: bool) -> None:
served = _ServedStack(gateway)
stack = await served.start()

dist = resolve_ui_dist()
app = build_app(
gateway,
resolve_ui_dist(),
dist,
# Read per request, not once: a first run assembles its stack after the
# app is built, and a store captured here would leave that process with
# no download surface for the rest of its life.
deliverables=lambda: served.current.deliverables,
agent_loop_factory=lambda: served.current.agent_loop,
page_behind=lambda: page_behind_sources(dist),
)
runner = web.AppRunner(app)
await runner.setup()
Expand Down Expand Up @@ -1067,7 +1116,7 @@ def _web(port: int, *, foreground: bool = False, stop: bool = False, supervise:
return

if resolve_ui_dist() is None:
typer.echo("No page is built. Run `python ui-web/build.py`, or install raven from a release wheel.")
typer.echo("No page is built. Run `make build-ui`, or install raven from a release wheel.")
raise typer.Exit(1)

if supervise:
Expand Down
52 changes: 34 additions & 18 deletions raven/rpc/transports/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import secrets
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -518,6 +519,7 @@ def build_app(
*,
deliverables: Any = None,
agent_loop_factory: Any = None,
page_behind: Callable[[], bool] | None = None,
) -> web.Application:
from raven.rpc.transports.deliverables import add_files_routes

Expand Down Expand Up @@ -558,24 +560,38 @@ async def index(_request: web.Request) -> web.FileResponse:
if assets.is_dir():
app.router.add_static("/assets", assets)

async def revalidate(_request: web.Request, response: web.StreamResponse) -> None:
"""Make the browser ask before reusing an asset it already has.

These files are served from one unversioned path each, so a
rebuilt icon lands at the URL its predecessor is cached under.
Without a directive the browser is free to guess a lifetime from
the last-modified date and keep the old drawing for hours -- a
provider logo replaced in the bundle went on rendering as the
one it replaced.

``no-cache`` is not "do not store": the copy is kept and offered
back with its etag, so an unchanged file costs a 304 and no
bytes. Only the guessing is switched off.
"""
if _request.path.startswith("/assets/"):
response.headers.setdefault("Cache-Control", "no-cache")

app.on_response_prepare.append(revalidate)
async def revalidate(_request: web.Request, response: web.StreamResponse) -> None:
"""Make the browser ask before reusing a dist file it already has.

The page and its assets are served from one unversioned path each,
so a rebuilt file lands at the URL its predecessor is cached under.
Without a directive the browser is free to guess a lifetime from
the last-modified date and keep the old copy for hours -- a
provider logo replaced in the bundle went on rendering as the one
it replaced, and a rebuilt page went on opening as the build
before it: the sign-in page at ``/auth`` ends by navigating the
tab to ``/``, and a browser answers that navigation from a copy
it still guesses fresh.

``no-cache`` is not "do not store": the copy is kept and offered
back with its etag, so an unchanged file costs a 304 and no
bytes. Only the guessing is switched off.

The page also says when it is older than the sources it was built
from, which only a source checkout can be (``page_behind`` is the
caller's judgement of that, asked per response so a rebuild takes
the header away without a restart). The page's own HEAD probe of
``/`` reads it and shows the reader how to rebuild: the terminal
that resolved the page has already said so, but `raven web`
detaches that terminal, and the page is the one place both launch
paths can show it.
"""
if _request.path == "/" or _request.path.startswith("/assets/"):
response.headers.setdefault("Cache-Control", "no-cache")
if _request.path == "/" and page_behind is not None and page_behind():
response.headers["X-Raven-Page-Behind"] = "sources"

app.on_response_prepare.append(revalidate)
else:

async def placeholder(_request: web.Request) -> web.Response:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_cli_gateway_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,37 @@ async def fake_pick(preferred: int, *, strict: bool = False, wait_s: float = 20.
with pytest.raises(OSError):
await _gateway_page.mount_page(loop, 18931)
assert asked == [False], "an ordinary start should still probe forward"


async def test_the_mounted_page_says_when_it_is_behind_its_sources(home: Path, tmp_path: Path, monkeypatch) -> None:
"""The gateway host hands the resolver's judgement to the app the way
standalone serve does, so the page it mounts carries the header -- pinned
here because the predicate and the header are each tested alone, and a
host that forgot to wire them would keep both green."""
import os

import aiohttp

from raven.cli import serve_commands
from raven.cli._gateway_page import mount_page

ui = tmp_path / "repo" / "ui-web"
(ui / "dist").mkdir(parents=True)
(ui / "src").mkdir()
(ui / "dist" / "index.html").write_text("<html>", encoding="utf-8")
(ui / "src" / "main.tsx").write_text("x", encoding="utf-8")
os.utime(ui / "dist" / "index.html", (1_000, 1_000))
os.utime(ui / "src" / "main.tsx", (2_000, 2_000))
monkeypatch.setattr(serve_commands, "_PACKAGED_UI_DIST", tmp_path / "nowhere")
monkeypatch.setattr(serve_commands, "_UI_DIR", ui)

mount = await mount_page(_FakeLoop(_FakeCron()), 18931)
assert mount is not None
try:
async with aiohttp.ClientSession() as session:
async with session.head(f"{mount.url}/") as resp:
assert resp.status == 200
assert resp.headers["X-Raven-Page-Behind"] == "sources"
assert resp.headers["Cache-Control"] == "no-cache"
finally:
await mount.teardown()
102 changes: 102 additions & 0 deletions tests/test_cli_serve_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1520,3 +1520,105 @@ async def test_two_writes_at_once_assemble_once(monkeypatch) -> None:
assert both == [True, True]
assert len(calls) == 2 # the first start, then one assembly
assert served.current is second


class TestPageBehindSources:
"""A source-tree page is behind when something it is built from is newer.

The wheel's copy never is: it ships beside the code it was built with, so
nothing newer exists for it to be behind. The comparison is by mtime, the
way make judges a target, and it skips tests and snapshots because those
change without changing the page.
"""

@staticmethod
def _stamp(path: Path, when: float) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text("x", encoding="utf-8")
import os

os.utime(path, (when, when))

def test_a_page_built_after_its_last_source_edit_is_current(self, two_candidates) -> None:
_packaged, source = two_candidates
ui = source.parent
self._stamp(ui / "src" / "main.tsx", 1_000.0)
self._stamp(ui.parent / "i18n" / "messages.json", 1_500.0)
self._stamp(source / "index.html", 2_000.0)

assert serve_commands.page_behind_sources(source) is False

def test_a_newer_source_file_puts_the_page_behind(self, two_candidates) -> None:
_packaged, source = two_candidates
ui = source.parent
self._stamp(source / "index.html", 2_000.0)
self._stamp(ui / "src" / "features" / "rail" / "RailPage.tsx", 3_000.0)

assert serve_commands.page_behind_sources(source) is True

def test_a_newer_catalogue_puts_the_page_behind(self, two_candidates) -> None:
_packaged, source = two_candidates
ui = source.parent
self._stamp(source / "index.html", 2_000.0)
self._stamp(ui / "src" / "main.tsx", 1_000.0)
self._stamp(ui.parent / "i18n" / "messages.json", 3_000.0)

assert serve_commands.page_behind_sources(source) is True

def test_tests_harnesses_and_snapshots_do_not_count(self, two_candidates) -> None:
_packaged, source = two_candidates
ui = source.parent
self._stamp(source / "index.html", 2_000.0)
self._stamp(ui / "src" / "app" / "updates.test.ts", 3_000.0)
self._stamp(ui / "src" / "test" / "settingsHarness.ts", 3_000.0)
self._stamp(ui / "src" / "features" / "rail" / "__snapshots__" / "RailPage.test.tsx.snap", 3_000.0)
self._stamp(ui / "scripts" / "__golden__" / "boot-stub.txt", 3_000.0)

assert serve_commands.page_behind_sources(source) is False

def test_a_dependency_bump_or_build_script_edit_puts_the_page_behind(self, two_candidates) -> None:
_packaged, source = two_candidates
ui = source.parent
self._stamp(source / "index.html", 2_000.0)
self._stamp(ui / "src" / "main.tsx", 1_000.0)
self._stamp(ui / "package-lock.json", 3_000.0)

assert serve_commands.page_behind_sources(source) is True

self._stamp(ui / "package-lock.json", 1_000.0)
self._stamp(ui / "build.py", 3_000.0)

assert serve_commands.page_behind_sources(source) is True

def test_the_packaged_copy_is_never_behind(self, two_candidates) -> None:
packaged, source = two_candidates
ui = source.parent
self._stamp(packaged / "index.html", 1_000.0)
self._stamp(ui / "src" / "main.tsx", 3_000.0)

assert serve_commands.page_behind_sources(packaged) is False

def test_no_page_is_not_behind(self, two_candidates) -> None:
_packaged, source = two_candidates

assert serve_commands.page_behind_sources(None) is False
assert serve_commands.page_behind_sources(source) is False

def test_the_resolver_warns_where_the_page_is_behind(self, two_candidates, monkeypatch) -> None:
"""Every path that serves or opens the page resolves it here once, and
the terminal that resolver prints to is where the rebuild happens."""
_packaged, source = two_candidates
ui = source.parent
self._stamp(source / "index.html", 2_000.0)
self._stamp(ui / "src" / "main.tsx", 3_000.0)
said: list[str] = []
from loguru import logger

token = logger.add(lambda m: said.append(m.record["message"]), level="WARNING")
try:
assert serve_commands.resolve_ui_dist() == source
finally:
logger.remove(token)

assert [m for m in said if "make build-ui" in m], said
Loading
Loading