fix(store): round-trip VAL_BUFFER through ext_store instead of encoding it as null - #814
Conversation
…ing (InauguralSystems#805) store_json_encode wrote a VAL_BUFFER as JSON `null`, so `store_put of [db, "c", {"b": buf}]` followed by store_get handed back a null and the bytes were gone — the data-loss gap the InauguralSystems#738 exhaustiveness sweep made visible at the code site. A buffer now encodes as a tagged object, {"_eigs_buffer":[…],"_eigs_shape":[rows,cols]}, that the store's decoder rebuilds as a VAL_BUFFER at any nesting depth. The issue's other option — a bare numeric array — round-trips as a LIST, and a store that silently changes a value's type is the same class of bug as the one being fixed. Shape travels with it (`shape of` reads rows/cols back), elements use %.17g so a non-integral buffer comes back bit-exact (matching trace.c's buffer tape encoding), and a non-finite element rides as "inf"/"-inf"/ "nan" — reachable, since the flat-buffer matmul kernel accumulates unguarded, and a bare `inf` would make the whole RECORD unparseable. The tag's cost, stated rather than hidden: a user dict with exactly the two tag keys, a body list of numbers (or those sentinels), and a shape list of two integers satisfying rows*cols == count (or [0,0]) now decodes as a buffer. Nothing else does — a third key, a missing key, a non-list body, a foreign string element, or an inconsistent shape all stay a dict, and a top-level record is never at risk because store_put stamps _id onto it. There was no existing type-tag convention to follow; the `_eigs_` prefix matches the file's `_`-prefixed reserved names (_id, _store, _store_id, _type), and trace.c's `b[...]` form belongs to the tape's own non-JSON grammar. The store is file-backed, so old databases exist. STORE_VERSION is deliberately not bumped — store_read_header rejects any other version, so a bump would make every existing database unopenable to fix a value that was already lost. Old records decode exactly as before (they never contained the tag); an older binary reading a new file sees a plain dict rather than failing. test_store.eigs gains a mid-file buffer group: zero bytes, 0xFF/0xFE (invalid UTF-8), fractional and negative elements, a non-finite element, an empty buffer, both degenerate `buffer of [0, n]` / `[n, 0]` shapes, a 2-D shape, persistence across close/reopen, and the negative cases that prove an ordinary dict or list is not swallowed by the tag. On upstream/main the group fails at its first check; here it passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fb7b23b to
d2da64c
Compare
|
CodeQL flagged the two float comparisons on The alert itself is a false positive. Line 321 is an integrality test — round-trip the stored dimension through Eleven constructed boundary cases, fed to the decoder directly by hand-building a byte-valid store file (the encoder normalises integral values to
11/11 correct, in both directions. The second row is the direct refutation of what the alert implies. But line 320 — the cast that produces those operands — was a real defect, and it is now fixed. Worth knowing why the ASan+UBSan job never saw it: GCC's The answers were right only because x86-64's The fix range-gates before narrowing, which also folds in the now-redundant Two things for you rather than for the diff:
One correction to my own PR body: the branch adds 30 counted checks, not 32 (22 → 52 in that file). I have corrected the body. |
| if (!(rd >= 0 && rd <= (double)INT_MAX)) return NULL; | ||
| if (!(cd >= 0 && cd <= (double)INT_MAX)) return NULL; | ||
| int rows = (int)rd, cols = (int)cd; | ||
| if (rd != (double)rows || cd != (double)cols) return NULL; |
| if (!(rd >= 0 && rd <= (double)INT_MAX)) return NULL; | ||
| if (!(cd >= 0 && cd <= (double)INT_MAX)) return NULL; | ||
| int rows = (int)rd, cols = (int)cd; | ||
| if (rd != (double)rows || cd != (double)cols) return NULL; |
InauguralPhysicist
left a comment
There was a problem hiding this comment.
Approving. Verified on current main (8de9715 + #811):
- Planted fault reproduced exactly: your test file against main's
ext_store.cfails withFAIL: buffer field decodes as a buffer — expected buffer, got none; with yourext_store.crestored,[56]reports all 52 checks and the standalone run is 52/52 under ASan+UBSan withdetect_leaks=1— the new decoder paths (near-miss rejections, sentinels, nesting, reopen) are sanitizer-clean. - The decoder's hostile-input posture is right: range-gate before the int narrowing with the UB citation, integrality by exact equality (correct operator, as you say — a tolerance would be the bug), the shape invariant in int64 so
rows*colscan't overflow, elements validated before the allocation, and the triple-set restricted to what the constructors can produce. Thexcalloc+ manual-fill construction matches the buffer idiom used byread_bytes_bufand friends, so no allocator-discipline mismatch. - The judgment calls all land where I'd have put them: tagged over numeric-array (type fidelity is the point of the fix),
STORE_VERSIONnot bumped (bricking every existing database to recover already-lost data would be backwards), the collision surface stated as a real residual rather than argued away — and the_idstamp making top-level records immune is the observation that makes it acceptable. - Keep the matmul-coupled sentinel check. You offered to drop it; don't. It pins the only coverage of the non-finite path, and if
num_guardever lands inne_matmul_bufthe failure will be loud, explainable, and a one-line test fix — better than the path silently going uncovered.
Merging.
What does this PR do?
store_json_encodeencodedVAL_BUFFERas JSONnull, so a buffer stored through ext_store came back as null and the data was silently gone. This adds a tagged encoding the decoder recognises and rebuilds as a realVAL_BUFFER, shape included.Related Issues and Pull Requests
Fixes #805
Changes
src/ext_store.c:VAL_BUFFERmoves off the null path onto a real encoder. Format is{"_eigs_buffer":[…],"_eigs_shape":[rows,cols]}; the decoder resolves both keys by name, so key order is not load-bearing, and it runs at the object's closing brace so a buffer is rebuilt at any nesting depth. The-Werror=switchexhaustiveness you landed in runtime: ValType switches go exhaustive, finishing #738 — first build caught real drift #806 is preserved — nodefault:reintroduced, the remainingVAL_NULL/VAL_FN/VAL_BUILTIN/VAL_JSON_RAW/VAL_TEXT_BUILDERcases stay enumerated.CHANGELOG.md: the format change and the collision disclosure below.tests/test_store.eigs: 30 new checks (22 -> 52 in that file), mid-file in the group position the file already uses.Why tagged and not a numeric array. A buffer carries an observable shape as well as a type —
shape of bufreadsrows/colsback (eigenscript.h:321) — so the array option loses both, and a store that silently changes a value's type on round-trip is the same class of bug as the one being fixed.Element width is
%.17g, matchingtrace.c's buffer tape (trace.c:1254), which is also the nearest existing precedent for "disambiguate and rebuild the real type" — itsb[…]form does exactly that on replay. There is no pre-existing type-tag convention inext_store.cto follow; the only reserved names are the_-prefixed record fields (_id,_store,_store_id,_type), and_eigs_collides with nothing in the tree.Non-finite elements ride as
"nan"/"inf"/"-inf", and that is not defensive dead code.ne_matmul_bufaccumulates with a bare+=and nonum_guard(builtins_tensor.c:39-48), somatmulon large values genuinely reaches+Inf. A bareinftoken would fail the strict parser and cost the whole record, not just the buffer — i.e. without this the change would have made one case strictly worse than it is today.Testing
Merge-base
14c3a07. Release suite 3394/3394, exit 0. ASan+UBSan withdetect_leaks=13398/3398, exit 0, zero sanitizer findings and no LeakSanitizer tally line. Section[56] EigenStore DatabasereportsPASS: all 52 store checksin both.tools/doc_drift_check.shexit 0 after the CHANGELOG edit.The new checks are mutation-proven against a pre-fix build of
main:and
Tests: 52 | Pass: 52 | Fail: 0on this branch. The check count is 30, not the 32 I first wrote. Coverage includes a zero byte, invalid-UTF-8 bytes, non-finite elements, nesting inside a list, persistence across a close/reopen, and four near-miss dicts that must not decode as buffers.Follow-ups / Known Limitations
_eigs_bufferand_eigs_shape, the body is a list of numbers or the three sentinel strings, the shape is two non-negative integers, and the shape invariant holds. Anything else stays a dict — verified live for a third key, a non-list body, a foreign string element and an inconsistent shape. But a nested field genuinely shaped like the tag does come back a buffer; I confirmed that rather than assuming it away. A top-level record is never at risk, sincestore_putstamps_idand so it always carries a third key. I considered encode-side escaping and rejected it: it cannot fix data already on disk, and it adds a second ambiguity class of its own.STORE_VERSIONdeliberately not bumped (ext_store_internal.h:10).store_read_headerrejects any version ≠STORE_VERSION, so bumping would make every existing database unopenable in order to recover a value that was already lost. Old records decode exactly as before (they contain no tag), and an older binary reading a new file sees a plain dict rather than a parse failure.nullcost four, so a large buffer can push a record pastSTORE_MAX_RECORD_SIZEand raiserecord exceeds page size. That is the same per-record limit a long list already hits, and a loud refusal seems like the right outcome for this issue — but it is a change.ne_matmul_bufbeing unguarded. If you later addnum_guardthere, that check goes red for a reason unrelated to the store. I kept it so the sentinel path cannot silently stop being covered, but it is easy to drop if you'd rather.trace.c/write_value_ptr_fullgap you noted in ext_store silently encodes VAL_BUFFER as null — buffers are lost on store round-trip #805's last paragraph is untouched, as you filed it.Checklist
make testpasses locally — 3394/3394 release, 3398/3398 under ASan+UBSan with leak detectiondocs/BUILTINS.md— n/a, no new builtinsdocs/STDLIB.md— n/aGenerated by Claude Opus 5 (brief, implementation, review)