From 2ace01b13fb44141637785079db164c54a9b5397 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Thu, 30 Jul 2026 11:27:55 +0600 Subject: [PATCH 1/5] Remove health_status polling from server gateway redirect The backend already waits for the container health check before reporting status=running, making client-side health polling redundant. Increase the redirect delay from 1s to 3s to let Traefik route discovery settle. --- .../src/routes/user.$username.$serverName.tsx | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/frontend/src/routes/user.$username.$serverName.tsx b/frontend/src/routes/user.$username.$serverName.tsx index c85ecd3..99042fc 100644 --- a/frontend/src/routes/user.$username.$serverName.tsx +++ b/frontend/src/routes/user.$username.$serverName.tsx @@ -657,20 +657,11 @@ function ServerGatewayPage() { useActivityHeartbeat(server?.id, server?.status === 'running') // When server transitions to running, get access token and redirect. - // Wait for health_status === 'healthy' first (capped at 15s) so the - // one-shot redirect does not land on a transient 503 while Traefik - // routing and in-container app startup are still settling. + // The backend spawner already waits for the container's /health endpoint + // before reporting status=running, so nginx is up. A short fixed delay gives + // Traefik a moment to pick up the new Docker route before we navigate. useEffect(() => { if (server?.status !== 'running') return - if (startTimeRef.current === null) startTimeRef.current = Date.now() - - const waitedMs = Date.now() - startTimeRef.current - if (server.health_status !== 'healthy' && waitedMs < 15000) { - const interval = setInterval(() => { - queryClient.invalidateQueries({ queryKey: ['server-by-path', username, serverName] }) - }, 2000) - return () => clearInterval(interval) - } const redirectKey = `server-redirect-${server.id}` const alreadyRedirected = sessionStorage.getItem(redirectKey) @@ -704,18 +695,9 @@ function ServerGatewayPage() { const timeout = setTimeout(() => { getAccessTokenAndRedirect() - }, 1000) + }, 3000) return () => clearTimeout(timeout) - }, [ - server?.status, - server?.id, - server?.external_url, - server?.health_status, - isOwnServer, - queryClient, - username, - serverName, - ]) + }, [server?.status, server?.id, server?.external_url, isOwnServer, username, serverName]) const handleStart = useCallback(async () => { if (!server) return From b4a74df48d7a83a03b8c74a1fb78f169c4f56bd6 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Thu, 30 Jul 2026 11:55:32 +0600 Subject: [PATCH 2/5] Add Traefik readiness probe to server spawn process --- .env.example | 5 ++++ backend/AGENTS.md | 2 +- backend/app/config.py | 6 ++++ backend/app/container/docker_driver.py | 11 ++++++++ backend/app/container/driver.py | 10 ++++++- backend/app/container/spawner.py | 28 +++++++++++++++++-- backend/tests/container/test_spawner.py | 26 +++++++++++++---- compose.yml | 1 + .../src/routes/user.$username.$serverName.tsx | 14 +++++----- 9 files changed, 87 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index ab56f33..1ee075e 100644 --- a/.env.example +++ b/.env.example @@ -377,6 +377,11 @@ SERVER_AUTH_AUDIT_LOG=true # TRAEFIK (Reverse Proxy) # ============================================================================= +# Internal URL the backend uses to verify a server route is reachable through +# Traefik before reporting status=running. Points to Traefik's load-balancer +# port inside the container network. Leave empty to skip the probe. +TRAEFIK_INTERNAL_URL=http://traefik:80 + # Let's Encrypt contact email (used in infrastructure/traefik/traefik.yml) TRAEFIK_ACME_EMAIL=admin@nukelab.org diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 23d7c1a..7e80e2b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -13,7 +13,7 @@ All files under `backend/` except generated artifacts (`.venv-dev`, `__pycache__ - Python 3.13; formatting and linting configured in `pyproject.toml`. - `app/main.py` is the ASGI entry point. - `app/api/` owns route definitions; `app/services/` owns business logic; `app/models/` owns SQLAlchemy models; `app/db/` owns session/connection logic; `app/core/` owns cross-cutting utilities; `app/middleware/` owns ASGI middleware; `app/container/` owns container-runtime orchestration; `app/tasks.py` and `app/worker.py` own Celery. -- `app/container/` is a driver layer: `driver.py` defines the `ContainerDriver` ABC + `ContainerDriverError` (plain-data returns only — no runtime objects escape), `docker_driver.py` is the Docker/Podman implementation, `factory.py` selects the driver via `CONTAINER_RUNTIME` (default `docker`), `client.py` is a compatibility shim for legacy imports/test seams, and `spawner.py` (server lifecycle) talks only to driver methods. To add a runtime (e.g. Kubernetes): implement `ContainerDriver` (synthesizing the documented return shapes, e.g. Docker-stats for `get_container_stats`) and register it in `factory.py`. Container health is driver-level config, not image metadata: `docker_driver.py` injects a uniform `Healthcheck` (`/usr/local/bin/nukelab-healthcheck.sh`) into every create config, because OCI images drop Dockerfile `HEALTHCHECK` and Kubernetes ignores it — a k8s driver must translate the same definition into pod liveness/startup probes and surface failures as the same `State.Health.Status` shape `HealthCheckService` consumes. +- `app/container/` is a driver layer: `driver.py` defines the `ContainerDriver` ABC + `ContainerDriverError` (plain-data returns only — no runtime objects escape), `docker_driver.py` is the Docker/Podman implementation, `factory.py` selects the driver via `CONTAINER_RUNTIME` (default `docker`), `client.py` is a compatibility shim for legacy imports/test seams, and `spawner.py` (server lifecycle) talks only to driver methods. To add a runtime (e.g. Kubernetes): implement `ContainerDriver` (synthesizing the documented return shapes, e.g. Docker-stats for `get_container_stats`) and register it in `factory.py`. Container health is driver-level config, not image metadata: `docker_driver.py` injects a uniform `Healthcheck` (`/usr/local/bin/nukelab-healthcheck.sh`) into every create config, because OCI images drop Dockerfile `HEALTHCHECK` and Kubernetes ignores it — a k8s driver must translate the same definition into pod liveness/startup probes and surface failures as the same `State.Health.Status` shape `HealthCheckService` consumes. `spawner.py` performs two readiness probes before marking a server `running`: (1) the container's own `/health` endpoint over the Docker network alias, and (2) the public server path through the internal Traefik load balancer (`TRAEFIK_INTERNAL_URL`, default `http://traefik:80`) with a `healthy` body check. This ensures the browser-facing route exists before the frontend is told to redirect. - `app/api/search.py` — grouped, permission-scoped search at `/api/search/`; optional `group` query parameter scopes the response to a single group; groups the user lacks read permission for are omitted from the response (never 403). - `app/api/tokens.py` owns `VALID_TOKEN_SCOPES`, the source of truth for API-token scopes; the frontend `AVAILABLE_SCOPES` in `frontend/src/components/settings/tokens-page.tsx` must stay in sync (unknown scopes are rejected with 422). - `app/services/gpu_allocator.py` owns exclusive GPU device reservations (table `gpu_allocations`, active only when `GPU_DEVICES` lists CDI device names); every code path that stops or deletes a server must release its devices, and every GPU spawn path must allocate first. diff --git a/backend/app/config.py b/backend/app/config.py index df5f890..29263ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -217,6 +217,12 @@ def gpu_device_list(self) -> list[str]: container_readiness_timeout: int = 60 # seconds container_readiness_interval: float = 1.0 # seconds between probes + # Internal URL the backend uses to verify that Traefik has picked up a new + # server route before reporting status=running. Should point to Traefik's + # load-balancer port inside the container network (e.g. http://traefik:80). + # Leave empty to skip the Traefik readiness probe. + traefik_internal_url: str = "http://traefik:80" + registration_enabled: bool = True max_servers_per_user: int = 10 diff --git a/backend/app/container/docker_driver.py b/backend/app/container/docker_driver.py index 7098eca..45256b7 100644 --- a/backend/app/container/docker_driver.py +++ b/backend/app/container/docker_driver.py @@ -541,6 +541,7 @@ async def wait_for_container_ready( health_url: str, timeout: int | None = None, interval: float | None = None, + body_contains: str | None = None, ) -> bool: """Wait until the container responds successfully on health_url. @@ -566,6 +567,16 @@ async def wait_for_container_ready( async with aiohttp.ClientSession(timeout=timeout_obj) as session: async with session.get(health_url) as resp: if resp.status == 200: + if body_contains: + text = await resp.text() + if body_contains not in text: + logger.debug( + "Container %s returned 200 but body did not contain %r", + container_name, + body_contains, + ) + await asyncio.sleep(interval) + continue logger.info("Container %s is ready", container_name) return True except Exception as e: diff --git a/backend/app/container/driver.py b/backend/app/container/driver.py index 740c6a2..c4eb0e4 100644 --- a/backend/app/container/driver.py +++ b/backend/app/container/driver.py @@ -112,8 +112,16 @@ async def wait_for_container_ready( health_url: str, timeout: int | None = None, interval: float | None = None, + body_contains: str | None = None, ) -> bool: - """Wait until the container responds successfully on health_url.""" + """Wait until the container responds successfully on health_url. + + Args: + body_contains: If set, the response body must contain this string + (case-sensitive) for the probe to be considered successful. + Useful to distinguish the container's /health body from a + fallback SPA shell served by the reverse proxy. + """ @abstractmethod async def list_containers(self, filters: dict | None = None) -> list[dict]: diff --git a/backend/app/container/spawner.py b/backend/app/container/spawner.py index 242cffa..6ff909a 100644 --- a/backend/app/container/spawner.py +++ b/backend/app/container/spawner.py @@ -325,6 +325,30 @@ async def spawn( container_name, ) + # Wait for Traefik to publish the server route. Without this, the + # frontend can redirect before Traefik knows about the container and + # land on the SPA user-gateway fallback or a 503. Probe the public + # path through Traefik's internal load-balancer address and check the + # body to distinguish the container's /health response from a + # fallback HTML shell. + traefik_ready = True + if settings.traefik_internal_url: + traefik_health_url = ( + f"{settings.traefik_internal_url.rstrip('/')}{route_prefix}/health" + ) + traefik_ready = await container_client.wait_for_container_ready( + container_name, + traefik_health_url, + timeout=settings.container_readiness_timeout, + interval=settings.container_readiness_interval, + body_contains="healthy", + ) + if not traefik_ready: + logger.warning( + "Traefik route for %s not ready within timeout; continuing", + container_name, + ) + # Determine primary volume_id from volume_mounts if provided primary_volume_id = None if volume_mounts: @@ -332,7 +356,7 @@ async def spawn( primary = next((m for m in volume_mounts if m.get("is_primary")), volume_mounts[0]) primary_volume_id = primary.get("volume_id") - # Create server record. Reflect the result of the readiness probe in + # Create server record. Reflect the result of the readiness probes in # health_status so the API shows an accurate initial state instead of # the model default "unknown". server = Server( @@ -344,7 +368,7 @@ async def spawn( image=image, volume_id=uuid.UUID(primary_volume_id) if primary_volume_id else None, status="running", - health_status="healthy" if ready else "unhealthy", + health_status="healthy" if (ready and traefik_ready) else "unhealthy", allocated_cpu=cpu, allocated_memory=memory, allocated_disk=disk, diff --git a/backend/tests/container/test_spawner.py b/backend/tests/container/test_spawner.py index 5f2379d..dc87113 100644 --- a/backend/tests/container/test_spawner.py +++ b/backend/tests/container/test_spawner.py @@ -664,9 +664,17 @@ async def test_spawn_returns_server_with_url(self, fresh_spawner): @pytest.mark.asyncio async def test_spawn_waits_for_container_ready(self, fresh_spawner): - """spawn should wait for container readiness before returning.""" + """spawn should wait for container and Traefik readiness before returning.""" + from app.container.spawner import settings + user_id = str(uuid_mod.uuid4()) - with mock.patch("app.container.spawner.settings.public_url", "http://test"): + with ( + mock.patch("app.container.spawner.settings.public_url", "http://test"), + mock.patch( + "app.container.spawner.settings.traefik_internal_url", + "http://traefik", + ), + ): server = await fresh_spawner.spawn( user_id=user_id, username="testuser", @@ -674,9 +682,17 @@ async def test_spawn_waits_for_container_ready(self, fresh_spawner): ) health_alias = f"srv-{str(server.id)[:8]}" - fresh_spawner.container_client.wait_for_container_ready.assert_awaited_once_with( - health_alias, - f"http://{health_alias}:8080/health", + calls = fresh_spawner.container_client.wait_for_container_ready.await_args_list + assert mock.call(health_alias, f"http://{health_alias}:8080/health") in calls + assert ( + mock.call( + mock.ANY, + "http://traefik/user/testuser/srv1/health", + timeout=settings.container_readiness_timeout, + interval=settings.container_readiness_interval, + body_contains="healthy", + ) + in calls ) @pytest.mark.asyncio diff --git a/compose.yml b/compose.yml index 5bd7329..5405df8 100644 --- a/compose.yml +++ b/compose.yml @@ -159,6 +159,7 @@ services: - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} - DOCKER_NETWORK=${DOCKER_NETWORK:-nukelab-network} + - TRAEFIK_INTERNAL_URL=${TRAEFIK_INTERNAL_URL:-http://traefik:80} - LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_FORMAT=${LOG_FORMAT:-json} - DEV_RELOAD=${DEV_RELOAD:-true} diff --git a/frontend/src/routes/user.$username.$serverName.tsx b/frontend/src/routes/user.$username.$serverName.tsx index 99042fc..32ec8cc 100644 --- a/frontend/src/routes/user.$username.$serverName.tsx +++ b/frontend/src/routes/user.$username.$serverName.tsx @@ -657,9 +657,9 @@ function ServerGatewayPage() { useActivityHeartbeat(server?.id, server?.status === 'running') // When server transitions to running, get access token and redirect. - // The backend spawner already waits for the container's /health endpoint - // before reporting status=running, so nginx is up. A short fixed delay gives - // Traefik a moment to pick up the new Docker route before we navigate. + // The backend spawner already waits for both the container's /health endpoint + // and the Traefik route before reporting status=running, so we can redirect + // immediately. useEffect(() => { if (server?.status !== 'running') return @@ -693,10 +693,10 @@ function ServerGatewayPage() { } } - const timeout = setTimeout(() => { - getAccessTokenAndRedirect() - }, 3000) - return () => clearTimeout(timeout) + // The backend now waits for the Traefik route before reporting + // status=running, so we can navigate as soon as we have a token and the + // service worker is up to date. + getAccessTokenAndRedirect() }, [server?.status, server?.id, server?.external_url, isOwnServer, username, serverName]) const handleStart = useCallback(async () => { From bca95845173deedf409fecef01383c1d0e465f79 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Mon, 3 Aug 2026 16:21:37 +0600 Subject: [PATCH 3/5] Preserve nuclear toolchain paths after conda activation --- environments/radiation-transport/nuke-env.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/environments/radiation-transport/nuke-env.sh b/environments/radiation-transport/nuke-env.sh index 67fd525..1ec1ca7 100755 --- a/environments/radiation-transport/nuke-env.sh +++ b/environments/radiation-transport/nuke-env.sh @@ -6,7 +6,25 @@ # conda environment in login/interactive shells so users get the right Python # and the MOAB/OpenMC/PyNE toolchain. if [ -n "${NUKE_DIR:-}" ] && [ -d "${NUKE_DIR}/bin" ]; then + # Conda activation replaces PATH with the env's bin directory. The Dockerfile + # adds nuclear-code bin directories to PATH; preserve them across activation + # so openmc, geant4-config, njoy, etc. remain available in terminals. + _nuke_tool_path="${NUKE_DIR}/bin" + _nuke_tool_path="${_nuke_tool_path}:${MOAB_ROOT:-/opt/moab}/bin" + _nuke_tool_path="${_nuke_tool_path}:${DOUBLE_DOWN_ROOT:-/opt/double-down}/lib" + _nuke_tool_path="${_nuke_tool_path}:${GEANT4_ROOT:-/opt/geant4}/bin" + _nuke_tool_path="${_nuke_tool_path}:${DAGMC_ROOT:-/opt/dagmc}/bin" + _nuke_tool_path="${_nuke_tool_path}:${LIBMESH_ROOT:-/opt/libmesh}/bin" + _nuke_tool_path="${_nuke_tool_path}:${NJOY2016_ROOT:-/opt/njoy2016}/bin" + _nuke_tool_path="${_nuke_tool_path}:${OPENMC_ROOT:-/opt/openmc}/bin" + _nuke_tool_path="${_nuke_tool_path}:${KDSOURCE_ROOT:-/opt/kdsource}/bin" + _nuke_tool_path="${_nuke_tool_path}:${ALARA_ROOT:-/opt/alara}/bin" + conda activate "${NUKE_DIR}" > /dev/null 2>&1 || true + + export PATH="${_nuke_tool_path}${PATH:+:${PATH}}" + unset _nuke_tool_path + # The nuke env replaces PATH with its own bin directory. Keep the base # conda tools (node, yarn, npm) available in login/terminal sessions. if [ -d "/opt/conda/bin" ] && [[ ":${PATH}:" != *":/opt/conda/bin:"* ]]; then From 083557166a0e0d8becbc24c88434431163a9462b Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Mon, 3 Aug 2026 16:29:17 +0600 Subject: [PATCH 4/5] Add OpenMC depletion chain data download Add a new `DOWNLOAD_CHAIN_DATA` build arg and `download_chain_files.sh` script to fetch ENDF/B-VIII.0 and ENDF/B-VIII.1 depletion chains, setting `OPENMC_CHAIN_FILE` to the thermal ENDF/B-VIII.0 chain. --- environments/radiation-transport/Dockerfile | 12 ++++--- .../download_chain_files.sh | 31 ++++++++++++++++ .../download_cross_sections.sh | 36 +++++++++++++++---- 3 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 environments/radiation-transport/download_chain_files.sh diff --git a/environments/radiation-transport/Dockerfile b/environments/radiation-transport/Dockerfile index d2c8a8d..76f233c 100644 --- a/environments/radiation-transport/Dockerfile +++ b/environments/radiation-transport/Dockerfile @@ -22,9 +22,10 @@ ARG KDSOURCE_BRANCH="v0.2.2" ARG PYNE_BRANCH="scikit-build-core" ARG ALARA_BRANCH="main" -# Define whether to download Geant4 data and cross-section data +# Define whether to download Geant4 data, cross-section data, and depletion chain data ARG DOWNLOAD_GEANT4_DATA=ON ARG DOWNLOAD_CROSS_SECTION_DATA=ON +ARG DOWNLOAD_CHAIN_DATA=ON ########################################################################### # Initial Setup # @@ -213,13 +214,16 @@ RUN git clone -b ${OPENMC_BRANCH} https://github.com/openmc-dev/openmc.git && \ cd .. && pip install --no-cache-dir . && \ rm -rf /tmp/openmc -# Download cross-section data using the system shell +# Download cross-section and depletion chain data using the system shell ARG DOWNLOAD_CROSS_SECTION_DATA +ARG DOWNLOAD_CHAIN_DATA ENV OPENMC_DATA_DIR=/opt/openmc_data ENV OPENMC_CROSS_SECTIONS=${OPENMC_DATA_DIR}/lib80x_hdf5/cross_sections.xml -COPY --chmod=755 download_cross_sections.sh . +ENV OPENMC_CHAIN_FILE=${OPENMC_DATA_DIR}/chain/chain_endfb80_thermal.xml +COPY --chmod=755 download_cross_sections.sh download_chain_files.sh . RUN ./download_cross_sections.sh ${DOWNLOAD_CROSS_SECTION_DATA} ${OPENMC_DATA_DIR} && \ - rm download_cross_sections.sh + ./download_chain_files.sh ${DOWNLOAD_CHAIN_DATA} ${OPENMC_DATA_DIR} && \ + rm download_cross_sections.sh download_chain_files.sh # Install PyNE ARG PYNE_BRANCH diff --git a/environments/radiation-transport/download_chain_files.sh b/environments/radiation-transport/download_chain_files.sh new file mode 100644 index 0000000..49d4269 --- /dev/null +++ b/environments/radiation-transport/download_chain_files.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2023-2026 NukeHub Developers +# SPDX-License-Identifier: BSD-2-Clause + +# Download OpenMC depletion chain files. +# +# Usage: +# download_chain_files.sh +# +# When the first argument is ON, chain XML files are downloaded into +# /chain/. The default OpenMC chain file environment +# variable should point to one of these files. + +set -euo pipefail + +download_chain_data=${1:-OFF} +chain_data_dir=${2:-/opt/openmc_data} + +if [ "$download_chain_data" == "ON" ]; then + mkdir -p "${chain_data_dir}/chain" + cd "${chain_data_dir}/chain" + + # ENDF/B-VIII.0 depletion chains (thermal and fast spectrum). + # These pair with the LANL ENDF/B-VIII.0 (lib80x) cross-section library. + wget -O chain_endfb80_thermal.xml "https://anl.box.com/shared/static/nyezmyuofd4eqt6wzd626lqth7wvpprr.xml" + wget -O chain_endfb80_fast.xml "https://anl.box.com/shared/static/x3kp739hr5upmeqpbwx9zk9ep04fnmtg.xml" + + # ENDF/B-VIII.1 depletion chains. + wget -O chain_endfb81_thermal.xml "https://anl.box.com/shared/static/q6ev8pl7xct179ke7kq148smde8gzni6.xml" + wget -O chain_endfb81_fast.xml "https://anl.box.com/shared/static/n0pkqe66uotskoljr93szvjyvtvycgze.xml" +fi diff --git a/environments/radiation-transport/download_cross_sections.sh b/environments/radiation-transport/download_cross_sections.sh index c02541e..e171c3b 100755 --- a/environments/radiation-transport/download_cross_sections.sh +++ b/environments/radiation-transport/download_cross_sections.sh @@ -2,24 +2,46 @@ # SPDX-FileCopyrightText: 2023-2026 NukeHub Developers # SPDX-License-Identifier: BSD-2-Clause +# Download OpenMC cross-section data libraries. +# +# Usage: +# download_cross_sections.sh +# +# When the first argument is ON, libraries are downloaded and extracted into +# /. + +set -euo pipefail + # Assign command line arguments to variables -download_cross_section_data=$1 -cross_section_data_lib=$2 +download_cross_section_data=${1:-OFF} +cross_section_data_lib=${2:-/opt/openmc_data} if [ "$download_cross_section_data" == "ON" ]; then - mkdir -p ${cross_section_data_lib} - cd ${cross_section_data_lib} + mkdir -p "${cross_section_data_lib}" + cd "${cross_section_data_lib}" + # Function to download and extract data download_and_extract() { local url=$1 - local filename=$(basename $url) + local filename + filename=$(basename "$url") mkdir -p tmp cd tmp - wget $url + wget "$url" cd .. - tar -Jxvf tmp/$filename + tar -Jxvf "tmp/${filename}" rm -rf tmp } + + # Current libraries are the LANL-based data sets distributed for use with + # MCNP/OpenMC. They extract to mcnp_endfb70, mcnp_endfb71, and lib80x_hdf5. + # + # Newer official OpenMC-produced libraries are available from + # https://openmc.org/data/ and may be substituted here. For example: + # - ENDF/B-VIII.0 official: https://anl.box.com/shared/static/uhbxlrx7hvxqw27psymfbhi7bx7s6u6a.xz + # - ENDF/B-VIII.1 official: https://anl.box.com/shared/static/6qr7jezzihkj9p9esl5jn19qgpujyjyz.xz + # If switching libraries, update OPENMC_CROSS_SECTIONS in the Dockerfile to + # match the extracted directory name and verify with a test build. download_and_extract "https://anl.box.com/shared/static/t25g7g6v0emygu50lr2ych1cf6o7454b.xz" download_and_extract "https://anl.box.com/shared/static/d359skd2w6wrm86om2997a1bxgigc8pu.xz" download_and_extract "https://anl.box.com/shared/static/nd7p4jherolkx4b1rfaw5uqp58nxtstr.xz" From 1179780c892c8374ddfdf03c87c8f8f97da155b3 Mon Sep 17 00:00:00 2001 From: Ahnaf Tahmid Chowdhury Date: Mon, 3 Aug 2026 19:10:21 +0600 Subject: [PATCH 5/5] Fix service worker bypass for visualizer routes and improve update flow --- frontend/public/sw.js.tpl | 5 ++ .../src/routes/user.$username.$serverName.tsx | 47 ++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/frontend/public/sw.js.tpl b/frontend/public/sw.js.tpl index 7361548..1ec5a50 100644 --- a/frontend/public/sw.js.tpl +++ b/frontend/public/sw.js.tpl @@ -23,6 +23,11 @@ function shouldBypass(request, url) { for (const prefix of BYPASS_PATHS) { if (pathname.startsWith(prefix)) return true; } + // Visualizer reverse-proxy routes are served by the IDE container's trame + // servers and must not be served the cached SPA shell. They normally live + // under /user/.../visualizer/, but bypass them anywhere they appear as a + // defense-in-depth measure. + if (pathname.includes('/visualizer/')) return true; return false; } diff --git a/frontend/src/routes/user.$username.$serverName.tsx b/frontend/src/routes/user.$username.$serverName.tsx index 32ec8cc..e914454 100644 --- a/frontend/src/routes/user.$username.$serverName.tsx +++ b/frontend/src/routes/user.$username.$serverName.tsx @@ -54,7 +54,8 @@ async function getServerAccessToken(serverId: string, reason?: string): Promise< async function ensureServiceWorkerUpdated(): Promise { // Browsers with a stale service worker may serve the cached SPA shell for // /user/ routes instead of letting the request reach the terminal container. - // Force an update and activate any waiting worker before we navigate. + // Force an update and wait for the new worker to activate and claim this + // client before we navigate. if (!('serviceWorker' in navigator)) return try { // navigator.serviceWorker.ready never resolves when no worker is @@ -62,12 +63,46 @@ async function ensureServiceWorkerUpdated(): Promise { // so check for a registration first instead of awaiting it blindly. const registration = await navigator.serviceWorker.getRegistration() if (!registration) return - await registration.update() - if (registration.waiting) { - registration.waiting.postMessage({ type: 'SKIP_WAITING' }) - // Give the new worker a moment to activate and claim this client. - await new Promise((resolve) => setTimeout(resolve, 300)) + + const skipWaiting = (worker: ServiceWorker | null): void => { + if (worker) { + worker.postMessage({ type: 'SKIP_WAITING' }) + } } + + // If a worker is already waiting, activate it immediately. + skipWaiting(registration.waiting) + + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 2000) + + const onStateChange = (worker: ServiceWorker): void => { + if (worker.state === 'activated') { + clearTimeout(timeout) + resolve() + } + } + + registration.addEventListener('updatefound', () => { + const worker = registration.installing + if (!worker) return + skipWaiting(worker) + worker.addEventListener('statechange', () => onStateChange(worker)) + }) + + // A worker may already be installing after registration.update() resolves. + if (registration.installing) { + skipWaiting(registration.installing) + registration.installing.addEventListener('statechange', () => + onStateChange(registration.installing!) + ) + } + + registration.update().catch(() => { + clearTimeout(timeout) + resolve() + }) + }) } catch { // Best-effort; don't block navigation if the SW API misbehaves. }