# `[2026-08-19]` waterland studio containerised on irv-ml1 — three landmines, all measured Handover from `waterland-dev` over althing (thread `01M0CDRGEZWAJCEJXXMQWXV80F`): a FastAPI + SPA GPU service fronting the `waterland` CLI, running as a bare `nohup` (PID 1283383) that would not survive a reboot. Now `stacks/waterland-studio/`, `restart: unless-stopped`, healthy on irv-ml1:8410. Commits `a2b5b58`, `8189076`. Tracking `main` per operator: PR #4 merged and `main` HEAD was exactly the pinned `8025366`, so tracking-a-moving-ref and keeping-the-pin agreed anyway. **Now deployed at `b72425b` (2026-08-19).** The container sat on `8025366` for a few hours after PR #5 (`464dfc2`) landed — deliberately, since the image's own guards already neutralised both landmines and the project was in wind-down. PR #6 (the job-store rehydrate, operator-green-lit) was the rebuild with a real reason behind it, and one `update.sh` run carried both. Verified end to end after the update: healthy, `backend: cupy`, and a real 256² plate render completes warm — the kernel-cache volume survived the image swap. ## Build context lives OUTSIDE the compose dir — on purpose `/opt/waterland-studio/src` is the checkout; the Dockerfile is passed out-of-context from `/opt/docker/compose/waterland-studio/`. **`deploy-stack.sh` rsyncs `stacks//` with `--delete`**, so a checkout kept beside `compose.yaml` would be destroyed by the next deploy of this stack. `update.sh` refreshes source → rebuild → recreate → health, and is verified end to end. ## Landmine 1 — both uv extras are load-bearing at BUILD *and* RUN `gpu` carries `cupy-cuda12x`; a bare `uv sync` prunes it and the renderer silently drops to the numpy path at ~21x wall time — it does not error, it just gets slow. waterland-dev warned about the build side. The runtime side is worse and was not in the handover: **`studio/jobs.py` shells the renderer out as a literal `uv run waterland ...` with no `--extra` flags** (`cwd=WATERLAND_STUDIO_REPO`). Left alone, uv re-syncs the project mid-job to its default extras and prunes cupy back out from under a correctly built venv. Pinned with `UV_NO_SYNC=1`; `UV_OFFLINE=1` alongside so that if the pin ever stops holding the job fails **loudly** instead of quietly rebuilding a slower environment. **Fixed upstream in `464dfc2`:** the server now spawns `sys.executable -m waterland.cli` directly — no resolver in the render path at all. **The pins stay anyway.** They cost nothing and are now defence-in-depth: if any future code path re-enters `uv` inside the container, the job fails loudly instead of quietly dropping to the numpy backend. `uv` itself must stay in the image regardless — it performs the build-time `uv sync` / `uv pip install`, and this is a single-stage build. ## Landmine 2 — cupy needs CUDA HEADERS, which the host never had to declare Every render died 1.7s in with: ``` RuntimeError: Failed to find CUDA headers. ``` printed **through argparse's usage banner**, which makes it read like a CLI argument bug rather than a missing toolkit. That misdirection is the reason this is written down. cupy compiles kernels at runtime through NVRTC, which needs the toolkit **headers** — not just the driver and the runtime libs bundled in the `cupy-cuda12x` wheel. irv-ml1 has a CUDA toolkit installed system-wide, so the bare `nohup` process found them **by accident**; a slim image has none. Fixed with `uv pip install "cupy-cuda12x[ctk]"` — headers as wheels, a few hundred MB against ~6 GB for a `-devel` base image. It runs **after** `uv sync`, because sync prunes what it does not know about. Reported upstream: it is an undeclared runtime dependency of the `gpu` extra, and anyone running this without a system toolkit hits it. **Declared upstream in `464dfc2`** (`gpu` is now `cupy-cuda12x[ctk]>=13`). **The explicit install stays in the Dockerfile**: the header requirement is a property of *this* image — a slim base with no system CUDA toolkit — so it belongs in the file that creates the problem, not inherited from an extra two repos away. It also survives any future restructuring of the `gpu` extra. Cost of keeping it is now measured, not assumed: since `uv sync` satisfies it first, the line reports `Audited 1 package in 49ms` and adds **0.3s** to the build. A no-op that documents a non-obvious requirement is worth 0.3s. (waterland-dev independently agreed they would keep it too.) ## Landmine 3 — the GPU index inside the container is not the host's The app pins `CUDA_DEVICE_ORDER=PCI_BUS_ID` and selects `CUDA_VISIBLE_DEVICES_TARGET` (default `1`, correct on the host, where `nvidia-smi` shows A6000 at 1). Compose exposes **exactly one** GPU (`device_ids: ["1"]`, the A6000 in Docker's ordering), so **inside** the container that card is index **0** ⇒ `CUDA_VISIBLE_DEVICES_TARGET=0`. Copying the host's value selects a device that does not exist. Host device 0 is the 3090, which carries the TTS zoo and must not be touched. ## Cold start is ~17s of NVRTC compile → `/root/.cupy` is a volume | job | wall | |---|---| | 256² + anim, cold container | 23.3 s | | 256² + anim, warm | **6.1 s** | | 256² plate only (`--codec none`) | 3.9 s | | 512² plate only | 6.4 s | Warm beats the **7.4 s** recorded against the bare-metal process, so containerising cost nothing. Verified the cache volume properly: recreate (fresh cache → 23.2 s first render) then restart (populated → 6.0 s). Without it every restart makes the next user wait 4x and the service merely *looks* slow. ## Upstream finding — the on-disk job store grows without bound `JobStore._jobs` is a plain dict and **nothing scans `WATERLAND_STUDIO_DATA` at startup**. Consequences: 1. After a restart `/api/jobs` lists only jobs created since — cosmetic, and how this was spotted: the API reported **1 job** while the volume held all **16 directories, 60.6 MB**. Not data loss. 2. The real one: `RETAIN = 40` eviction only ever iterates the in-memory dict, so directories orphaned by a restart are **never reclaimed**. The handover's "bounded around 500 MB" holds within a single process lifetime; across restarts the store grows monotonically at ~12 MB per animated job. Reported to waterland-dev with evidence; **not patched from the infra side** — it is their code. Prune the volume by hand if it bites first. **waterland-dev confirmed it (2026-08-19)** — their "bounded ~500 MB" handover claim holds within one process lifetime and nowhere else, which on a `restart: unless-stopped` service is the wrong lifetime to have bounded. They have **surfaced a startup-rehydrate fix to the operator** rather than opening a third PR during wind-down. **Operator green-lit it; PR #6 merged as `b72425b` and is DEPLOYED (2026-08-19).** Startup rehydrate, as recommended — and waterland-dev deliberately went further than the framing I sent them. I had said a directory the scan cannot parse "just does not enter the index"; they made the opposite call, because a directory that never enters the index is exactly the one that never gets reclaimed. **That is the sharper reading and it is the reason the fix works on this volume at all** — the 16 pre-existing dirs have no sidecar. Their adoption ladder: sidecar → restored verbatim; no sidecar → adopted with dimensions recovered from the PNG IHDR (24-byte read, not a decode); corrupt sidecar → degrades to inference, no startup crash; **neither source nor sidecar → skipped on purpose**, since adopting it would turn eviction into a delete-arbitrary-directories primitive pointed at this volume. Sidecar writes go through `os.replace`, and `job.json` is excluded from `ARTIFACTS` so it is unreachable via the artifact route. They also closed a second leak I never saw, because it needs a restart *mid-render* to surface: a job left `running`/`queued` in its sidecar is non-terminal forever, and eviction skips non-terminal jobs — so it is a phantom that is never reclaimed and `queue_depth` over-reports for the life of the process. Adoption now marks those `failed`. **Verified on this host after the update:** `/api/jobs` went **1 → 16** while the volume stayed at 16 dirs / 61 MB — disk and API agree for the first time. Nothing was reclaimed, correctly: 16 is under `RETAIN=40`, so adoption only made them visible. A subsequent real render took both to 17. From here the store is bounded **across** restarts, not merely within a process. ## Access Repo is not anonymously readable (a bare clone 403s). Operator granted **`claude-bot` read on `vh/waterland`** — verified `admin: False, push: False, pull: True`. Token on irv-ml1 at `/root/.config/waterland-studio/git-credentials`, `0600` root-owned, wired as a **repo-scoped** credential helper; `.git/config` carries no token (verified), so the remote stays clean in any diff or backup. The operator's `vh` site-admin token was used only for the initial clone and the grant itself and was **never written to disk on that host** — a site-admin credential on a GPU box is a blast radius nobody needs for a read-only fetch. ## Constraints honoured as stated (not inferred) - **Serial by design — one replica, one card.** A render is 20–45s of near-full GPU with a single worker thread. Two on the same A6000 would OOM or thrash. Throughput is a hardware conversation, not a replica-count one. - **No authentication, arbitrary file uploads** ⇒ stays inside the LAN/WireGuard boundary. Do **not** paper over it with a proxy password; waterland-dev offered to add a real auth layer if wider reach is ever needed.