Summary
JSON.stringify(obj) returns the literal string "null" when obj is the result of JSON.parse(...) and has 9 or more fields. Object-literal counterparts with the same shape stringify correctly. Field access on the parsed object works (the data is there) — only re-serialization is broken.
This silently broke perry-hub: every POST /api/v1/build request had its manifest parsed correctly, but when the hub re-stringified job.manifest to send job_assign over WS to a worker, the manifest field became null. Workers received {"type":"job_assign","manifest":null,...} and rejected with "missing field app_name" — every CI build failed sub-second with no error in the user-visible output.
Repro (minimal)
repro.ts:
// Object literal — works at any size
const lit9 = {a:1,b:2,c:3,d:4,e:5,f:6,g:7,h:8,i:9};
console.log("literal-9: " + JSON.stringify(lit9));
// literal-9: {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9}
// JSON.parse output — broken at >=9 fields
const src = '{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9}';
const round = JSON.parse(src);
console.log("typeof parsed=" + typeof round); // object
console.log("round.a=" + round.a + " round.i=" + round.i); // 1, 9
console.log("re-stringify: " + JSON.stringify(round)); // null ← BUG
perry compile repro.ts -o repro && ./repro
Output:
literal-9: {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9}
typeof parsed=object
round.a=1 round.i=9
re-stringify: null
Threshold
Iterated n = 1..20 constructing dynamic objects via obj["field_" + i] = i:
| n |
result |
| 1–8 |
full JSON, length scales linearly |
| 9 |
"null" (length 4) |
| 10–20 |
"null" |
The boundary is exactly at n = 9 (the same as JSON.parse-generated objects). Tightly suggests an internal [T; 8] or capacity-8 buffer in the runtime's JSON.stringify object emit path that overflows silently and falls through to a null fallback.
Object literals construct via a different path (the codegen emits direct field stores into a literal-object header) and aren't affected — only objects constructed dynamically (via obj[k] = v or JSON.parse) trip the bug.
Suspected location
crates/perry-runtime/src/json.rs — stringify_object_inner and the shape-template fast path it dispatches to (shape_template_for / try_emit_shape_element / build_shape_prefix_template). The fast path activates at num_fields >= 5, but the broken threshold is 9 — so it's likely a secondary structure (perhaps keys_array capacity, or one of the inline buffers in ObjectHeader/ArrayHeader at runtime) sized 8 that gets exceeded for parser-built objects but not literal-built ones. Worth grepping that file for 8/MAX_INLINE_KEYS/similar caps.
Impact
- Any program that round-trips JSON (parse, mutate or pass through, stringify) silently corrupts payloads of 9+ fields.
- Perry-hub: every CI build job rejected with no actionable error. Rolled back to a hub binary built with perry 0.5.162 to recover.
- Likely affects any HTTP server forwarding JSON, any database row mapper, anything that parses then re-emits.
Environment
- perry version: 0.5.390 (also reproduces at 0.5.349, 0.5.388 — appears to have been introduced sometime between 0.5.162 and 0.5.349; an older perry 0.5.162 binary stringifies the same input correctly)
- Platform-independent — pure runtime/codegen issue.
Suggested fix beyond the immediate cap-bump
Once the underlying buffer is enlarged or generalized: make the JSON.stringify fall-through return the actual error (or panic in debug) rather than the string "null". The current behavior is the worst possible failure mode — same exit path as legitimately stringifying null, which is why the hub silently corrupted job dispatch for two days before anyone could pin it down. A unique sentinel or a thrown TypeError would have surfaced this on the first job.
Summary
JSON.stringify(obj)returns the literal string"null"whenobjis the result ofJSON.parse(...)and has 9 or more fields. Object-literal counterparts with the same shape stringify correctly. Field access on the parsed object works (the data is there) — only re-serialization is broken.This silently broke perry-hub: every
POST /api/v1/buildrequest had its manifest parsed correctly, but when the hub re-stringifiedjob.manifestto sendjob_assignover WS to a worker, the manifest field becamenull. Workers received{"type":"job_assign","manifest":null,...}and rejected with "missing fieldapp_name" — every CI build failed sub-second with no error in the user-visible output.Repro (minimal)
repro.ts:Output:
Threshold
Iterated
n = 1..20constructing dynamic objects viaobj["field_" + i] = i:"null"(length 4)"null"The boundary is exactly at n = 9 (the same as JSON.parse-generated objects). Tightly suggests an internal
[T; 8]or capacity-8 buffer in the runtime's JSON.stringify object emit path that overflows silently and falls through to anullfallback.Object literals construct via a different path (the codegen emits direct field stores into a literal-object header) and aren't affected — only objects constructed dynamically (via
obj[k] = vorJSON.parse) trip the bug.Suspected location
crates/perry-runtime/src/json.rs—stringify_object_innerand the shape-template fast path it dispatches to (shape_template_for/try_emit_shape_element/build_shape_prefix_template). The fast path activates atnum_fields >= 5, but the broken threshold is 9 — so it's likely a secondary structure (perhapskeys_arraycapacity, or one of the inline buffers inObjectHeader/ArrayHeaderat runtime) sized 8 that gets exceeded for parser-built objects but not literal-built ones. Worth grepping that file for8/MAX_INLINE_KEYS/similar caps.Impact
Environment
Suggested fix beyond the immediate cap-bump
Once the underlying buffer is enlarged or generalized: make the JSON.stringify fall-through return the actual error (or panic in debug) rather than the string
"null". The current behavior is the worst possible failure mode — same exit path as legitimately stringifyingnull, which is why the hub silently corrupted job dispatch for two days before anyone could pin it down. A unique sentinel or a thrown TypeError would have surfaced this on the first job.