From a90ed763963d1502bc9a3fe8e6bc0f2d6a50a2b0 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:40:15 +0000 Subject: [PATCH 1/4] add a Sandbox app entry point: app file locations, free port, failure page (#118) --- node/pyproject.toml | 1 + node/src/storm_shield/app_files.py | 21 +++++ node/src/storm_shield/region.py | 3 +- node/src/storm_shield/sandbox.py | 47 ++++++---- node/src/storm_shield/sandbox_app.py | 82 +++++++++++++++++ node/src/storm_shield/settings.py | 6 +- node/tests/test_sandbox_app.py | 132 +++++++++++++++++++++++++++ 7 files changed, 270 insertions(+), 22 deletions(-) create mode 100644 node/src/storm_shield/app_files.py create mode 100644 node/src/storm_shield/sandbox_app.py create mode 100644 node/tests/test_sandbox_app.py diff --git a/node/pyproject.toml b/node/pyproject.toml index aef53cb6..5fae047e 100644 --- a/node/pyproject.toml +++ b/node/pyproject.toml @@ -16,6 +16,7 @@ dev = ["pytest", "ruff", "mypy"] [project.scripts] storm-shield-node = "storm_shield.main:run" storm-shield-sandbox = "storm_shield.sandbox:run" +storm-shield-sandbox-app = "storm_shield.sandbox_app:main" [build-system] requires = ["hatchling"] diff --git a/node/src/storm_shield/app_files.py b/node/src/storm_shield/app_files.py new file mode 100644 index 00000000..c2d7ee96 --- /dev/null +++ b/node/src/storm_shield/app_files.py @@ -0,0 +1,21 @@ +"""Where the app's own files live: the built dashboard and the Regions. + +A repo checkout keeps them beside `node/`; a bundled app (PyInstaller) unpacks them under +`sys._MEIPASS`. Everything that needs one of these folders asks here, so the bundle needs no +other change. +""" + +import sys +from pathlib import Path + + +def _app_root() -> Path: + bundle = getattr(sys, "_MEIPASS", None) + if getattr(sys, "frozen", False) and bundle: + return Path(bundle) + return Path(__file__).resolve().parents[3] + + +APP_ROOT = _app_root() +DASHBOARD_DIR = APP_ROOT / "dashboard" / "dist" +REGIONS_DIR = APP_ROOT / "regions" diff --git a/node/src/storm_shield/region.py b/node/src/storm_shield/region.py index 6a05c8ba..1591851d 100644 --- a/node/src/storm_shield/region.py +++ b/node/src/storm_shield/region.py @@ -5,8 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator -# Resolved from this file, not the cwd, so the Node starts from any directory. -REGIONS_DIR = Path(__file__).resolve().parents[3] / "regions" +from storm_shield.app_files import REGIONS_DIR as REGIONS_DIR # noqa: PLC0414 (re-exported: tests and scripts read it here) Hazard = Literal["flash_flood", "storm_surge"] diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index b762d9aa..4e7f30fa 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -20,6 +20,7 @@ from fastapi.testclient import TestClient from starlette.types import Receive, Scope, Send +from storm_shield.app_files import APP_ROOT, DASHBOARD_DIR from storm_shield.cache_pack import CachePack, InvalidCachePack, download_source from storm_shield.community_reading import Position from storm_shield.main import create_app @@ -44,6 +45,7 @@ logger = logging.getLogger(__name__) HUB = "hub" +LOOPBACK_HOST = "127.0.0.1" # A suggested site counts as taken by a Sensor this close: a tap on its marker lands on the pixel, # not the exact coordinate. SITE_TAKEN_WITHIN_M = 50.0 @@ -51,8 +53,8 @@ # it towards the spec's 1 km. SANDBOX_RADIO_RANGE_M = 40_000.0 DOWNLOAD_TIMEOUT_SECONDS = 120 -REPO = Path(__file__).resolve().parents[3] -BUILT_DASHBOARD = REPO / "dashboard" / "dist" / "index.html" +REPO = APP_ROOT +BUILT_DASHBOARD = DASHBOARD_DIR / "index.html" class NodeSite(NamedTuple): @@ -628,21 +630,30 @@ def run() -> None: ) arguments = parser.parse_args() try: - with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client: - cache_pack_url = Settings().cache_pack_url - - def find_pack(region: str) -> Path: - return pack_for(region, client, cache_pack_url) - - pack = arguments.pack or find_pack(arguments.region) - build_dashboard_if_missing() - with Sandbox(pack, arguments.region, find_pack=find_pack) as sandbox: - server = uvicorn.Server(uvicorn.Config(sandbox.hub, port=arguments.port)) - url = f"http://localhost:{arguments.port}" - logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url) - threading.Thread( - target=_open_browser_when_up, args=(server, url), daemon=True - ).start() - server.run() + serve(arguments.port, arguments.region, arguments.pack) except SandboxError as error: raise SystemExit(f"storm-shield-sandbox: {error}") from None + + +def sandbox_server_config(app: Any, port: int) -> uvicorn.Config: + """Always loopback: the Sandbox is for the one person at this computer, and nothing here or + on the command line widens it.""" + return uvicorn.Config(app, host=LOOPBACK_HOST, port=port) + + +def serve(port: int, region: str, pack: Path | None = None) -> None: + """Start the Sandbox on `region`, open the browser once it answers, and serve until stopped.""" + with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client: + cache_pack_url = Settings().cache_pack_url + + def find_pack(region: str) -> Path: + return pack_for(region, client, cache_pack_url) + + pack = pack or find_pack(region) + build_dashboard_if_missing() + with Sandbox(pack, region, find_pack=find_pack) as sandbox: + server = uvicorn.Server(sandbox_server_config(sandbox.hub, port)) + url = f"http://{LOOPBACK_HOST}:{port}" + logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url) + threading.Thread(target=_open_browser_when_up, args=(server, url), daemon=True).start() + server.run() diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py new file mode 100644 index 00000000..3abaf51f --- /dev/null +++ b/node/src/storm_shield/sandbox_app.py @@ -0,0 +1,82 @@ +"""The Sandbox as a double-clickable app: no arguments, and any problem ends up on a page. + +Someone who opens it has no terminal, so it picks the port itself, opens the dashboard of a +Sandbox that is already running instead of starting a second one, logs to a file in the temp +folder and, when it cannot start, opens a page that says why. +""" + +import html +import logging +import socket +import sys +import tempfile +import traceback +import webbrowser +from pathlib import Path + +import httpx + +from storm_shield.sandbox import LOOPBACK_HOST, serve +from storm_shield.settings import Settings + +logger = logging.getLogger(__name__) + +PREFERRED_PORT = 8000 +LOG_FILE_NAME = "storm-shield-sandbox.log" +FAILURE_PAGE_NAME = "storm-shield-sandbox-failed.html" + + +def log_file() -> Path: + return Path(tempfile.gettempdir()) / LOG_FILE_NAME + + +def sandbox_is_running_on(port: int) -> bool: + try: + state = httpx.get(f"http://{LOOPBACK_HOST}:{port}/api/state", timeout=2).json() + return bool(state["sandbox"]) + except (httpx.HTTPError, ValueError, KeyError, TypeError): + return False + + +def _bindable(port: int) -> bool: + with socket.socket() as probe: + try: + probe.bind((LOOPBACK_HOST, port)) + except OSError: + return False + return True + + +def free_port() -> int: + with socket.socket() as probe: + probe.bind((LOOPBACK_HOST, 0)) + return int(probe.getsockname()[1]) + + +def write_failure_page(error: BaseException, log: Path) -> Path: + page = Path(tempfile.gettempdir()) / FAILURE_PAGE_NAME + page.write_text( + "Storm Shield Sandbox could not start" + "

Storm Shield Sandbox could not start

" + f"

{html.escape(str(error) or type(error).__name__)}

" + f"

The full log is at {html.escape(str(log))}

", + encoding="utf-8", + ) + return page + + +def main() -> None: + log = log_file() + logging.basicConfig(level=logging.INFO, filename=log, force=True) + logging.getLogger("httpx").setLevel(logging.WARNING) + try: + if sandbox_is_running_on(PREFERRED_PORT): + webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}") + return + port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port() + serve(port, Settings().region) + # Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page. + except Exception as error: # noqa: BLE001 + logger.error("the Sandbox could not start\n%s", traceback.format_exc()) + webbrowser.open(write_failure_page(error, log).as_uri()) + sys.exit(1) diff --git a/node/src/storm_shield/settings.py b/node/src/storm_shield/settings.py index 0674574e..b50edca7 100644 --- a/node/src/storm_shield/settings.py +++ b/node/src/storm_shield/settings.py @@ -4,6 +4,8 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from storm_shield.app_files import DASHBOARD_DIR + Role = Literal["sensor", "hub"] MeshKind = Literal["reticulum", "memory"] ReticulumInterface = Literal["auto", "loopback", "tcp"] @@ -26,8 +28,8 @@ class Settings(BaseSettings): # Kilometres down the river from the Lhende-Kyirong confluence (negative above it); blank for a # Node that is not on the river, which then cannot start a Community Warning. river_km: float | None = None - # Resolved from this file, not the cwd, so the Node starts from any directory. - dashboard_dir: Path = Path(__file__).resolve().parents[3] / "dashboard" / "dist" + # Resolved from the app's files, not the cwd, so the Node starts from any directory. + dashboard_dir: Path = DASHBOARD_DIR # "memory" is a Node that hears only itself; tests and the dashboard dev server use it. mesh: MeshKind = "reticulum" # "auto" finds Nodes on the local Wi-Fi (the demo). "loopback" joins Nodes on this one machine, diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py new file mode 100644 index 00000000..102c17e8 --- /dev/null +++ b/node/tests/test_sandbox_app.py @@ -0,0 +1,132 @@ +import logging +import sys +import tempfile +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +from storm_shield import app_files, sandbox_app +from storm_shield.sandbox import LOOPBACK_HOST, sandbox_server_config + +_log = logging.getLogger("storm_shield.sandbox_app") + + +class _Answers(BaseHTTPRequestHandler): + body = b'{"sandbox": {"anything": 1}}' + + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + self.wfile.write(self.body) + + def log_message(self, format: str, *args: object) -> None: + pass + + +def _serving(body: bytes) -> tuple[HTTPServer, int]: + handler = type("Handler", (_Answers,), {"body": body}) + server = HTTPServer((LOOPBACK_HOST, 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, int(server.server_address[1]) + + +@pytest.fixture +def opened(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + urls: list[str] = [] + + def record(url: str) -> bool: + urls.append(url) + return True + + monkeypatch.setattr(webbrowser, "open", record) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + return urls + + +@pytest.fixture +def started(monkeypatch: pytest.MonkeyPatch) -> list[int]: + ports: list[int] = [] + monkeypatch.setattr(sandbox_app, "serve", lambda port, region: ports.append(port)) + return ports + + +def _prefer(monkeypatch: pytest.MonkeyPatch, port: int) -> None: + monkeypatch.setattr(sandbox_app, "PREFERRED_PORT", port) + + +def test_a_running_sandbox_is_opened_and_no_second_is_started( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + server, port = _serving(b'{"sandbox": {"nodes": 5}}') + _prefer(monkeypatch, port) + sandbox_app.main() + server.shutdown() + assert opened == [f"http://127.0.0.1:{port}"] + assert started == [] + + +def test_something_else_on_the_port_leaves_the_port_alone_and_serves_elsewhere( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + server, port = _serving(b'{"sandbox": null}') + _prefer(monkeypatch, port) + sandbox_app.main() + server.shutdown() + assert len(started) == 1 and started[0] not in (0, port) + assert opened == [] + + +def test_a_free_preferred_port_is_used( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int] +) -> None: + port = sandbox_app.free_port() + _prefer(monkeypatch, port) + sandbox_app.main() + assert started == [port] + + +def test_a_start_failure_opens_a_page_with_the_error_and_the_logs_path( + monkeypatch: pytest.MonkeyPatch, opened: list[str], tmp_path: Path +) -> None: + def fail(port: int, region: str) -> None: + raise RuntimeError("no here") + + monkeypatch.setattr(sandbox_app, "serve", fail) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + (page_url,) = opened + page = Path(page_url.removeprefix("file://")).read_text() + assert "no <Cache Pack> here" in page + assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page + + +def test_the_app_logs_to_a_file_in_the_temp_folder( + monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int], tmp_path: Path +) -> None: + _prefer(monkeypatch, sandbox_app.free_port()) + monkeypatch.setattr(sandbox_app, "serve", lambda port, region: _log.info("serving")) + sandbox_app.main() + logging.shutdown() + assert "serving" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() + + +def test_the_sandbox_binds_loopback_only() -> None: + config = sandbox_server_config(object(), 8000) + assert config.host == "127.0.0.1" + + +def test_the_dashboard_and_regions_are_found_beside_the_checkout() -> None: + assert (app_files.REGIONS_DIR / "nepal" / "region.json").is_file() + assert app_files.DASHBOARD_DIR == app_files.APP_ROOT / "dashboard" / "dist" + + +def test_a_bundled_app_finds_them_in_its_bundle( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + assert app_files._app_root() == tmp_path From b39668e08b7afed5425be609c38f764807d6973d Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:45:52 +0000 Subject: [PATCH 2/4] keep Cache Pack folders in app_files, catch bind failures, write the failure page to a fresh folder (#118) --- node/AGENTS.md | 1 + node/src/storm_shield/app_files.py | 5 ++++- node/src/storm_shield/sandbox.py | 9 ++++----- node/src/storm_shield/sandbox_app.py | 6 ++++-- node/tests/test_sandbox.py | 5 ++++- node/tests/test_sandbox_app.py | 13 +++++++++++++ 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/node/AGENTS.md b/node/AGENTS.md index 2f060fac..3bf51f76 100644 --- a/node/AGENTS.md +++ b/node/AGENTS.md @@ -32,5 +32,6 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m - **Mesh decode and delivery go through `mesh.decode_message` and `deliver_to_listeners`** for both Meshes; add rules there. - **A Sensor sends an Event's extra `tracks` (made-up Events only) only when `NODE_MEASURES` names the kind**; the default names the flood four, so only the Sandbox (`SANDBOX_MEASURES`) or a Node told so sends `air_temperature`, `humidity`, `wind_speed`. - **A Camera is a Sensor whose `NODE_MEASURES` names `camera`**: each share bumps its picture number and redraws its one in-memory SVG (`camera.py`, fed only a water depth, so nothing about where it is can leak). The Sandbox Hub reads Sensors' pictures in-process at `/api/sandbox/sensors//picture`; `MeshedNodes.record_readings()` hears what is on the Mesh. +- **App file locations live in `app_files.py`** (dashboard, Regions, built and downloaded Cache Packs); a test that needs other packs patches `sandbox.BUILT_PACKS_DIR` / `DOWNLOADED_PACKS_DIR`. `storm-shield-sandbox-app` is the no-argument app start: port 8000 or a free one, log and failure page in the temp folder; uvicorn exits with `SystemExit` when it cannot bind, so the app catches that too. Networking and Pi lessons (firewalls, TCP peers) are in `pi/AGENTS.md`. diff --git a/node/src/storm_shield/app_files.py b/node/src/storm_shield/app_files.py index c2d7ee96..3de37fa7 100644 --- a/node/src/storm_shield/app_files.py +++ b/node/src/storm_shield/app_files.py @@ -1,4 +1,4 @@ -"""Where the app's own files live: the built dashboard and the Regions. +"""Where the app's own files live: the built dashboard, the Regions and the Cache Packs. A repo checkout keeps them beside `node/`; a bundled app (PyInstaller) unpacks them under `sys._MEIPASS`. Everything that needs one of these folders asks here, so the bundle needs no @@ -19,3 +19,6 @@ def _app_root() -> Path: APP_ROOT = _app_root() DASHBOARD_DIR = APP_ROOT / "dashboard" / "dist" REGIONS_DIR = APP_ROOT / "regions" +# Cache Packs: the one a build command wrote, and the one downloaded from a Release on an earlier run. +BUILT_PACKS_DIR = APP_ROOT / "node" / "build" +DOWNLOADED_PACKS_DIR = APP_ROOT / ".demo" / "pack" diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index 4e7f30fa..2ae4c0ff 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -20,7 +20,7 @@ from fastapi.testclient import TestClient from starlette.types import Receive, Scope, Send -from storm_shield.app_files import APP_ROOT, DASHBOARD_DIR +from storm_shield.app_files import APP_ROOT, BUILT_PACKS_DIR, DASHBOARD_DIR, DOWNLOADED_PACKS_DIR from storm_shield.cache_pack import CachePack, InvalidCachePack, download_source from storm_shield.community_reading import Position from storm_shield.main import create_app @@ -53,7 +53,6 @@ # it towards the spec's 1 km. SANDBOX_RADIO_RANGE_M = 40_000.0 DOWNLOAD_TIMEOUT_SECONDS = 120 -REPO = APP_ROOT BUILT_DASHBOARD = DASHBOARD_DIR / "index.html" @@ -560,10 +559,10 @@ def pack_for(region: str, client: httpx.Client, override: str = "") -> Path: """The Region's Cache Pack: the one its build command wrote, else the one fetched from its Release on an earlier run, else the Release itself. Says which command to run when none is there.""" - built = REPO / "node" / "build" / region / "cache-pack.zip" + built = BUILT_PACKS_DIR / region / "cache-pack.zip" if built.is_file(): return built - downloaded = REPO / ".demo" / "pack" / region / "cache-pack.zip" + downloaded = DOWNLOADED_PACKS_DIR / region / "cache-pack.zip" if downloaded.is_file(): return downloaded build_command = f"scripts/build-cache-pack.sh {region}" @@ -596,7 +595,7 @@ def build_dashboard_if_missing() -> None: if BUILT_DASHBOARD.is_file(): return logger.info("building the dashboard (once)") - dashboard = REPO / "dashboard" + dashboard = APP_ROOT / "dashboard" try: for command in (["pnpm", "install", "--frozen-lockfile"], ["pnpm", "build"]): subprocess.run(command, cwd=dashboard, check=True, capture_output=True) diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py index 3abaf51f..62b406e0 100644 --- a/node/src/storm_shield/sandbox_app.py +++ b/node/src/storm_shield/sandbox_app.py @@ -54,7 +54,8 @@ def free_port() -> int: def write_failure_page(error: BaseException, log: Path) -> Path: - page = Path(tempfile.gettempdir()) / FAILURE_PAGE_NAME + # A fresh folder, so a file planted at a predictable name in the shared temp folder is not followed. + page = Path(tempfile.mkdtemp(prefix="storm-shield-")) / FAILURE_PAGE_NAME page.write_text( "Storm Shield Sandbox could not start" "

Storm Shield Sandbox could not start

" @@ -76,7 +77,8 @@ def main() -> None: port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port() serve(port, Settings().region) # Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page. - except Exception as error: # noqa: BLE001 + # uvicorn exits with SystemExit when it cannot bind, which is also a failure to start. + except (Exception, SystemExit) as error: # noqa: BLE001 logger.error("the Sandbox could not start\n%s", traceback.format_exc()) webbrowser.open(write_failure_page(error, log).as_uri()) sys.exit(1) diff --git a/node/tests/test_sandbox.py b/node/tests/test_sandbox.py index 8694a2e5..351c729e 100644 --- a/node/tests/test_sandbox.py +++ b/node/tests/test_sandbox.py @@ -116,7 +116,10 @@ def test_a_node_outside_the_sandbox_reports_no_sandbox(meshed_nodes: MeshedNodes @pytest.fixture def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """An empty checkout, so a test decides which packs are on disk.""" - monkeypatch.setattr(sandbox_module, "REPO", tmp_path / "repo") + monkeypatch.setattr(sandbox_module, "BUILT_PACKS_DIR", tmp_path / "repo" / "node" / "build") + monkeypatch.setattr( + sandbox_module, "DOWNLOADED_PACKS_DIR", tmp_path / "repo" / ".demo" / "pack" + ) return tmp_path / "repo" diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py index 102c17e8..a689f834 100644 --- a/node/tests/test_sandbox_app.py +++ b/node/tests/test_sandbox_app.py @@ -104,6 +104,19 @@ def fail(port: int, region: str) -> None: assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page +def test_a_port_that_cannot_be_bound_still_opens_the_failure_page( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + def cannot_bind(port: int, region: str) -> None: + raise SystemExit(1) + + monkeypatch.setattr(sandbox_app, "serve", cannot_bind) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + assert len(opened) == 1 + + def test_the_app_logs_to_a_file_in_the_temp_folder( monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int], tmp_path: Path ) -> None: From 0c0b29963611cda13cb839a41ef35f527d919d35 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:49:16 +0000 Subject: [PATCH 3/4] Sandbox app: failures at import and bind reach the page, uvicorn logs to the file, README URL (#118) --- README.md | 2 +- node/src/storm_shield/sandbox.py | 6 ++- node/src/storm_shield/sandbox_app.py | 42 +++++++++++++--- node/tests/test_sandbox_app.py | 75 +++++++++++++++++++++++++--- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ad866e04..58ec3384 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ A local-first community sensor network: Nodes share readings over a local Mesh t scripts/sandbox.sh # needs uv and pnpm; the first run downloads the Cache Pack and builds the dashboard ``` -It opens `http://localhost:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border". +It opens `http://127.0.0.1:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border". ## How it's built diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index 2ae4c0ff..0a164d2f 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -40,12 +40,12 @@ SuggestedSite, UnknownSensor, ) +from storm_shield.sandbox_app import LOOPBACK_HOST from storm_shield.settings import Settings logger = logging.getLogger(__name__) HUB = "hub" -LOOPBACK_HOST = "127.0.0.1" # A suggested site counts as taken by a Sensor this close: a tap on its marker lands on the pixel, # not the exact coordinate. SITE_TAKEN_WITHIN_M = 50.0 @@ -637,7 +637,9 @@ def run() -> None: def sandbox_server_config(app: Any, port: int) -> uvicorn.Config: """Always loopback: the Sandbox is for the one person at this computer, and nothing here or on the command line widens it.""" - return uvicorn.Config(app, host=LOOPBACK_HOST, port=port) + # No log_config: uvicorn's own sends its lines to stderr, which the app has no terminal for; + # without it they reach the root logger, and so the app's log file. + return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None, access_log=False) def serve(port: int, region: str, pack: Path | None = None) -> None: diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py index 62b406e0..5d540d38 100644 --- a/node/src/storm_shield/sandbox_app.py +++ b/node/src/storm_shield/sandbox_app.py @@ -16,11 +16,11 @@ import httpx -from storm_shield.sandbox import LOOPBACK_HOST, serve from storm_shield.settings import Settings logger = logging.getLogger(__name__) +LOOPBACK_HOST = "127.0.0.1" PREFERRED_PORT = 8000 LOG_FILE_NAME = "storm-shield-sandbox.log" FAILURE_PAGE_NAME = "storm-shield-sandbox-failed.html" @@ -53,13 +53,31 @@ def free_port() -> int: return int(probe.getsockname()[1]) -def write_failure_page(error: BaseException, log: Path) -> Path: +class _LastServerError(logging.Handler): + """Remembers uvicorn's last error line: when it cannot bind it exits with just a number, and + the reason ("address already in use") is only in what it logged.""" + + def __init__(self) -> None: + super().__init__(level=logging.ERROR) + self.message = "" + + def emit(self, record: logging.LogRecord) -> None: + self.message = record.getMessage() + + +def _reason(error: BaseException, server_error: str) -> str: + if isinstance(error, SystemExit): + return server_error or f"the server stopped while starting (exit code {error.code})" + return str(error) or type(error).__name__ + + +def write_failure_page(reason: str, log: Path) -> Path: # A fresh folder, so a file planted at a predictable name in the shared temp folder is not followed. page = Path(tempfile.mkdtemp(prefix="storm-shield-")) / FAILURE_PAGE_NAME page.write_text( "Storm Shield Sandbox could not start" "

Storm Shield Sandbox could not start

" - f"

{html.escape(str(error) or type(error).__name__)}

" + f"

{html.escape(reason)}

" f"

The full log is at {html.escape(str(log))}

", encoding="utf-8", ) @@ -70,15 +88,27 @@ def main() -> None: log = log_file() logging.basicConfig(level=logging.INFO, filename=log, force=True) logging.getLogger("httpx").setLevel(logging.WARNING) + last_server_error = _LastServerError() + logging.getLogger("uvicorn.error").addHandler(last_server_error) + port = PREFERRED_PORT + # Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page. + # That includes importing the Sandbox, which reads its Region, and uvicorn exiting with + # SystemExit when it cannot bind. try: if sandbox_is_running_on(PREFERRED_PORT): webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}") return + from storm_shield.sandbox import serve + port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port() serve(port, Settings().region) - # Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page. - # uvicorn exits with SystemExit when it cannot bind, which is also a failure to start. except (Exception, SystemExit) as error: # noqa: BLE001 logger.error("the Sandbox could not start\n%s", traceback.format_exc()) - webbrowser.open(write_failure_page(error, log).as_uri()) + # Two quick launches both find 8000 free and the slower one loses the bind: the winner is + # the Sandbox to open. + if port == PREFERRED_PORT and sandbox_is_running_on(PREFERRED_PORT): + webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}") + return + reason = _reason(error, last_server_error.message) + webbrowser.open(write_failure_page(reason, log).as_uri()) sys.exit(1) diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py index a689f834..2ae1da59 100644 --- a/node/tests/test_sandbox_app.py +++ b/node/tests/test_sandbox_app.py @@ -1,4 +1,6 @@ import logging +import os +import subprocess import sys import tempfile import threading @@ -9,7 +11,9 @@ import pytest from storm_shield import app_files, sandbox_app -from storm_shield.sandbox import LOOPBACK_HOST, sandbox_server_config +from storm_shield import sandbox as sandbox_module +from storm_shield.sandbox import sandbox_server_config +from storm_shield.sandbox_app import LOOPBACK_HOST _log = logging.getLogger("storm_shield.sandbox_app") @@ -49,7 +53,7 @@ def record(url: str) -> bool: @pytest.fixture def started(monkeypatch: pytest.MonkeyPatch) -> list[int]: ports: list[int] = [] - monkeypatch.setattr(sandbox_app, "serve", lambda port, region: ports.append(port)) + monkeypatch.setattr(sandbox_module, "serve", lambda port, region: ports.append(port)) return ports @@ -94,7 +98,7 @@ def test_a_start_failure_opens_a_page_with_the_error_and_the_logs_path( def fail(port: int, region: str) -> None: raise RuntimeError("no here") - monkeypatch.setattr(sandbox_app, "serve", fail) + monkeypatch.setattr(sandbox_module, "serve", fail) _prefer(monkeypatch, sandbox_app.free_port()) with pytest.raises(SystemExit): sandbox_app.main() @@ -110,18 +114,76 @@ def test_a_port_that_cannot_be_bound_still_opens_the_failure_page( def cannot_bind(port: int, region: str) -> None: raise SystemExit(1) - monkeypatch.setattr(sandbox_app, "serve", cannot_bind) + monkeypatch.setattr(sandbox_module, "serve", cannot_bind) _prefer(monkeypatch, sandbox_app.free_port()) with pytest.raises(SystemExit): sandbox_app.main() - assert len(opened) == 1 + (page_url,) = opened + assert "exit code 1" in Path(page_url.removeprefix("file://")).read_text() + + +def test_a_bind_lost_to_a_sandbox_that_just_started_opens_that_sandbox( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + server, port = _serving(b'{"sandbox": {"nodes": 5}}') + _prefer(monkeypatch, port) + answers = iter([False, True]) + monkeypatch.setattr(sandbox_app, "sandbox_is_running_on", lambda _port: next(answers)) + monkeypatch.setattr(sandbox_app, "_bindable", lambda _port: True) + + def lost_the_race(port: int, region: str) -> None: + raise SystemExit(3) + + monkeypatch.setattr(sandbox_module, "serve", lost_the_race) + sandbox_app.main() + server.shutdown() + assert opened == [f"http://127.0.0.1:{port}"] + + +def test_the_servers_own_reason_for_not_binding_is_on_the_page( + monkeypatch: pytest.MonkeyPatch, opened: list[str] +) -> None: + def cannot_bind(port: int, region: str) -> None: + logging.getLogger("uvicorn.error").error("address already in use") + raise SystemExit(3) + + monkeypatch.setattr(sandbox_module, "serve", cannot_bind) + _prefer(monkeypatch, sandbox_app.free_port()) + with pytest.raises(SystemExit): + sandbox_app.main() + (page_url,) = opened + assert "address already in use" in Path(page_url.removeprefix("file://")).read_text() + + +def test_a_region_that_cannot_be_read_opens_the_failure_page_through_the_real_entry_point( + tmp_path: Path, +) -> None: + opened_url = tmp_path / "opened-url" + program = ( + "import webbrowser\n" + f"webbrowser.open = lambda url: open({str(opened_url)!r}, 'w').write(url)\n" + "from storm_shield import sandbox_app\n" + "sandbox_app.main()\n" + ) + environment = {**os.environ, "NODE_REGION": "nowhere", "TMPDIR": str(tmp_path)} + result = subprocess.run( + [sys.executable, "-c", program], + check=False, + env=environment, + capture_output=True, + timeout=60, + ) + assert result.returncode == 1 + page = Path(opened_url.read_text().removeprefix("file://")).read_text() + assert "nowhere" in page + assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page def test_the_app_logs_to_a_file_in_the_temp_folder( monkeypatch: pytest.MonkeyPatch, opened: list[str], started: list[int], tmp_path: Path ) -> None: _prefer(monkeypatch, sandbox_app.free_port()) - monkeypatch.setattr(sandbox_app, "serve", lambda port, region: _log.info("serving")) + monkeypatch.setattr(sandbox_module, "serve", lambda port, region: _log.info("serving")) sandbox_app.main() logging.shutdown() assert "serving" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() @@ -130,6 +192,7 @@ def test_the_app_logs_to_a_file_in_the_temp_folder( def test_the_sandbox_binds_loopback_only() -> None: config = sandbox_server_config(object(), 8000) assert config.host == "127.0.0.1" + assert config.log_config is None def test_the_dashboard_and_regions_are_found_beside_the_checkout() -> None: From 6f3f687969a12bafbaaebdfc7b501ad6fdd43b54 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:58:48 +0000 Subject: [PATCH 4/4] Move LOOPBACK_HOST to settings, keep uvicorn access log, assert log file in app test (#118) --- node/src/storm_shield/sandbox.py | 5 ++--- node/src/storm_shield/sandbox_app.py | 3 +-- node/src/storm_shield/settings.py | 3 +++ node/tests/test_sandbox_app.py | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index 0a164d2f..5be584f0 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -40,8 +40,7 @@ SuggestedSite, UnknownSensor, ) -from storm_shield.sandbox_app import LOOPBACK_HOST -from storm_shield.settings import Settings +from storm_shield.settings import LOOPBACK_HOST, Settings logger = logging.getLogger(__name__) @@ -639,7 +638,7 @@ def sandbox_server_config(app: Any, port: int) -> uvicorn.Config: on the command line widens it.""" # No log_config: uvicorn's own sends its lines to stderr, which the app has no terminal for; # without it they reach the root logger, and so the app's log file. - return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None, access_log=False) + return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None) def serve(port: int, region: str, pack: Path | None = None) -> None: diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py index 5d540d38..fd1de799 100644 --- a/node/src/storm_shield/sandbox_app.py +++ b/node/src/storm_shield/sandbox_app.py @@ -16,11 +16,10 @@ import httpx -from storm_shield.settings import Settings +from storm_shield.settings import LOOPBACK_HOST, Settings logger = logging.getLogger(__name__) -LOOPBACK_HOST = "127.0.0.1" PREFERRED_PORT = 8000 LOG_FILE_NAME = "storm-shield-sandbox.log" FAILURE_PAGE_NAME = "storm-shield-sandbox-failed.html" diff --git a/node/src/storm_shield/settings.py b/node/src/storm_shield/settings.py index b50edca7..2cea3a14 100644 --- a/node/src/storm_shield/settings.py +++ b/node/src/storm_shield/settings.py @@ -6,6 +6,9 @@ from storm_shield.app_files import DASHBOARD_DIR +# The Sandbox listens here and nowhere wider: it is for the one person at this computer. +LOOPBACK_HOST = "127.0.0.1" + Role = Literal["sensor", "hub"] MeshKind = Literal["reticulum", "memory"] ReticulumInterface = Literal["auto", "loopback", "tcp"] diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py index 2ae1da59..2c3f8ed7 100644 --- a/node/tests/test_sandbox_app.py +++ b/node/tests/test_sandbox_app.py @@ -13,7 +13,7 @@ from storm_shield import app_files, sandbox_app from storm_shield import sandbox as sandbox_module from storm_shield.sandbox import sandbox_server_config -from storm_shield.sandbox_app import LOOPBACK_HOST +from storm_shield.settings import LOOPBACK_HOST _log = logging.getLogger("storm_shield.sandbox_app") @@ -177,6 +177,7 @@ def test_a_region_that_cannot_be_read_opens_the_failure_page_through_the_real_en page = Path(opened_url.read_text().removeprefix("file://")).read_text() assert "nowhere" in page assert str(tmp_path / sandbox_app.LOG_FILE_NAME) in page + assert "nowhere" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() def test_the_app_logs_to_a_file_in_the_temp_folder(