From b1a83853ce565573f030134b81bdc22d432f3d12 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 02:29:05 +0000 Subject: [PATCH 1/3] Stop the Sandbox app from its panel, and end it after ten idle minutes (#119) --- dashboard/src/App.tsx | 19 +++ dashboard/src/SandboxPanel.tsx | 8 ++ dashboard/src/cameraPictureFlow.test.tsx | 2 +- dashboard/src/nodeState.ts | 3 +- dashboard/src/replayControlsFlow.test.tsx | 4 +- dashboard/src/sandboxPanelFlow.test.tsx | 44 ++++++- dashboard/src/useSandboxNodes.ts | 14 +++ node/src/storm_shield/main.py | 20 +++- node/src/storm_shield/sandbox.py | 62 ++++++++-- node/src/storm_shield/sandbox_api.py | 27 +++++ node/src/storm_shield/sandbox_app.py | 2 +- node/tests/test_sandbox.py | 139 +++++++++++++++++++++- node/tests/test_sandbox_app.py | 14 ++- 13 files changed, 337 insertions(+), 21 deletions(-) diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2f424c29..392be4a0 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -99,7 +99,9 @@ export function App() { removeSensor, setSensorKinds, switchRegion, + stopSandbox, } = useSandboxNodes(state?.sandbox != null, refresh); + const [sandboxStopped, setSandboxStopped] = useState(false); const [sandboxPanelOpen, setSandboxPanelOpen] = useState(true); const [placingSensor, setPlacingSensor] = useState(false); const eventClock = useEventClock(!unreachable); @@ -273,6 +275,15 @@ export function App() { sandbox={sandbox} regions={regions} onSwitchRegion={switchRegion} + onStop={ + state?.sandbox?.can_stop + ? () => + void stopSandbox().then( + () => setSandboxStopped(true), + () => {}, + ) + : null + } problem={problem} onSetRadioRange={setRadioRange} placing={placingSensor} @@ -341,6 +352,14 @@ export function App() { } : null; const selectedNode = nodes.find((node) => node.id === selectedId); + if (sandboxStopped) { + return ( +
+

Storm Shield Sandbox has stopped

+

It is safe to close this tab.

+
+ ); + } return (
diff --git a/dashboard/src/SandboxPanel.tsx b/dashboard/src/SandboxPanel.tsx index 62132e7c..0f4b2e63 100644 --- a/dashboard/src/SandboxPanel.tsx +++ b/dashboard/src/SandboxPanel.tsx @@ -14,6 +14,8 @@ type Props = { sandbox: SandboxNodes; regions: SandboxRegions | null; onSwitchRegion: (name: string) => Promise; + // Present only in the app, the one Sandbox a Stop button can end. + onStop: (() => void) | null; problem: string | null; onSetRadioRange: (metres: number) => Promise; // "Add Sensor" arms the map: the next tap places a Sensor. Pressing it again disarms. @@ -42,6 +44,7 @@ export function SandboxPanel({ sandbox, regions, onSwitchRegion, + onStop, problem, onSetRadioRange, placing, @@ -94,6 +97,11 @@ export function SandboxPanel({ > Add Sensor + {onStop && ( + + )} {problem &&

{problem}

}
    diff --git a/dashboard/src/cameraPictureFlow.test.tsx b/dashboard/src/cameraPictureFlow.test.tsx index aee3d6b2..8b835533 100644 --- a/dashboard/src/cameraPictureFlow.test.tsx +++ b/dashboard/src/cameraPictureFlow.test.tsx @@ -13,7 +13,7 @@ function stubTheNode({ sandbox }: { sandbox: boolean }) { nepalNodeState({ id: "porch", name: "porch", - sandbox: sandbox ? { label: "SIMULATED" } : null, + sandbox: sandbox ? { label: "SIMULATED", can_stop: false } : null, nodes: [ fakeNodeSnapshot("porch", { measurements: { camera: { value: pictureNumber, unit: "pic" } }, diff --git a/dashboard/src/nodeState.ts b/dashboard/src/nodeState.ts index 8a6de425..fb1b7038 100644 --- a/dashboard/src/nodeState.ts +++ b/dashboard/src/nodeState.ts @@ -94,7 +94,8 @@ export type NodeState = { status: NodeStatus; registration: Registration; // Present only when the Node is the Sandbox's Hub; nothing in the Sandbox is a real reading. - sandbox: { label: "SIMULATED" } | null; + // `can_stop` is true only in the double-click app, whose Panel offers Stop. + sandbox: { label: "SIMULATED"; can_stop: boolean } | null; region: Region; hazard: Hazard; // The Node's own verdict that real Sensors are reporting; the dashboard never re-derives it. diff --git a/dashboard/src/replayControlsFlow.test.tsx b/dashboard/src/replayControlsFlow.test.tsx index 2314ab58..cd91385b 100644 --- a/dashboard/src/replayControlsFlow.test.tsx +++ b/dashboard/src/replayControlsFlow.test.tsx @@ -32,7 +32,7 @@ const MADE_UP_PHASES = [{ id: "only", label: "The only phase", event_t: REPLAY_S let replay: object; let events: object | null; -let sandbox: { label: "SIMULATED" } | null; +let sandbox: { label: "SIMULATED"; can_stop: false } | null; let posted: { path: string; body: unknown }[]; let fetched: string[]; @@ -227,7 +227,7 @@ test("a running Event is labelled REPLAY beside its clock", async () => { test("the Sandbox Panel holds the same Replay controls", async () => { events = EVENTS; - sandbox = { label: "SIMULATED" }; + sandbox = { label: "SIMULATED", can_stop: false }; render(); const panel = await screen.findByRole("region", { name: "Sandbox" }); const picker = await within(panel).findByRole("combobox", { name: "Event" }); diff --git a/dashboard/src/sandboxPanelFlow.test.tsx b/dashboard/src/sandboxPanelFlow.test.tsx index ccdd91e3..e9ea1cc9 100644 --- a/dashboard/src/sandboxPanelFlow.test.tsx +++ b/dashboard/src/sandboxPanelFlow.test.tsx @@ -79,6 +79,10 @@ function stubTheHub(options: { heardNodes?: boolean; // What the Hub answers when asked to switch Region: a message refuses with it. regionRefusal?: string; + // The app can be stopped from its Panel; the Sandbox from a checkout cannot. + canStop?: boolean; + // What the Hub answers to Stop: a message refuses with it. + stopRefusal?: string; }) { posted = []; sent = []; @@ -107,6 +111,13 @@ function stubTheHub(options: { current = answer; return Response.json(current); } + if (path === "/api/sandbox/stop" && init?.method === "POST") { + posted.push({ path, body: undefined }); + if (options.stopRefusal) { + return Response.json({ detail: options.stopRefusal }, { status: 403 }); + } + return Response.json({ stopping: true }); + } if (path === "/api/sandbox/regions") return Response.json(REGIONS); if (path === "/api/sandbox/region" && init?.method === "POST") { const body = JSON.parse(init.body as string); @@ -121,7 +132,9 @@ function stubTheHub(options: { nepalNodeState({ id: "hub", name: "hub", - sandbox: options.sandbox ? { label: "SIMULATED" } : null, + sandbox: options.sandbox + ? { label: "SIMULATED", can_stop: options.canStop ?? false } + : null, // Where the readings say, which is not quite where the Node is. nodes: options.heardNodes ? WIDE.nodes.map((each) => @@ -526,3 +539,32 @@ test("a refused kind shows the Hub's sentence", async () => { "Could not change the Sensor's kinds: the Hub said no", ); }); + +test("the app's Panel has a Stop button, and after it the page says it is safe to close", async () => { + stubTheHub({ sandbox: true, canStop: true }); + const panel = await sandboxPanel(); + fireEvent.click(await within(panel).findByRole("button", { name: "Stop Sandbox" })); + expect(await screen.findByText("It is safe to close this tab.")).toBeInTheDocument(); + expect(posted).toEqual([{ path: "/api/sandbox/stop", body: undefined }]); +}); + +test("a Stop the Hub refuses says so and leaves the dashboard up", async () => { + stubTheHub({ + sandbox: true, + canStop: true, + stopRefusal: "only this Sandbox's own page can stop it", + }); + const panel = await sandboxPanel(); + fireEvent.click(await within(panel).findByRole("button", { name: "Stop Sandbox" })); + expect(await within(panel).findByRole("alert")).toHaveTextContent( + "Could not stop the Sandbox: only this Sandbox's own page can stop it", + ); + expect(screen.queryByText("It is safe to close this tab.")).not.toBeInTheDocument(); +}); + +test("outside the app the Panel has no Stop button", async () => { + stubTheHub({ sandbox: true }); + const panel = await sandboxPanel(); + await within(panel).findAllByRole("listitem"); + expect(within(panel).queryByRole("button", { name: "Stop Sandbox" })).not.toBeInTheDocument(); +}); diff --git a/dashboard/src/useSandboxNodes.ts b/dashboard/src/useSandboxNodes.ts index 275080aa..1446fcd5 100644 --- a/dashboard/src/useSandboxNodes.ts +++ b/dashboard/src/useSandboxNodes.ts @@ -49,6 +49,8 @@ export type SandboxFeed = { // Declares `kinds` on a Sensor that stays where it is. setSensorKinds: (id: string, position: [number, number], kinds: string[]) => Promise; switchRegion: (name: string) => Promise; + // Ends the Sandbox app; resolves once it has said it is stopping. + stopSandbox: () => Promise; }; async function ask(path: string, init?: RequestInit): Promise { @@ -174,11 +176,23 @@ export function useSandboxNodes(inSandbox: boolean, onRegionSwitched: () => void [onRegionSwitched], ); + const stopSandbox = useCallback(async () => { + setProblem(null); + try { + await ask("/api/sandbox/stop", sendJson("POST")); + } catch (error) { + console.error(error); + setProblem(`Could not stop the Sandbox: ${(error as Error).message}`); + throw error; + } + }, []); + return { sandbox: inSandbox ? sandbox : null, regions: inSandbox ? regions : null, problem, switchRegion, + stopSandbox, setRadioRange, addSensor, moveSensor, diff --git a/node/src/storm_shield/main.py b/node/src/storm_shield/main.py index 31e7f302..de44a72e 100644 --- a/node/src/storm_shield/main.py +++ b/node/src/storm_shield/main.py @@ -81,6 +81,7 @@ SandboxRegions, SensorChange, UnknownSensor, + is_from_this_machine, ) from storm_shield.settings import Role, Settings from storm_shield.shelter_status import ( @@ -111,6 +112,8 @@ class SandboxState(BaseModel): """Present in `/api/state` only in the Sandbox, where nothing is a real reading.""" label: Literal["SIMULATED"] = "SIMULATED" + # Whether the Panel may offer a Stop button: only the double-click app can be stopped. + can_stop: bool = False class OfficialGaugeState(BaseModel): @@ -781,7 +784,7 @@ def node_state() -> NodeState: role=settings.role, status=internet.status, registration=registration(), - sandbox=SandboxState() if sandbox else None, + sandbox=SandboxState(can_stop=sandbox.can_stop()) if sandbox else None, region=region.summary(), hazard=region.hazard, live=full_snapshot.is_live(registered_ids), @@ -1020,6 +1023,21 @@ def jump_replay(jump: ReplayJump) -> ReplayClockMessage: event, "paused" if keeps_paused else "playing", event.event_t_at(phase.at) ) + if sandbox is not None and sandbox.can_stop(): + + @app.post("/api/sandbox/stop") + def stop_sandbox(request: Request) -> dict[str, bool]: + if not is_from_this_machine( + request.client.host if request.client else None, + request.headers.get("host"), + request.headers.get("origin"), + ): + raise HTTPException( + status_code=403, detail="only this Sandbox's own page can stop it" + ) + sandbox.stop() + return {"stopping": True} + if sandbox is not None: @app.get("/api/sandbox/nodes") diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index 5be584f0..93eb94e0 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -8,7 +8,7 @@ import webbrowser from collections.abc import Callable from contextlib import ExitStack -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from tempfile import TemporaryDirectory from types import TracebackType @@ -53,6 +53,9 @@ SANDBOX_RADIO_RANGE_M = 40_000.0 DOWNLOAD_TIMEOUT_SECONDS = 120 BUILT_DASHBOARD = DASHBOARD_DIR / "index.html" +# Browsers poll `/api/state` even from a background tab, so ten minutes of silence means none is open. +APP_IDLE_LIMIT = timedelta(minutes=10) +APP_WATCH_SECONDS = 1.0 class NodeSite(NamedTuple): @@ -173,10 +176,14 @@ class _Switchboard: without restarting the server. The Hub's own loops run in its test client, so this answers the server's lifespan itself.""" - def __init__(self) -> None: + def __init__(self, clock: Callable[[], datetime]) -> None: self.app: FastAPI + self._clock = clock + self.last_state_request = clock() async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and scope["path"] == "/api/state": + self.last_state_request = self._clock() if scope["type"] != "lifespan": await self.app(scope, receive, send) return @@ -198,6 +205,10 @@ class Sandbox: Switching Region starts a whole new Mesh from that Region's pack, found by `find_pack`, and swaps it in under `hub`; the old one is closed once the new one is up. + + As the app (`app=True`) it can be stopped from its Panel, which sets `stopped`, and it counts as + `is_idle` once no dashboard has asked for `/api/state` for ten minutes; the caller serving it + watches both and ends the server, which leaves the `with` and removes the folder. """ def __init__( @@ -206,16 +217,24 @@ def __init__( region: str | None = None, *, find_pack: Callable[[str], Path] | None = None, + app: bool = False, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), ) -> None: _require_pack(pack) + self._app = app + self._clock = clock + self.stopped = threading.Event() self._first = (region or Settings().region, pack) self._find_pack = find_pack # Shared with the Hub's controls, so a Sensor is never edited on a Mesh that is being replaced. self._lock = threading.RLock() self._stack = ExitStack() - self.hub = _Switchboard() + self.hub = _Switchboard(clock) self.network: InMemoryNetwork + def is_idle(self) -> bool: + return self._app and self._clock() - self.hub.last_state_request > APP_IDLE_LIMIT + def __enter__(self) -> Self: self._stack, self.hub.app, self.network = self._start(*self._first) return self @@ -236,7 +255,9 @@ def _start(self, region: str, pack: Path) -> tuple[ExitStack, FastAPI, InMemoryN directory = Path( setup.enter_context(TemporaryDirectory(prefix="storm-shield-sandbox-")) ) - nodes = SimulatedNodes(directory, radio_range_m=SANDBOX_RADIO_RANGE_M) + nodes = SimulatedNodes( + directory, radio_range_m=SANDBOX_RADIO_RANGE_M, clock=self._clock + ) setup.callback(nodes.close) hub_client = self._start_hub( nodes, hub_site, region, pack, pack_bytes, len(sensor_sites) @@ -286,6 +307,7 @@ def _start_hub( sensors_placed, self._switch_region, self._lock, + self.stopped.set if self._app else None, ), ) @@ -325,8 +347,10 @@ def __init__( sensors_placed: int, switch_region: Callable[[str], None], lock: threading.RLock, + stop: Callable[[], None] | None, ) -> None: self._nodes = nodes + self._stop = stop self._pack = pack self._river = river self._region = region @@ -346,6 +370,13 @@ def __init__( # Sensors with no path to the Hub when they were placed: it cannot hear them sign up. self._unregistered: set[str] = set() + def can_stop(self) -> bool: + return self._stop is not None + + def stop(self) -> None: + if self._stop is not None: + self._stop() + def nodes(self) -> SandboxNodes: with self._lock: radio_range_m = self._nodes.network.radio_range_m @@ -641,8 +672,21 @@ def sandbox_server_config(app: Any, port: int) -> uvicorn.Config: return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None) -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.""" +def _end_when_stopped_or_idle(sandbox: Sandbox, server: uvicorn.Server) -> None: + while not server.should_exit: + if sandbox.stopped.wait(APP_WATCH_SECONDS): + logger.info("the Sandbox was stopped from its Panel") + break + if sandbox.is_idle(): + logger.info("no dashboard has asked for the state for %s: ending", APP_IDLE_LIMIT) + break + server.should_exit = True + + +def serve(port: int, region: str, pack: Path | None = None, *, app: bool = False) -> None: + """Start the Sandbox on `region`, open the browser once it answers, and serve until stopped. + + As the app it can also be stopped from its Panel and ends itself when left idle.""" with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client: cache_pack_url = Settings().cache_pack_url @@ -651,9 +695,13 @@ def find_pack(region: str) -> Path: pack = pack or find_pack(region) build_dashboard_if_missing() - with Sandbox(pack, region, find_pack=find_pack) as sandbox: + with Sandbox(pack, region, find_pack=find_pack, app=app) 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() + if app: + threading.Thread( + target=_end_when_stopped_or_idle, args=(sandbox, server), daemon=True + ).start() server.run() diff --git a/node/src/storm_shield/sandbox_api.py b/node/src/storm_shield/sandbox_api.py index 89120581..538caf61 100644 --- a/node/src/storm_shield/sandbox_api.py +++ b/node/src/storm_shield/sandbox_api.py @@ -1,3 +1,4 @@ +import ipaddress from typing import Protocol from pydantic import BaseModel, Field @@ -78,9 +79,35 @@ class SandboxRefused(Exception): """The Sandbox cannot do this; the message says why.""" +LOOPBACK_NAMES = {"127.0.0.1", "localhost", "[::1]"} + + +def is_from_this_machine(client_host: str | None, host: str | None, origin: str | None) -> bool: + """Whether a request to end the Sandbox came from a page of its own, on this computer. + + The client must be on loopback and the `Host` a loopback name, so a page that reached + loopback by rebinding its own domain is refused. An `Origin`, which a browser sends on every + cross-site POST, must be this Sandbox's own; a request without one is not from a browser page. + """ + try: + if client_host is None or not ipaddress.ip_address(client_host).is_loopback: + return False + except ValueError: + return False + if host is None or host.rsplit(":", 1)[0] not in LOOPBACK_NAMES: + return False + return origin is None or origin == f"http://{host}" + + class SandboxControls(Protocol): """What the Sandbox lets its Hub's dashboard see and change about the simulated Mesh.""" + def can_stop(self) -> bool: + """Whether this Sandbox is the app, which a Stop button in its Panel may end.""" + ... + + def stop(self) -> None: ... + def nodes(self) -> SandboxNodes: ... def set_radio_range(self, radio_range_m: float) -> SandboxNodes: ... diff --git a/node/src/storm_shield/sandbox_app.py b/node/src/storm_shield/sandbox_app.py index fd1de799..6e5c0bd9 100644 --- a/node/src/storm_shield/sandbox_app.py +++ b/node/src/storm_shield/sandbox_app.py @@ -100,7 +100,7 @@ def main() -> None: from storm_shield.sandbox import serve port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port() - serve(port, Settings().region) + serve(port, Settings().region, app=True) except (Exception, SystemExit) as error: # noqa: BLE001 logger.error("the Sandbox could not start\n%s", traceback.format_exc()) # Two quick launches both find 8000 free and the slower one loses the bind: the winner is diff --git a/node/tests/test_sandbox.py b/node/tests/test_sandbox.py index 351c729e..a7457148 100644 --- a/node/tests/test_sandbox.py +++ b/node/tests/test_sandbox.py @@ -1,5 +1,7 @@ import tempfile +import threading import time +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -22,6 +24,7 @@ pack_for, sites_of, ) +from storm_shield.sandbox_app import free_port # On the bay shore, where the Event's water arrives first, and on a hill inland that it never reaches. BY_THE_BAY = {"latitude": 26.4507, "longitude": -81.9533} @@ -68,7 +71,7 @@ def test_every_sensor_is_registered_and_its_reading_is_in_the_hubs_state( with Sandbox(pack) as sandbox: state = TestClient(sandbox.hub).get("/api/state").json() - assert state["sandbox"] == {"label": "SIMULATED"} + assert state["sandbox"] == {"label": "SIMULATED", "can_stop": False} nodes = {node["id"]: node for node in state["nodes"]} for site in SENSOR_SITES: assert nodes[site.name]["registered"] is True @@ -669,3 +672,137 @@ def test_a_node_outside_the_sandbox_has_no_picture_pass_through(meshed_nodes: Me meshed_nodes.start_hub() assert meshed_nodes["hub"].get("/api/sandbox/sensors/s0/picture").status_code == 404 + + +IDLE_LIMIT = timedelta(minutes=10) +LOOPBACK_CLIENT = ("127.0.0.1", 50000) +HERE = "http://127.0.0.1:8000" + + +class _FakeClock: + def __init__(self) -> None: + self.now = datetime(2026, 8, 26, 6, 0, tzinfo=UTC) + + def __call__(self) -> datetime: + return self.now + + +def _app( + sandbox: Sandbox, *, client: tuple[str, int] = LOOPBACK_CLIENT, host: str = "127.0.0.1:8000" +) -> TestClient: + return TestClient(sandbox.hub, client=client, base_url=f"http://{host}") + + +def test_stop_ends_the_app_and_its_state_says_the_panel_may_offer_it( + pack: Path, scratch: Path +) -> None: + with Sandbox(pack, app=True) as sandbox: + assert _app(sandbox).get("/api/state").json()["sandbox"]["can_stop"] is True + assert not sandbox.stopped.is_set() + + response = _app(sandbox).post("/api/sandbox/stop", headers={"Origin": HERE}) + + assert response.status_code == 200 + assert sandbox.stopped.is_set() + assert list(scratch.iterdir()) == [] + + +def test_stop_refuses_a_foreign_origin(pack: Path, scratch: Path) -> None: + with Sandbox(pack, app=True) as sandbox: + for origin in ("https://evil.example", "http://127.0.0.1:9999", "null"): + response = _app(sandbox).post("/api/sandbox/stop", headers={"Origin": origin}) + assert response.status_code == 403 + assert not sandbox.stopped.is_set() + + +def test_stop_refuses_a_page_that_reached_loopback_under_another_name( + pack: Path, scratch: Path +) -> None: + with Sandbox(pack, app=True) as sandbox: + response = _app(sandbox, host="evil.example:8000").post( + "/api/sandbox/stop", headers={"Origin": "http://evil.example:8000"} + ) + assert response.status_code == 403 + assert not sandbox.stopped.is_set() + + +def test_stop_refuses_a_client_that_is_not_on_loopback(pack: Path, scratch: Path) -> None: + with Sandbox(pack, app=True) as sandbox: + response = _app(sandbox, client=("192.168.1.20", 50000)).post( + "/api/sandbox/stop", headers={"Origin": HERE} + ) + assert response.status_code == 403 + assert not sandbox.stopped.is_set() + + +def test_outside_the_app_there_is_no_stop(pack: Path, scratch: Path) -> None: + with Sandbox(pack) as sandbox: + assert _app(sandbox).get("/api/state").json()["sandbox"]["can_stop"] is False + response = _app(sandbox).post("/api/sandbox/stop", headers={"Origin": HERE}) + assert response.status_code in (404, 405) + assert not sandbox.stopped.is_set() + + +def test_the_app_is_idle_after_ten_minutes_with_no_state_request(pack: Path, scratch: Path) -> None: + clock = _FakeClock() + with Sandbox(pack, app=True, clock=clock) as sandbox: + clock.now += IDLE_LIMIT - timedelta(seconds=1) + assert not sandbox.is_idle() + + _app(sandbox).get("/api/state") + clock.now += IDLE_LIMIT - timedelta(seconds=1) + assert not sandbox.is_idle() + + clock.now += timedelta(seconds=2) + assert sandbox.is_idle() + + +def test_other_requests_do_not_keep_the_app_alive(pack: Path, scratch: Path) -> None: + clock = _FakeClock() + with Sandbox(pack, app=True, clock=clock) as sandbox: + clock.now += IDLE_LIMIT - timedelta(seconds=1) + _app(sandbox).get("/api/sandbox/nodes") + clock.now += timedelta(seconds=2) + assert sandbox.is_idle() + + +def test_outside_the_app_the_sandbox_is_never_idle(pack: Path, scratch: Path) -> None: + clock = _FakeClock() + with Sandbox(pack, clock=clock) as sandbox: + clock.now += timedelta(days=2) + assert not sandbox.is_idle() + + +def test_serving_the_app_ends_on_stop_and_removes_its_folder( + pack: Path, scratch: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sandbox_module, "_open_browser_when_up", lambda server, url: None) + port = free_port() + served = threading.Thread( + target=sandbox_module.serve, args=(port, "fort-myers", pack), kwargs={"app": True} + ) + served.start() + try: + _wait_until_up(port) + stopped = httpx.post( + f"http://127.0.0.1:{port}/api/sandbox/stop", + headers={"Origin": f"http://127.0.0.1:{port}"}, + ) + assert stopped.status_code == 200 + served.join(timeout=30) + assert not served.is_alive() + finally: + if served.is_alive(): # pragma: no cover + pytest.fail("the app did not end after Stop") + assert list(scratch.iterdir()) == [] + + +def _wait_until_up(port: int) -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + httpx.get(f"http://127.0.0.1:{port}/api/state", timeout=1) + return + except httpx.HTTPError: + time.sleep(0.2) + pytest.fail("the Sandbox did not come up") diff --git a/node/tests/test_sandbox_app.py b/node/tests/test_sandbox_app.py index 2c3f8ed7..a6035002 100644 --- a/node/tests/test_sandbox_app.py +++ b/node/tests/test_sandbox_app.py @@ -53,7 +53,7 @@ def record(url: str) -> bool: @pytest.fixture def started(monkeypatch: pytest.MonkeyPatch) -> list[int]: ports: list[int] = [] - monkeypatch.setattr(sandbox_module, "serve", lambda port, region: ports.append(port)) + monkeypatch.setattr(sandbox_module, "serve", lambda port, region, app=False: ports.append(port)) return ports @@ -95,7 +95,7 @@ def test_a_free_preferred_port_is_used( 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: + def fail(port: int, region: str, app: bool = False) -> None: raise RuntimeError("no here") monkeypatch.setattr(sandbox_module, "serve", fail) @@ -111,7 +111,7 @@ def fail(port: int, region: str) -> None: 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: + def cannot_bind(port: int, region: str, app: bool = False) -> None: raise SystemExit(1) monkeypatch.setattr(sandbox_module, "serve", cannot_bind) @@ -131,7 +131,7 @@ def test_a_bind_lost_to_a_sandbox_that_just_started_opens_that_sandbox( 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: + def lost_the_race(port: int, region: str, app: bool = False) -> None: raise SystemExit(3) monkeypatch.setattr(sandbox_module, "serve", lost_the_race) @@ -143,7 +143,7 @@ def lost_the_race(port: int, region: str) -> None: 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: + def cannot_bind(port: int, region: str, app: bool = False) -> None: logging.getLogger("uvicorn.error").error("address already in use") raise SystemExit(3) @@ -184,7 +184,9 @@ 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_module, "serve", lambda port, region: _log.info("serving")) + monkeypatch.setattr( + sandbox_module, "serve", lambda port, region, app=False: _log.info("serving") + ) sandbox_app.main() logging.shutdown() assert "serving" in (tmp_path / sandbox_app.LOG_FILE_NAME).read_text() From b7d24b5eda858ca1b9db6deb5aaca90d565b77c8 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 02:39:36 +0000 Subject: [PATCH 2/3] Review fixes: stop returns whether it worked, style the stop page, test the idle end --- dashboard/src/App.css | 12 ++++++++++++ dashboard/src/App.tsx | 6 +----- dashboard/src/useSandboxNodes.ts | 7 ++++--- node/src/storm_shield/sandbox.py | 3 ++- node/tests/test_sandbox.py | 15 +++++++++++++++ 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/dashboard/src/App.css b/dashboard/src/App.css index 05d34635..bc098ba7 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -2101,6 +2101,18 @@ section p.flood-prone-caption { margin-top: 0.75rem; } +.sandbox-stop { + margin-top: 0.75rem; +} + +.sandbox-stopped { + display: grid; + place-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} + .sandbox-add[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 392be4a0..4bec4572 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -277,11 +277,7 @@ export function App() { onSwitchRegion={switchRegion} onStop={ state?.sandbox?.can_stop - ? () => - void stopSandbox().then( - () => setSandboxStopped(true), - () => {}, - ) + ? () => void stopSandbox().then((stopped) => setSandboxStopped(stopped)) : null } problem={problem} diff --git a/dashboard/src/useSandboxNodes.ts b/dashboard/src/useSandboxNodes.ts index 1446fcd5..f70d75a0 100644 --- a/dashboard/src/useSandboxNodes.ts +++ b/dashboard/src/useSandboxNodes.ts @@ -49,8 +49,8 @@ export type SandboxFeed = { // Declares `kinds` on a Sensor that stays where it is. setSensorKinds: (id: string, position: [number, number], kinds: string[]) => Promise; switchRegion: (name: string) => Promise; - // Ends the Sandbox app; resolves once it has said it is stopping. - stopSandbox: () => Promise; + // Ends the Sandbox app; true once it has said it is stopping, false (with `problem` set) if not. + stopSandbox: () => Promise; }; async function ask(path: string, init?: RequestInit): Promise { @@ -180,10 +180,11 @@ export function useSandboxNodes(inSandbox: boolean, onRegionSwitched: () => void setProblem(null); try { await ask("/api/sandbox/stop", sendJson("POST")); + return true; } catch (error) { console.error(error); setProblem(`Could not stop the Sandbox: ${(error as Error).message}`); - throw error; + return false; } }, []); diff --git a/node/src/storm_shield/sandbox.py b/node/src/storm_shield/sandbox.py index 93eb94e0..1075c12d 100644 --- a/node/src/storm_shield/sandbox.py +++ b/node/src/storm_shield/sandbox.py @@ -53,7 +53,8 @@ SANDBOX_RADIO_RANGE_M = 40_000.0 DOWNLOAD_TIMEOUT_SECONDS = 120 BUILT_DASHBOARD = DASHBOARD_DIR / "index.html" -# Browsers poll `/api/state` even from a background tab, so ten minutes of silence means none is open. +# Browsers poll `/api/state` even from a background tab, so ten minutes of silence means no tab +# is open. APP_IDLE_LIMIT = timedelta(minutes=10) APP_WATCH_SECONDS = 1.0 diff --git a/node/tests/test_sandbox.py b/node/tests/test_sandbox.py index a7457148..57a0cbe2 100644 --- a/node/tests/test_sandbox.py +++ b/node/tests/test_sandbox.py @@ -797,6 +797,21 @@ def test_serving_the_app_ends_on_stop_and_removes_its_folder( assert list(scratch.iterdir()) == [] +def test_serving_the_app_ends_itself_once_idle_and_removes_its_folder( + pack: Path, scratch: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sandbox_module, "_open_browser_when_up", lambda server, url: None) + monkeypatch.setattr(sandbox_module, "APP_IDLE_LIMIT", timedelta(seconds=2)) + monkeypatch.setattr(sandbox_module, "APP_WATCH_SECONDS", 0.2) + served = threading.Thread( + target=sandbox_module.serve, args=(free_port(), "fort-myers", pack), kwargs={"app": True} + ) + served.start() + served.join(timeout=60) + assert not served.is_alive() + assert list(scratch.iterdir()) == [] + + def _wait_until_up(port: int) -> None: deadline = time.monotonic() + 30 while time.monotonic() < deadline: From 8699fcd2e8d8c1391b711d34aaeccba07a8e9816 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 02:44:43 +0000 Subject: [PATCH 3/3] Serve tests no longer need pnpm or a built dashboard, and fail if serve() raises --- node/AGENTS.md | 1 + node/tests/test_sandbox.py | 55 +++++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/node/AGENTS.md b/node/AGENTS.md index 3bf51f76..565a6e13 100644 --- a/node/AGENTS.md +++ b/node/AGENTS.md @@ -33,5 +33,6 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m - **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. +- **CI's Node job has no `pnpm` and no built dashboard**, so a test that calls `sandbox.serve()` patches `build_dashboard_if_missing`, and collects what the serving thread raises: a thread that died on startup looks the same as one that ended on purpose. Networking and Pi lessons (firewalls, TCP peers) are in `pi/AGENTS.md`. diff --git a/node/tests/test_sandbox.py b/node/tests/test_sandbox.py index 57a0cbe2..931a0c7c 100644 --- a/node/tests/test_sandbox.py +++ b/node/tests/test_sandbox.py @@ -773,48 +773,65 @@ def test_outside_the_app_the_sandbox_is_never_idle(pack: Path, scratch: Path) -> assert not sandbox.is_idle() +def _serve_as_app( + monkeypatch: pytest.MonkeyPatch, pack: Path, port: int +) -> tuple[threading.Thread, list[BaseException]]: + """Run `serve(app=True)` in a thread. Whatever it raises lands in the returned list, so a + thread that died on startup cannot pass for one that ended on purpose.""" + # The Node CI job has no pnpm and no built dashboard; the dashboard is not what is tested. + monkeypatch.setattr(sandbox_module, "build_dashboard_if_missing", lambda: None) + monkeypatch.setattr(sandbox_module, "_open_browser_when_up", lambda server, url: None) + raised: list[BaseException] = [] + + def serve() -> None: + try: + sandbox_module.serve(port, "fort-myers", pack, app=True) + except BaseException as error: # noqa: BLE001 - handed to the test thread to fail on + raised.append(error) + + served = threading.Thread(target=serve) + served.start() + return served, raised + + def test_serving_the_app_ends_on_stop_and_removes_its_folder( pack: Path, scratch: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(sandbox_module, "_open_browser_when_up", lambda server, url: None) port = free_port() - served = threading.Thread( - target=sandbox_module.serve, args=(port, "fort-myers", pack), kwargs={"app": True} - ) - served.start() + served, raised = _serve_as_app(monkeypatch, pack, port) try: - _wait_until_up(port) + _wait_until_up(port, served) stopped = httpx.post( f"http://127.0.0.1:{port}/api/sandbox/stop", headers={"Origin": f"http://127.0.0.1:{port}"}, ) assert stopped.status_code == 200 - served.join(timeout=30) - assert not served.is_alive() finally: - if served.is_alive(): # pragma: no cover - pytest.fail("the app did not end after Stop") + served.join(timeout=30) + assert not served.is_alive(), "the app did not end after Stop" + assert raised == [] assert list(scratch.iterdir()) == [] def test_serving_the_app_ends_itself_once_idle_and_removes_its_folder( pack: Path, scratch: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(sandbox_module, "_open_browser_when_up", lambda server, url: None) monkeypatch.setattr(sandbox_module, "APP_IDLE_LIMIT", timedelta(seconds=2)) monkeypatch.setattr(sandbox_module, "APP_WATCH_SECONDS", 0.2) - served = threading.Thread( - target=sandbox_module.serve, args=(free_port(), "fort-myers", pack), kwargs={"app": True} - ) - served.start() - served.join(timeout=60) - assert not served.is_alive() + port = free_port() + served, raised = _serve_as_app(monkeypatch, pack, port) + try: + _wait_until_up(port, served) + finally: + served.join(timeout=60) + assert not served.is_alive(), "the app did not end itself when idle" + assert raised == [] assert list(scratch.iterdir()) == [] -def _wait_until_up(port: int) -> None: +def _wait_until_up(port: int, served: threading.Thread) -> None: deadline = time.monotonic() + 30 - while time.monotonic() < deadline: + while time.monotonic() < deadline and served.is_alive(): try: httpx.get(f"http://127.0.0.1:{port}/api/state", timeout=1) return