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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user