Skip to content

fix: encode non-finite settings floats for the browser protocol - #5

Merged
cboulay merged 4 commits into
devfrom
fix/nonfinite-float-settings
Aug 14, 2026
Merged

cboulay merged 4 commits into
devfrom
fix/nonfinite-float-settings

Conversation

@cboulay

@cboulay cboulay commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

Fixes #4.

Problem

A Settings dataclass may hold non-finite floats (clip_max: float = np.inf), and JSON has no literal for them. Neither ezmsg.core.settingsmeta._sanitize() nor the dashboard's _json_safe() treated them specially, so they reached the transport layer intact — where the two paths disagreed:

Transport Before
GET /api/settings, /api/snapshot 500 — JSONResponse renders with allow_nan=False → ValueError: Out of range float values are not JSON compliant. A single inf anywhere took the dashboard down.
/ws/events Silently lossy — envelopes go through pydantic model_dump(mode="json"), whose default ser_json_inf_nan="null" replaced the values with null.

ezmsg-sigproc no longer defaults any setting to np.inf, but nothing stops a user from providing one, so the dashboard should not fall over when they do.

Approach

Non-finite floats now travel as the string tokens "Infinity", "-Infinity" and "NaN" — spelled the way JavaScript spells them, so the frontend recovers the value with Number(token). null was rejected as lossy (indistinguishable from unset), and tagged objects as noisier for no benefit.

Backend

  • backend/json_encoding.py (new) holds encode_float / decode_float_token / sanitize_json_value.
  • adapters._json_safe() encodes on the way out. This is the single chokepoint every graph-derived payload passes through, and it runs before pydantic, so both transports are fixed in one place. (Note ser_json_inf_nan="strings" would not have worked: it only affects model_dump_json(), not the model_dump(mode="json") the event stream uses.)
  • DashboardJSONResponse re-renders through the sanitizer if a payload built outside the adapters trips allow_nan=False — a backstop for request echoes and future routes. It only pays the extra walk on failure, so large snapshot responses are unaffected.
  • 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 (resolved from the component's structured settings, so nested paths work); anything else is rejected with a 422. That way a str field whose value is literally "Infinity" is never rewritten into a float, and a string never lands in a float field. The extra settings-snapshot round-trip only happens when a token is actually being applied.

Frontend

  • utils/nonFiniteNumbers.ts (new) mirrors the wire format.
  • SettingsPanel displays the tokens, and accepts Infinity/inf/-inf/nan (Python spellings included) in float fields, encoding them back on apply.
  • Float fields use a text input, because <input type="number"> cannot hold "Infinity" — it silently reports an empty value. Integer fields keep the number input with its bounds and reject non-finite input with a clear message.

Verification

  • pytest: 42 passed. New tests/backend/test_nonfinite_float_settings.py covers the encoding, both REST routes, the websocket envelope through the real pydantic models, and the response-class backstop; new cases in test_graph_context_service.py cover the patch decode path (top-level, nested, NaN, string-field rejection, unknown-field rejection, and that the extra lookup is skipped for ordinary values).
  • npm test: 35 passed, including the new nonFiniteNumbers suite.
  • npm run test:e2e: 24 passed, including the visual baselines (which the input-type change could have disturbed) and a new test that renders timeout_s: "Infinity" from the fixture, patches it to -inf, and checks it comes back as -Infinity.
  • examples/nonfinite_float_settings.py runs a real one-unit system with inf/-inf/nan settings against a real dashboard:
--- GET http://127.0.0.1:26079/api/settings ---
status: 200
structured_value: {'gain': 1.0, 'clip_max': 'Infinity', 'clip_min': '-Infinity', 'last_value': 'NaN'}

--- GET http://127.0.0.1:26079/api/snapshot ---
status: 200
structured_value: {'gain': 1.0, 'clip_max': 'Infinity', 'clip_min': '-Infinity', 'last_value': 'NaN'}

--- ws://127.0.0.1:26079/ws/events ---
structured_value: {'gain': 1.0, 'clip_max': 'Infinity', 'clip_min': '-Infinity', 'last_value': 'NaN'}

All three probes parse with parse_constant set to raise, i.e. the payloads are valid JSON by the browser's rules.

Lint configuration (second and third commits)

CI's Lint step (uv tool run ruff check src) had gone red on dev on its own: it always installs the current ruff, the repo had no [tool.ruff] section, and ruff 0.16 widened its default rule set. Per review, the fix mirrors ezmsg-sigproc:

  • [tool.ruff] with select = ["E", "F", "I", "W"], line-length 120, and the isort first-party/third-party split (target-version is py311 here, matching requires-python).
  • ruff==0.16.2 pinned in the lint dependency group.
  • The same ruff-check --fix / ruff-format pre-commit hooks.
  • The workflow already matched sigproc's, so it is unchanged.

Under that rule set the tree needed only import ordering (ruff check --fix). ruff format is a separate commit — sigproc's tree is format-clean, so running it once here keeps the new hook from reformatting unrelated files on someone's next commit. Both are reviewable independently of the fix.

Notes

  • npm run build fails on dev already, with two pre-existing TS2345 errors in topologyFlowData.tsx about ownerKind: "scope_collection_stream". Not touched here; tsc reports no errors in the changed files.
  • The same blind spot exists upstream in ezmsg.core.settingsmeta._sanitize(). Fixing it there would help every consumer of the settings metadata, but the dashboard should stay defensive regardless since it also serializes profiling data that never goes through _sanitize.
  • Non-finite floats in profiling metrics now render as tokens rather than 500ing the snapshot. Worth a look if any chart assumes a number there.

Packaged assets (last commit)

The dashboard serves src/ezmsg/dashboard/_web, 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 here were invisible to anyone installing the package rather than running the dev server. The last commit rebuilds and re-syncs it.

Worth knowing when working on this repo: npm run build alone does not update what the server serves. The full sequence is

cd frontend && npm run build
cd .. && uv run python -m ezmsg.dashboard.build_frontend

#6 adds a CI check so this can't drift silently again.

🤖 Generated with Claude Code

cboulay and others added 3 commits August 14, 2026 11:10
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>
@cboulay
cboulay merged commit 1b8321d into dev Aug 14, 2026
11 checks passed
@cboulay
cboulay deleted the fix/nonfinite-float-settings branch August 14, 2026 23:09
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.

Settings with float datatypes set to inf cause json serialization errors

1 participant