Skip to content

fix(nix-web-monitor): bust stale UI cache, harden WebSocket send and lag resync#806

Merged
Andrew Gazelka (andrewgazelka) merged 1 commit into
mainfrom
nwm-fixes
Jun 7, 2026
Merged

fix(nix-web-monitor): bust stale UI cache, harden WebSocket send and lag resync#806
Andrew Gazelka (andrewgazelka) merged 1 commit into
mainfrom
nwm-fixes

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Jun 7, 2026

Copy link
Copy Markdown
Member

Follow-ups after the WebSocket switch (#804), including the user-reported "Expected a JavaScript module but got text/html" UI load failure.

index.html stale-cache → wrong-MIME load failure (the visible bug)

index.html names the content-hashed asset files. It was served with no cache headers, and nix store mtimes are a constant epoch, so a browser kept a stale index.html across a rebuild and asked for /assets/index-<oldhash>.js, which the new server no longer has. The SPA fallback then answered that missing asset with index.html (text/html), and strict MIME checking rejected it.

Two fixes:

  • index.html is served with Cache-Control: no-store so the browser always re-fetches it and its asset references stay in sync with the server. (no-cache wouldn't be enough: the constant epoch mtime would 304 the stale copy.)
  • A missing asset now 404s instead of falling back to index.html. The dashboard has no client-side routing, so the HTML fallback was unnecessary; serving HTML for a missing .js is exactly what produced the MIME error.

WebSocket hardening (from the #804 review)

  • 30s send timeout per frame: a client that completes the handshake then stops reading no longer pins its per-client task indefinitely.
  • Lag resync no longer double-applies: on broadcast Lagged, the server now re-subscribes under the read lock alongside the reset snapshot, dropping the buffered backlog instead of replaying it on top of the reset (replay double-counted non-idempotent appends like log lines).

Validation

  • nix build .#nix-web-monitor
  • runtime: GET /cache-control: no-store; real asset → 200 text/javascript; missing asset → 404; WS still seeds the Delta::Reset
  • new unit tests: index_is_served_no_store, missing_asset_404s_instead_of_html_fallback

AI-generated with Claude Opus 4.8.

Note

Fix stale UI cache and harden WebSocket send/lag resync in nix-web-monitor

  • Serves index.html with Cache-Control: no-store via a dedicated serve_index handler, busting stale browser caches. Missing static assets now return 404 instead of falling back to index.html.
  • Adds a send_frame() helper that wraps each WebSocket send with a 30s SEND_TIMEOUT, dropping unresponsive clients that stop reading.
  • On broadcast lag (RecvError::Lagged), the server now re-subscribes to drop the stale backlog and sends a fresh Reset snapshot instead of replaying non-idempotent deltas.
  • index.html is read from disk at startup; the server will fail to start if the file is missing.
  • Risk: the startup read is a new hard requirement — deployments where index.html is absent will now error immediately rather than at request time.

Macroscope summarized 7a291d1.

…lag resync

Three fixes after the WebSocket switch:

- index.html is now served with Cache-Control: no-store. It names the content-hashed asset files, so a browser that cached it across a rebuild requests assets the new server no longer has. nix store mtimes are a constant epoch, so no-cache would 304 the stale copy; no-store forces a fresh fetch each load. This is the durable fix for the "Expected a JavaScript module but got text/html" load failure.
- A missing asset now 404s instead of falling back to index.html. Serving HTML for a missing /assets/*.js is what surfaces as the wrong-MIME error; the SPA fallback was unnecessary (the dashboard has no client-side routing).
- Each WebSocket frame send is bounded by a 30s timeout, so a client that completes the handshake then stops reading drops instead of pinning its task.
- On broadcast Lagged, the server now re-subscribes under the read lock alongside the reset snapshot, dropping the buffered backlog instead of replaying it on top of the reset (replay double-applied non-idempotent appends like log lines).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@andrewgazelka Andrew Gazelka (andrewgazelka) merged commit 8032c80 into main Jun 7, 2026
9 of 10 checks passed
@andrewgazelka Andrew Gazelka (andrewgazelka) deleted the nwm-fixes branch June 7, 2026 05:07
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Blast radius

25 of 1111 checks would rebuild between base 1444972 and head 7f96c9f.

2 added, 0 removed

pie showData title Rebuilt checks by category
  "image" : 13
  "rust" : 10
  "blast" : 1
  "lint" : 1
Loading
flowchart LR
  c0["ix-mcp"]
  c1["nix-web-monitor-0.1.0"]
  c2["blast-radius-test"]
  c3["lint"]
  c4["rust-nix-web-monitor.clippy"]
  c0 --> k1["image-kernel-dev"]
  c0 --> k2["image-minecraft"]
  c0 --> k3["image-minecraft-bedrock"]
  c0 --> k4["image-minecraft-status"]
  c0 --> k5["image-minecraft_1.21.11-fabric"]
  c1 --> k8["rust-nix-web-monitor.nix-web-monitor-all"]
  c1 --> k9["rust-nix-web-monitor.nix-web-monitor-tests-delta_encode_round_trips_through_msgpack"]
  c1 --> k10["rust-nix-web-monitor.nix-web-monitor-tests-state_endpoint_serves_snapshot_json"]
  c1 --> k11["rust-nix-web-monitor.nix-web-monitor-tests-ws_route_requires_websocket_upgrade"]
  c1 --> k12["rust-nix-web-monitor.package"]
Loading
changed checks (23)
  • blast-radius-test
  • image-kernel-dev
  • image-minecraft
  • image-minecraft-bedrock
  • image-minecraft-status
  • image-minecraft_1.21.11-fabric
  • image-minecraft_1.21.11-paper
  • image-minecraft_26.1.2-fabric
  • image-minecraft_26.1.2-paper
  • image-minecraft_26w17a-fabric
  • image-minestom
  • image-neovim-ci
  • image-remote-desktop
  • image-test-cluster-bootstrap
  • lint
  • rust-mcp.evalSmoke
  • rust-nix-web-monitor.clippy
  • rust-nix-web-monitor.nix-web-monitor-all
  • rust-nix-web-monitor.nix-web-monitor-tests-delta_encode_round_trips_through_msgpack
  • rust-nix-web-monitor.nix-web-monitor-tests-state_endpoint_serves_snapshot_json
  • rust-nix-web-monitor.nix-web-monitor-tests-ws_route_requires_websocket_upgrade
  • rust-nix-web-monitor.package
  • rust-nix-web-monitor.unusedCrateDependencies

Andrew Gazelka (andrewgazelka) added a commit that referenced this pull request Jun 7, 2026
…dropped reset seed (#811)

Redesign of the build view after the WebSocket switch (#804) and cache
fix (#806), addressing the reported issues: derivations floated as many
orphan roots instead of one goal, rows were noisy (`.drv` + store hash +
version all competing), there was no keyboard navigation, and the log
drawer named the pinned build by an opaque activity id. Also fixes a
latent bug that left the build tree empty on a page loaded mid-build.

## Single goal-rooted tree

Every derivation now hangs under one synthetic root labeled with the Nix
command (`nix build .#ix`), so the view is always a single tree from the
goal instead of a forest. Before, any derivation whose parent closure
had not yet been queried floated as its own top-level root; mid-build
that meant a wall of orphans (the reported screenshot). Now those nest
under the command root, and the real goal settles as its single child
once edges resolve.

The root row carries a live rollup: `succeeded/total` plus
`running`/`failed` counts and an elapsed clock that freezes when the
build finishes. `command` is threaded server → snapshot → client (and
through the client working-model so it survives projection).

## Readable rows

`splitDerivation` now yields `{ name, version }` with the `.drv` suffix
and store hash dropped: the package **name** leads, the **version**
trails dimmed, the full path stays in the row title. Applies to the tree
and the flat list.

## Vim navigation

The build panel owns `j`/`k` (move), `h` (collapse / step to parent),
`l` (expand / step to first child), `gg`/`G` (first/last), `o`/Enter
(pin the cursor's logs). Arrows mirror them. The cursor is a distinct
highlight from the click selection and scrolls into view as it moves.
The log drawer keeps only `/` and Esc so the two key handlers never
contend.

## Logs name the build

The log drawer's selection chip shows the pinned build's package name
instead of `build #<id>`.

## Bug fix: WebSocket reset seed was discarded

`runSession` did `applyDelta(working, delta)` and ignored the return.
`applyDelta` mutates in place for incremental deltas but returns a fresh
object for `reset` (the seed), so the seed — which carries the
builds/activities for a page loaded mid-build — was silently dropped
(logs survived only because their delta mutates in place). A page loaded
onto an in-progress build showed an empty tree. Fixed by capturing the
return (`working = applyDelta(...)`). This is the bug that made every
browser-rendered tree come up empty until now.

## Cleanup (existing code improved)

Shared row helpers (`whereLabel`, `isRemote`, `elapsedMs`,
`durationLabel`) moved to `lib/build-row.ts`, removing the copy in both
views. Pure tree helpers (`flattenVisible`, `hasChildren`,
`parentByDrv`) live in `build-tree.ts`, reused by the keyboard layer.

## Validation

- `nix build .#nix-web-monitor` (Rust + Svelte: svelte-check 0 errors,
eslint clean, vite build)
- **Live Playwright run against a real `nix build`**: confirmed the
single command root, `name`+dimmed-version rows with no `.drv`, vim `j`
moving the cursor, the logs chip showing `hello`, and builds/activities
rendering (regression fix). Server `/api/state` was diffed against the
browser DOM to catch the reset-seed drop.

AI-generated with Claude Opus 4.8.


<!-- Macroscope's pull request summary starts here -->
<!-- Macroscope will only edit the content between these invisible
markers, and the markers themselves will not be visible in the GitHub
rendered markdown. -->
<!-- If you delete either of the start / end markers from your PR's
description, Macroscope will append its summary at the bottom of the
description. -->
> [!NOTE]
> ### Add goal-rooted build tree, vim navigation, and readable row
labels to nix-web-monitor
> - Adds a sticky root row to the build tree labeled with the invoked
Nix command (e.g. `nix build .#foo`), showing aggregated build stats and
elapsed time; the command is seeded from the server's
[`MonitorState::new`](https://github.com/indexable-inc/index/pull/811/files#diff-b199662dc1ba66ad897244a9b7d9bf18327c06101b3425c83e9ff61f8ec6d627)
and propagated through snapshots to the UI.
> - Implements vim-style keyboard navigation (j/k/h/l, gg/G, Enter/o to
expand/collapse) in
[`BuildTable.svelte`](https://github.com/indexable-inc/index/pull/811/files#diff-c6118e9e853915cf57a07cc4b8b260cf6439944c6ef4e07cf86989dc1c6a64a9)
with cursor highlight and auto-scroll.
> - Updates derivation row labels to show `name` and `version` instead
of store hash prefixes, using a refactored
[`splitDerivation`](https://github.com/indexable-inc/index/pull/811/files#diff-d9f5256217da8c7536b8dc20f19d3ad1f66d8f8bad822ed6e2790cacddf18553)
that strips `.drv` and splits out version.
> - Extracts shared duration and host helpers into
[`build-row.ts`](https://github.com/indexable-inc/index/pull/811/files#diff-40e6796962fb82eb6d7abecbfce89d974555a898a4541b731e29fd65f351687a)
and unifies their use across `BuildTable`, `BuildTree`, and `LogPanel`.
> - Fix:
[`monitor-transport.ts`](https://github.com/indexable-inc/index/pull/811/files#diff-3600e88edc89da306e3911677f724371bfc3126f68f93276d8bc303912c1c52a)
now reassigns `working` on `applyDelta`, so a `reset` delta (initial
seed on page load) correctly replaces client state instead of being
silently discarded.
>
> <!-- Macroscope's review summary starts here -->
>
> <sup><a href="https://app.macroscope.com">Macroscope</a> summarized
672c507.</sup>
> <!-- Macroscope's review summary ends here -->
>
<!-- macroscope-ui-refresh -->
<!-- Macroscope's pull request summary ends here -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant