Repository navigation
fix: encode non-finite settings floats for the browser protocol - #5
Merged
Merged
Conversation
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>
This was referenced Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4.
Problem
A
Settingsdataclass may hold non-finite floats (clip_max: float = np.inf), and JSON has no literal for them. Neitherezmsg.core.settingsmeta._sanitize()nor the dashboard's_json_safe()treated them specially, so they reached the transport layer intact — where the two paths disagreed:GET /api/settings,/api/snapshotJSONResponserenders withallow_nan=False→ValueError: Out of range float values are not JSON compliant. A singleinfanywhere took the dashboard down./ws/eventsmodel_dump(mode="json"), whose defaultser_json_inf_nan="null"replaced the values withnull.ezmsg-sigprocno longer defaults any setting tonp.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 withNumber(token).nullwas rejected as lossy (indistinguishable from unset), and tagged objects as noisier for no benefit.Backend
backend/json_encoding.py(new) holdsencode_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. (Noteser_json_inf_nan="strings"would not have worked: it only affectsmodel_dump_json(), not themodel_dump(mode="json")the event stream uses.)DashboardJSONResponsere-renders through the sanitizer if a payload built outside the adapters tripsallow_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 astrfield 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.SettingsPaneldisplays the tokens, and acceptsInfinity/inf/-inf/nan(Python spellings included) in float fields, encoding them back on apply.<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. Newtests/backend/test_nonfinite_float_settings.pycovers the encoding, both REST routes, the websocket envelope through the real pydantic models, and the response-class backstop; new cases intest_graph_context_service.pycover 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 newnonFiniteNumberssuite.npm run test:e2e: 24 passed, including the visual baselines (which the input-type change could have disturbed) and a new test that renderstimeout_s: "Infinity"from the fixture, patches it to-inf, and checks it comes back as-Infinity.examples/nonfinite_float_settings.pyruns a real one-unit system withinf/-inf/nansettings against a real dashboard:All three probes parse with
parse_constantset 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 ondevon 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 mirrorsezmsg-sigproc:[tool.ruff]withselect = ["E", "F", "I", "W"], line-length 120, and the isort first-party/third-party split (target-versionispy311here, matchingrequires-python).ruff==0.16.2pinned in thelintdependency group.ruff-check --fix/ruff-formatpre-commit hooks.Under that rule set the tree needed only import ordering (
ruff check --fix).ruff formatis 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 buildfails ondevalready, with two pre-existingTS2345errors intopologyFlowData.tsxaboutownerKind: "scope_collection_stream". Not touched here;tscreports no errors in the changed files.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.Packaged assets (last commit)
The dashboard serves
src/ezmsg/dashboard/_web, a committed copy offrontend/distproduced bypython -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 buildalone does not update what the server serves. The full sequence is#6 adds a CI check so this can't drift silently again.
🤖 Generated with Claude Code