fix(core): a non-finite cell value crosses the wire, in the spelling the format already uses (improvise-55v2)

serde_json writes a non-finite f64 as null, and null does not read back as one,
so CellValue::Number(INFINITY) serialized to {"Number":null} and the frame
carrying it was unreadable. The websocket reader skips an unparseable frame and
the worker logs a console error, so one poisoned cell silently dropped a whole
batch — and to_string returned Ok, so the send site could not notice either.

The ticket offered two answers and the format had already chosen. Measured
before changing anything: inf, -inf and nan all round-trip losslessly through
.improv — format_number writes them, the grammar's bare_value accepts them, the
walker maps them back. So refusing them before sending would make a legal saved
document unprojectable to a browser, which is a new failure rather than a fix.
It also settles the ticket's second location: resolve.rs's typed() accepting inf
is correct, not half a defect.

Number now serializes through an untagged adaptor: a JSON number when finite,
one of the three tokens when not. A finite cell's bytes are unchanged, and a
JSON integer still reads as a number rather than as a token.

The tokens are one home — nonfinite_token/parse_nonfinite beside CellValue,
called by the adaptor and by persistence/{format,parse}.rs. Three copies stood
before and a fourth was about to be written. format_f64 is deliberately not
folded in: it is the display layer and these three are a format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-22 19:00:13 -07:00
parent 6e10bb3afa
commit 5280683f12
6 changed files with 262 additions and 16 deletions
+51
View File
@@ -1217,3 +1217,54 @@ view a fresh workbook creates. Making `Workbook::create_view` refuse or rename
a name it already holds passes this test and breaks records mode, because both
rebuilders depend on the replacement — which is why the fix is a helper beside
`create_view` and not a change to it.
### model::cell::nonfinite_cell_values::a_nonfinite_number_survives_the_json_wire
improvise-55v2. `serde_json` writes a non-finite `f64` as `null`, and `null`
cannot be read back as one — so `CellValue::Number(f64::INFINITY)` serialized to
`{"Number":null}` and the frame carrying it was unreadable at the far end. The
websocket reader **skips** an unparseable frame and the worker reports only a
console error, so one poisoned cell silently dropped a whole batch. `to_string`
returned `Ok`, so nothing at the send site could notice either.
**The design question the ticket left open is already answered by the format**,
and measuring that was the useful half of this work. `.improv` carries
non-finite numbers as first-class values: `format_number` writes `inf`, `-inf`
and `nan`, the grammar's `bare_value` accepts them, and the walker maps them
back — all three round-trip losslessly, verified before anything was changed.
So "refuse them before sending" would make a legal saved document unprojectable
to a browser, and the remaining option is the one taken: give the wire an
explicit representation. It also means `resolve.rs`'s `typed()` accepting `inf`
is correct rather than the second half of a defect.
The tokens are therefore **one home**, `nonfinite_token`/`parse_nonfinite` in
`improvise-core`, called by the serde adaptor, by `persistence/format.rs` and by
`persistence/parse.rs` — where three separate copies stood before and a fourth
was about to be written. `format_f64` is deliberately not folded in: it is the
display layer (gotcha 8) and may legitimately want a different glyph.
Three rivals:
- **A plain `derive` on the `f64`** — the state at HEAD — fails this test at
`left: "{\"Number\":null}"` and also fails the io-side agreement test.
- **Drifting the shared token** (`-inf` → `-infinity`) fails this test, the
agreement test, and `generated_file_roundtrips`, because the writer and the
reader stop agreeing.
- **Re-copying the tokens into `persistence/format.rs`** so there are two homes
again, drifted, leaves *this* test passing and fails the agreement test and
the grammar round-trip. That is the rival the agreement test exists for: a
second home that happens to agree is harmless, and one that does not is what
must fail.
The companion `an_ordinary_number_is_still_a_json_number` is the direction the
fix could quietly break: it pins `{"Number":1.5}` exactly, and that a bare JSON
integer still reads as a number rather than being mistaken for a token.
### persistence::nonfinite_agreement::a_nonfinite_cell_is_spelled_the_same_on_disk_and_on_the_wire
improvise-55v2's other half. It writes each of the three values through
`format_md`, reads the token off the `## Data` line, reads the token off
`serde_json`, and requires them equal — then reparses and requires the value
back. It is the guard that makes "one home" checkable rather than asserted: the
two carriers are compared against each other, so a future second copy fails here
exactly when it drifts, which is the only time it matters.