Skip to content

Release: non-finite settings, inspector UX, stale shell and shutdown fixes - #14

Merged
cboulay merged 30 commits into
mainfrom
dev
Aug 15, 2026
Merged

cboulay merged 30 commits into
mainfrom
dev

Conversation

@cboulay

@cboulay cboulay commented Aug 15, 2026

Copy link
Copy Markdown
Member

30 commits, 42 files, +2812/−487. Closes #4.

Everything here has been through its own PR (#3, #6, #8, #10, #11, #12, #13) plus a holistic review of this diff, whose findings were folded back in as #13.


Things to know before upgrading

1. Everyone needs one hard reload, once

index.html was served with no Cache-Control, so browsers reused the cached shell indefinitely — and because Vite emits content-hashed filenames, a stale shell keeps naming the bundle it was built against. That is fixed (#12), but the fix can only take effect once the browser fetches the new shell. Anyone with an existing tab should hard reload (Cmd/Ctrl+Shift+R) after upgrading. After that it never needs doing again: the shell is no-store and /assets/* is immutable.

This is also why the previous release's features could look missing: the packaged bundle under src/ezmsg/dashboard/_web had drifted ~2 months behind the frontend source. It is current here, and CI now fails when it drifts.

2. Non-finite floats change shape on the wire

inf, -inf and nan in settings and profiling payloads now travel as the strings "Infinity", "-Infinity", "NaN" on /api/settings, /api/snapshot and /ws/events. Previously the REST routes returned 500 and the websocket silently replaced the values with null, so any pipeline with clip_max: float = np.inf took the dashboard's settings view down entirely (#4).

The bundled UI displays and accepts these (typing inf, -inf, nan or the JavaScript spellings into a float field works, and patches are decoded back to floats server-side). If you have your own client parsing these endpoints, it will now see strings where it previously saw numbers or a failed request.

3. The Publishers pane hides settings channels

A unit with dynamic settings gets a publisher on <unit>/INPUT_SETTINGS the first time one of its settings is patched. It is a control-plane endpoint that publishes nothing afterwards, so it sat in the list at 0.0 Hz offering a profiling trace that could never produce a sample. Those rows are hidden by default; re-enable with Show settings channels in Publishers (debug) in global settings.

4. Ctrl+C behaves

It used to end in a KeyboardInterrupt traceback, and with a browser attached it took ten seconds to get there — long enough that people pressed it twice, which tore the loop down mid-shutdown and made the output worse. Shutdown is now prompt and quiet: measured 10.05s → 0.16s with a live websocket, exit code 0.


What else is new

Inspector

  • Drag the divider between the topology and the inspector to trade width. Double-click resets it; arrow keys nudge it 16px; Home/End jump to the extremes. The width persists and still appears as Inspector Width in global settings.
  • The old fixed 900px maximum is gone. The inspector is now bounded by what the topology needs (480px minimum), enforced in the grid so a width stored on a wide monitor cannot squash the graph on a smaller screen.
  • Clicking a unit in the topology focuses both panels: Settings expands that component, and Publishers expands the topics it publishes. A row that is capturing a trace stays expanded so its stop control does not disappear.

Correctness

  • Client-side routes (any extensionless path) now serve the app shell instead of 404ing — the SPA fallback existed but was unreachable, because StaticFiles signals a miss by raising rather than returning a 404.
  • Non-finite profiling metrics display as Infinity/NaN rather than 0.0 Hz, which previously looked identical to an idle publisher.
  • Relay rendering and neutral topic/relay placement (Fix: Relay Topology Collapse and Neutral Stream Layout #3, pre-existing on dev).

Repo health (no user impact, but it is why this diff is trustworthy)

  • Ruff configuration adopted from ezmsg-sigproc (E,F,I,W, line-length 120) with the matching pre-commit hooks, plus a one-time ruff format pass. CI's lint step had silently gone red on dev when ruff widened its defaults.
  • npm run build was failing on dev with two type errors; fixed, and CI now builds the frontend, because neither vitest nor the e2e dev server typechecks.
  • CI fails when src/ezmsg/dashboard/_web does not match a fresh build. It caught real drift on the first PR after it landed.

Verification

pytest 57 · npm test 40 · npm run test:e2e 32 (incl. visual baselines) · production build clean · ruff and format clean · packaged assets in sync with a fresh build.

Beyond the suites, the significant paths were exercised against a live 90-component pipeline (3 processes, 192 topics, 572 KiB snapshots): REST and websocket payloads parse under a browser-strict JSON parse, settings render and patch, profiling traces stream and plot, and Ctrl+C exits cleanly with seven connections open.

🤖 Generated with Claude Code

griffinmilsap and others added 30 commits April 10, 2026 15:47
Fix: Relay Topology Collapse and Neutral Stream Layout
Settings classes are free to hold non-finite floats (clip_max: float = np.inf
is perfectly reasonable) and JSON has no literal for them. Neither ezmsg's
settings metadata pass nor the dashboard's _json_safe adapter treated them
specially, so they reached the transport layer intact, where the two paths
disagreed:

- REST: JSONResponse renders with allow_nan=False, so /api/settings and
  /api/snapshot returned 500 with "Out of range float values are not JSON
  compliant" -- one inf anywhere took the whole dashboard down.
- WebSocket: envelopes go through pydantic model_dump(mode="json"), whose
  default ser_json_inf_nan="null" silently replaced the values with null.

Non-finite floats now travel as the string tokens "Infinity", "-Infinity" and
"NaN" (spelled so the browser recovers them with Number(token)):

- backend/json_encoding.py holds the encode/decode/sanitize helpers.
- adapters._json_safe encodes on the way out, which covers both transports
  since it runs before pydantic sees the payload.
- DashboardJSONResponse re-renders with the sanitizer if a payload built
  outside the adapters trips allow_nan=False.
- update_setting_field decodes tokens back to floats before handing them to
  ezmsg, which writes the value into the dataclass as-is. Only fields that
  currently hold a number are decoded; anything else is rejected with a 422
  so a string can never land in a float field.
- The settings panel displays the tokens, accepts inf/-inf/nan (and the
  Python spellings) in float fields, and encodes them back on apply. Float
  fields use a text input because a number input cannot hold "Infinity";
  integer fields keep the number input and require a finite value.

examples/nonfinite_float_settings.py runs a real system with inf/-inf/nan
settings and probes both transports end to end.

Fixes #4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs `uv tool run ruff check src` with whatever ruff is current, and the
repo had no [tool.ruff] section, so ruff 0.16's wider default rule set turned
the Lint step red on dev independently of any code change.

Pin the rule set the way ezmsg-sigproc does (select = E, F, I, W with an isort
first-party/third-party split), pin ruff itself in the lint dependency group,
and add the matching ruff-check/ruff-format pre-commit hooks. The workflow
already matched sigproc's, so it needs no change.

The only code effect is import ordering, applied with `ruff check --fix`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff format` is one of the pre-commit hooks adopted from ezmsg-sigproc, whose
tree is already format-clean. Run it once here so the hook starts clean instead
of reformatting unrelated files on the next contributor's first commit.

Formatting only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard serves `src/ezmsg/dashboard/_web`, which is a committed copy of
`frontend/dist` produced by `python -m ezmsg.dashboard.build_frontend`. It had
not been refreshed since the relay rendering work, so the settings panel changes
in this branch -- non-finite floats rendered as Infinity/-Infinity/NaN -- were
invisible to anyone who installed the package rather than running the dev server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run build` (tsc -b && vite build) has been failing on dev: the
`streamOwnerByAddress` map's value type and `registerStreamOwner`'s parameter
type were two hand-maintained copies of the same union, and only the parameter
gained "scope_collection_stream", so every call to `.set()` was a type error.
The map already held those values at runtime -- only the type was wrong.

Declare the union once as `StreamOwnerKind`/`StreamOwner` and use it in both
places, which fixes the errors and removes the drift that caused them.

Nothing in CI ran `tsc`: vitest strips types without checking them, and the e2e
suite serves the app with `vite dev`, which does the same. Build the frontend in
the unit-tests job so the next type error is caught here rather than by whoever
next runs a release build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard serves `src/ezmsg/dashboard/_web`, a committed copy of
`frontend/dist`, and nothing verified the two agreed. They had been out of sync
since the relay rendering work, so the packaged dashboard served a bundle
months older than the source -- `npm run build` alone does not update it.

`build_frontend --check` already existed for exactly this; run it right after
the build step added in this branch, with the fix spelled out in the failure
message. Requires `uv sync` because importing the package pulls in FastAPI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a unit in the topology already snaps the Settings panel to that
component. Do the same in Publishers: reveal every row whose owning component
is the selected unit, and scroll the first one into view.

The panel already had this machinery for publisher and subscriber stream
selections, so the three focus branches now share one `revealRows` helper
rather than repeating the expand/scroll block a third time.

Two deliberate choices:

- The Publishers section's collapsed state is left alone. Collapsing a section
  is a deliberate "maximize the other one" action, so a unit click should not
  undo it -- the panel is unmounted while collapsed and picks the request up
  when it is expanded again.
- A focus request that matches no rows is not marked handled, so a unit whose
  publishers only show up in a later snapshot still gets focused then.

Collections are untouched: their streams belong to their child units, and
selecting one continues to focus Settings only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split between the topology and the Publishers/Settings inspector was only
adjustable through the `Inspector Width` number field in global settings. Make
the border between them a real divider: drag it to trade width between the two,
double-click to return to the default, or focus it and use the arrow keys
(`Home`/`End` jump to the extremes).

The width still lives in `globalSettings.inspectorWidthPx`, so it persists and
stays in sync with the existing number field, and the 360-900px clamp -- until
now spelled out separately in three places -- is a single `clampInspectorWidth`.

While dragging, the live width is held in component state and only committed to
global settings on release, so a drag is one localStorage write rather than one
per frame. The handle overlays the existing border rather than taking a grid
column, so nothing shifts when it appears.

Resizing deliberately does not re-fit the topology view, matching what the
number field has always done -- a re-fit would throw away the user's pan/zoom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the publishers focus and draggable divider changes so the packaged
dashboard serves them, and keeps the new CI staleness check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+C ended in a KeyboardInterrupt traceback, and with a browser attached it
took ten seconds to get there. Two causes:

uvicorn shuts the application down gracefully on SIGINT and then re-raises the
signal so that callers see conventional interrupt semantics. For a foreground
server that only amounts to a traceback printed after a clean shutdown, so
serve_dashboard now swallows it and logs "Dashboard stopped." instead. Real
failures from `server.run()` still propagate.

The delay was the event stream. The `/ws/events` handler only woke when the
graph service yielded, so a quiet stream held the connection -- and with it the
server shutdown -- until the next heartbeat, ten seconds away. Long enough that
Ctrl+C looks hung and gets pressed again, and the second press tears the loop
down mid-flight, which is where the messiest output came from. The handler now
watches for the disconnect concurrently and stops as soon as one arrives.

Measured with a live websocket: 10.05s to shut down, before; 0.16s, after.
A second Ctrl+C no longer has time to land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: repair the frontend type build and check it in CI
feat: inspector UX — publishers follow unit selection, draggable divider
fix: shut the dashboard down quietly on Ctrl+C
A unit with dynamic settings gets a publisher on `<unit>/INPUT_SETTINGS`,
created lazily the first time one of its settings is patched. It is a
control-plane endpoint: it carries settings updates and publishes nothing the
rest of the time, so it sits in the publishers list at 0.0 Hz looking exactly
like a data publisher -- including offering a profiling trace that can never
produce a sample, which reads as a broken trace rather than an idle topic.

Hide them by default, with `Show settings channels in Publishers (debug)` in
global settings to bring them back.

Found on a live pipeline where one of 88 publishers was a settings channel:
`HUB1/SDA/INPUT_SETTINGS`, created twenty minutes after the process it belongs
to, at the moment SDA was first patched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilt so `src/ezmsg/dashboard/_web` carries the settings-channel filter. The
staleness check added in #6 is now on dev and fails without this, which is
exactly the drift it was added to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`index.html` went out with no `Cache-Control`, so browsers applied heuristic
freshness and reused the cached shell without revalidating. Because Vite emits
content-hashed filenames, that shell keeps naming the bundle it was built
against: a rebuilt, restarted dashboard serves the old app indefinitely and
nothing short of a hard reload discovers the new one. A live session showed
only `/api/*` traffic -- the shell had last been fetched the day before.

Send the no-store headers that the JSON routes already use with the shell, and
mark `/assets/*` `immutable`: those names are content-hashed, so they can never
go stale, which is what makes an uncacheable shell cheap.

Fixing this also required repairing the SPA fallback it shares a code path with.
StaticFiles signals a miss by *raising* Starlette's HTTPException rather than
returning a 404, so the `if response.status_code != 404` branch was unreachable
and client-side routes 404ed instead of serving the shell. Catch the exception
instead -- Starlette's class, not FastAPI's subclass, which is what is raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StaticFiles` hands `get_response` a path normalized for the host OS, so on
Windows it arrives as `assets\index-abc123.js` and the `assets/` prefix check
never matched: the immutable header was silently dropped and the Windows CI
jobs failed on it.

Normalize separators before deciding. Going through `Path.as_posix()` looks
tidier but only reads a backslash as a separator when running on Windows, which
would have left the behaviour untestable anywhere else -- the parametrized test
covers both separator styles on every platform.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: stop the browser serving a stale app shell
Three findings from the dev->main review, plus a docs gap.

Non-finite profiling metrics displayed as 0. `toNumber` mapped anything that
was not a finite number to zero, and non-finite floats now arrive as tokens, so
a rate of NaN rendered as `0.0 Hz` -- indistinguishable from an idle publisher,
which is precisely the confusion that makes an empty trace look broken. Decode
tokens instead and let the value stay non-finite; `toFixed` spells it out
("Infinity Hz") and the consumers that do arithmetic on rates already guard.

Selecting a unit collapsed a publisher row that was capturing, hiding its stop
control while the capture kept running. Keep active trace rows expanded when
the list refocuses.

The inspector's 900px ceiling was arbitrary, and on a narrow window it still
allowed the topology to be squeezed to a sliver. Bound the inspector by what
the topology needs instead: at least 480px, enforced in the grid template too,
so a width stored on a wide monitor cannot squash the graph after a resize.

The global settings list in the user guide did not mention the settings-channel
toggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: review follow-ups on metrics, trace controls and inspector width
@cboulay
cboulay merged commit 15cfbdd into main Aug 15, 2026
11 checks passed
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.

2 participants