Scope: Perry versions 0.5.203–0.5.211
Subsystem: crates/perry-runtime/src/json.rs, crates/perry-runtime/src/json_tape.rs,
crates/perry-runtime/src/array.rs (lazy-aware dispatch), crates/perry-runtime/src/gc.rs
(tracer), crates/perry-hir/src/lower.rs (pragma detection), crates/perry-codegen/src/expr.rs
(IndexGet dispatch + Array.isArray runtime fallback).
Audience: external security / correctness auditors evaluating Perry's JSON runtime.
Reading order: this document is self-contained; no prior knowledge of Perry's
codebase required. Implementation details reference files + line ranges so each
claim can be verified directly against the source.
Perry historically parsed JSON by running a recursive-descent parser over the input
bytes that produced a full in-memory tree of JSValues — the same representation
the language runtime uses for every other object. This is the obvious implementation
and matches Node's JSON.parse behavior from the user's point of view.
Versions 0.5.203 through 0.5.211 add a second, opt-in-then-default parse strategy
called the "tape-based lazy parse" (or just "lazy parse"). In current auto-mode,
top-level JSON arrays from 1 KB through 16 MB build a compact tape (a flat
Vec<TapeEntry> of
structural positions — object / array starts/ends, key positions, scalar positions)
instead of a tree. The tape is stored on a LazyArrayHeader alongside a sparse
per-element materialization cache. When user code later reads elements, the runtime
materializes exactly the touched subtrees on demand. When user code stringifies
without touching, the runtime memcpys the original blob bytes.
The design is semantically identical to eager JSON.parse from the TypeScript
user's perspective. Every user-observable operation (length, indexed access,
iteration, mutation, identity, stringify) produces byte-for-byte identical output
compared to Node's JSON.parse, verified across an extensive test matrix
(Section 10).
The flip from "opt-in via PERRY_JSON_TAPE=1" to "default-on above 1024 bytes"
happened in 0.5.211 after the runtime adaptive handling (Sections 5.4, 5.5)
eliminated every measured workload shape where lazy was slower or heavier than
eager. Later tuning briefly raised the auto lower bound to 64 KB for small
iterate-all payloads (#437); #1090 restored the current 1 KB lower bound so
RSS-sensitive parse-churn loops use the tape path once payloads exceed the tiny
direct threshold. The PERRY_JSON_TAPE=0 environment variable is a
correctness escape hatch that forces the eager parser unconditionally.
Safety claim: the lazy path is observationally equivalent to the eager path under single-threaded execution. This document proves that claim by exhaustive case analysis of every user-observable operation against the runtime's dispatch tables (Section 5) and tracer (Section 6).
- Eager parse / direct parse: the pre-existing
DirectParserincrates/perry-runtime/src/json.rs. Reads the blob byte-by-byte and builds a full JSValue tree. - Tape: a
Vec<TapeEntry>, one entry per structural token in the JSON input. Each entry is 12 bytes:offset: u32(byte position in blob),kind: u8(one of 10 structural kinds),link: u32(for container kinds, the tape index of the matching end marker).crates/perry-runtime/src/json_tape.rs:37-47. - LazyArrayHeader: an arena-allocated struct that holds a tape,
a reference to the source blob, a sparse materialization cache, a
materialization bitmap, a walk cursor, and a cumulative walk counter.
Layout documented in Section 4.
crates/perry-runtime/src/json_tape.rs:657-717. - Force-materialize: convert a
LazyArrayHeaderinto a fullArrayHeader-backed tree. Happens when user code mutates the array, whenArray.prototypemethods run, when certain adaptive thresholds trip, and when stringify sees a partially-populated sparse cache.json_tape.rs::force_materialize_lazy, lines 802-880. - Sparse cache: an arena-allocated array of
JSValues, one slot per top-level element of the lazy array, with an accompanying bitmap indicating which slots hold materialized values. Guarantees identity:parsed[i] === parsed[i]always holds because a cache hit returns the exact same JSValue it returned last time. - Walk cursor: a
(walk_idx: u32, walk_tape_pos: u32)pair on the header that records the most recent indexed access's element index and tape position. Lets sequential access amortize to O(1) per element instead of O(n) per element. - Blob: the user's original input string to
JSON.parse. Owned by aStringHeaderwhose lifetime is extended via a reference in theLazyArrayHeaderand traced by the GC.
The lazy path fires only for parses whose top-level JSON value is an
array and whose input byte length meets a threshold. The decision is made
in a single function at crates/perry-runtime/src/json.rs:967-998:
const LAZY_MIN_BLOB_BYTES: usize = 1024;
const LAZY_MAX_BLOB_BYTES: usize = 16 * 1024 * 1024;
let tape_mode = tape_mode_from_env();
let use_tape = match tape_mode {
TapeMode::ForceOn => true,
TapeMode::ForceOff => false,
TapeMode::Auto => len >= LAZY_MIN_BLOB_BYTES && len <= LAZY_MAX_BLOB_BYTES,
};
if use_tape {
if let Some(result) = try_parse_via_tape(text_ptr, bytes) {
return result;
}
// Falls through to direct parser for malformed input or non-array roots.
}Five cases result in the eager parser running instead:
- Blob is smaller than 1 KB (tape build overhead dominates on tiny inputs).
PERRY_JSON_TAPE=0(or=off/=false) is set — manual override.- Blob is larger than 16 MB in auto-mode (large iterate-all payloads prefer direct).
- Top-level value is not a JSON array (object, scalar, etc.) —
try_parse_via_tapereturnsNoneand we fall through. - Input is malformed — the eager parser has the full error-reporting path.
Only the js_json_parse runtime entry is affected. js_json_stringify and
its variants are unchanged except for two small additions:
- A
redirect_lazy_to_materializedshim at the top of every stringify entry that forwards a materializedLazyArrayHeaderto itsArrayHeader-backed tree (crates/perry-runtime/src/json.rs:2205-2228). - A
try_stringify_lazy_arraymemcpy fast-path that handles an-unmutated lazy array without walking anything (crates/perry-runtime/src/json.rs:2245-2315).
Earlier versions (v0.5.207–v0.5.231) supported /** @perry-lazy */ to
force every JSON.parse in a file onto the tape path regardless of
blob size. Removed in v0.5.232 — the runtime auto-threshold
(currently lazy for 1 KB..16 MB, direct otherwise) makes the right call without
developer intervention; measurements showed forced-lazy was strictly
slower than direct on very small blobs, while #1090 keeps larger parse-churn
payloads on the lazy path for bounded RSS.
The PERRY_JSON_TAPE=0/=1 env-var escape hatch (Section 3.1) covers
correctness fallback / testing without burdening source files.
#[derive(Debug, Clone, Copy)]
pub struct TapeEntry {
pub offset: u32, // byte position in source blob
pub kind: u8, // one of KIND_*
pub link: u32, // index of matching end marker for containers; 0 for leaves
}crates/perry-runtime/src/json_tape.rs:37-47. Total size: 12 bytes
(padded to 16 by the compiler — can be verified with the
mem::size_of::<TapeEntry> test at the bottom of json_tape.rs).
Kind constants (json_tape.rs:51-60):
| Kind | Meaning |
|---|---|
| 1 | KIND_OBJ_START (open brace — link points at matching OBJ_END) |
| 2 | KIND_OBJ_END |
| 3 | KIND_ARR_START (open bracket — link points at matching ARR_END) |
| 4 | KIND_ARR_END |
| 5 | KIND_KEY (object key — offset points at opening quote) |
| 6 | KIND_STRING (scalar string value — offset points at opening quote) |
| 7 | KIND_NUMBER (scalar number — offset points at first digit / sign) |
| 8 | KIND_TRUE |
| 9 | KIND_FALSE |
| 10 | KIND_NULL |
#[repr(C)]
pub struct LazyArrayHeader {
pub cached_length: u32, // offset 0: total top-level element count
pub magic: u32, // offset 4: 0x4C5A5841 ("LZXA")
pub root_idx: u32, // tape index of the root ARR_START
pub tape_len: u32, // number of TapeEntry's inline after this struct
pub blob_str: *const StringHeader, // input bytes — must stay alive
pub materialized: *mut ArrayHeader, // null until force-materialize
pub materialized_elements: *mut JSValue, // sparse cache
pub materialized_bitmap: *mut u64, // 1 bit per index
pub walk_idx: u32, // cursor: last visited element index
pub walk_tape_pos: u32, // cursor: tape index for walk_idx
pub cumulative_walk_steps: u64, // adaptive threshold counter
// followed by `tape_len` TapeEntry's inline in arena memory
}crates/perry-runtime/src/json_tape.rs:657-720.
Offset 0 is load-bearing. Perry's codegen inlines .length reads as
a raw u32 load at offset 0 of the array pointer — it does not go
through js_array_length for the common case. Putting cached_length
at offset 0 means the inline-length fast path on an unmaterialized lazy
array returns the correct number without any runtime dispatch. The same
offset on a regular ArrayHeader holds length: u32. The invariant
is documented in source at lines 659-666.
Magic (offset 4) is load-bearing. The next field on a regular
ArrayHeader is capacity: u32. Perry's generic array validators apply
a sanity check length <= capacity. The LAZY_ARRAY_MAGIC value
0x4C5A5841 is always larger than any plausible array length (48.5 MB
of elements), so the sanity check passes on a lazy header interpreted
as a regular header. The magic also lets accessors double-check the
obj_type == GC_TYPE_LAZY_ARRAY dispatch against corruption.
Arena block:
+---------------------+
| GcHeader (8 bytes) | obj_type = GC_TYPE_LAZY_ARRAY (9)
+---------------------+
| LazyArrayHeader | fields as above (64 bytes)
+---------------------+
| TapeEntry[tape_len] | inline after the header
+---------------------+
Separately arena-allocated (with their own GcHeaders, referenced via
pointers in the header):
- materialized_elements: cached_length × sizeof(JSValue)
- materialized_bitmap: ceil(cached_length / 64) × 8 bytes
- blob_str: the StringHeader + byte payload of the input
alloc_lazy_array in json_tape.rs:723-767 performs these allocations.
Each cache/bitmap allocation is explicitly zeroed via ptr::write_bytes
after arena_alloc_gc returns, because the arena free-list can reuse
slots whose bytes still hold prior occupants' data (verified: without
this zero, bench_json_readonly_indexed saw parsed[0].id return 5000
on iterations 40–41 of the 50-iter bench — a stale bitmap bit plus a
stale JSValue produced a ghost cache hit. Fix applied at the same
commit that introduced the sparse cache, v0.5.208).
This section enumerates every user-observable operation on a lazy array and shows how it is handled. The reader should be able to verify each row against source.
The runtime entry js_array_get_f64(arr, index) handles the generic
"read an element from this thing that looks like an array" path.
After v0.5.208 it has a lazy fast path before clean_arr_ptr:
crates/perry-runtime/src/array.rs:339-375:
pub extern "C" fn js_array_get_f64(arr: *const ArrayHeader, index: u32) -> f64 {
unsafe {
let bits = arr as u64;
// Strip NaN-box pointer tag if present.
let raw_ptr = if (bits >> 48) >= 0x7FF8 {
if (bits >> 48) == 0x7FFC { return f64::NAN; }
(bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader
} else { arr };
// Read GcHeader to identify lazy array.
if !raw_ptr.is_null() && (raw_ptr as usize) >= GC_HEADER_SIZE + 0x1000 {
let gc_header = (raw_ptr as *const u8).sub(GC_HEADER_SIZE)
as *const GcHeader;
if (*gc_header).obj_type == GC_TYPE_LAZY_ARRAY {
let lazy = raw_ptr as *mut LazyArrayHeader;
if (*lazy).magic == LAZY_ARRAY_MAGIC {
return f64::from_bits(lazy_get(lazy, index).bits());
}
}
}
}
// ... regular ArrayHeader path (unchanged) ...
}The lazy_get(header, i) function handles the lookup:
crates/perry-runtime/src/json_tape.rs:803-911. It has three fast paths:
- Already fully materialized — forward to the
ArrayHeaderelement slot atarr + 8 + i*8. Preserves identity (the materialized tree is the canonical view after this point). - Sparse cache hit — bitmap bit set for index i → return
materialized_elements[i]directly. Identity preserved: same pointer was stored last time, same pointer returned now. - Sparse cache miss — walk the tape from either the root (
i < walk_idx) or the cursor position (i >= walk_idx) to the i-th top-level element, materialize just that subtree viamaterialize_from_idx, cache it inmaterialized_elements[i], set the bitmap bit, update the cursor, and return.
js_array_length(arr) returns the element count.
crates/perry-runtime/src/array.rs:251-300. The lazy fast path lives
before clean_arr_ptr and returns (*lazy).cached_length directly —
no tape walk, no materialization.
clean_arr_ptr(arr) is a hot-path sanity validator used by most
non-read accessors: mutation (js_array_set_f64, js_array_push, .pop,
.shift, .unshift, .sort, .reverse, .splice), iteration
(.map, .filter, .forEach, .reduce), spread, Array.from.
crates/perry-runtime/src/array.rs:33-106.
When it sees a lazy pointer, it calls force_materialize_lazy and
returns the materialized ArrayHeader pointer. The caller gets a real
array and proceeds as if there had never been a lazy header. This is
the universal correctness-preserving fallback: any code path the lazy
fast-path doesn't explicitly handle just force-materializes.
When the user mutates an element: parsed[i] = newValue.
Path: codegen emits js_array_set_f64(arr, i, newValue) →
clean_arr_ptr(arr) → force_materialize_lazy(lazy) → real
ArrayHeader → mutation applied to real tree. The user-observable
effect matches Node's semantics: subsequent parsed[i] returns the new
value.
When the user mutates through a cached element:
parsed[0].name = "x".
Path: parsed[0] → lazy fast-path in js_array_get_f64 → lazy_get →
cache miss → materialize parsed[0]'s subtree once, cache it → return
pointer. The user assigns to .name, which mutates the materialized
subtree pointed to by the cache. Subsequent parsed[0] → cache hit →
same pointer → sees the mutation.
Identity invariant: parsed[0] === parsed[0] holds because the cache
stores a pointer to a single heap object and returns it on every hit.
Without the cache, two cold-path calls would materialize two distinct
trees and === would return false — a correctness bug, not a
performance bug. Verified by test_json_lazy_per_element.ts
(test-files/test_json_lazy_per_element.ts:31-37).
Three paths in js_json_stringify / js_json_stringify_full:
- Fully materialized lazy array (user triggered mutation, iteration,
or the adaptive threshold):
redirect_lazy_to_materializedswaps in theArrayHeaderpointer; the generic stringify walker proceeds normally.json.rs:2205-2228. - Unmutated lazy array (bitmap all zeros, materialized null):
try_stringify_lazy_arraymemcpys the input blob bytes[root.offset .. root_end.offset+1]into a freshStringHeader. This is byte-correct becauseJSON.stringifyproduces whitespace-free output and the input blob (which we're memcpy-ing) is the output of a previousJSON.stringifycall in the common case.json.rs:2245-2315. - Partially materialized lazy array (bitmap has some bits set but
no full materialize):
try_stringify_lazy_arraydetects the bits, force-materializes the whole tree viaforce_materialize_lazy— which consults the cache and reuses cached JSValues where bitmap bit is set and tape-walks where clear — then bails out of the memcpy path. Stringify proceeds via path 1. This preserves mutations that wouldn't survive a clean tape-walk.json.rs:2272-2298.
Handled at codegen: crates/perry-codegen/src/expr.rs::Expr::ArrayIsArray.
Fast path emits TAG_TRUE when the operand's static type is
definitively array. Slow path emits a call to js_array_is_array
when the static type is indeterminate (any / unknown / no annotation).
js_array_is_array in array.rs:1834-1880 checks the GcHeader::obj_type
against both GC_TYPE_ARRAY and GC_TYPE_LAZY_ARRAY and returns the
appropriate boolean.
Handled at codegen: lowered to js_instanceof(value, CLASS_ID_ARRAY).
crates/perry-runtime/src/object.rs:2867-2890. Same dispatch as
Array.isArray: check obj_type against both array types.
Three conditions trigger force_materialize_lazy without user request:
- Cumulative walk exceeds 2 × length. On every cold-path
lazy_get, we add(i - start_count)tocumulative_walk_steps. When the total exceeds2 × cached_length, full-materialize fires. Sequential access (1 step per element) never trips this; random access (n/2 per step average) trips after ~4 accesses on a 10k-element array. Post-trip, the materialized fast path is O(1) per access, which amortizes the walk cost we already paid.json_tape.rs:895-905. - Any
clean_arr_ptr-routed op. See Section 5.3. - Stringify with partial cache. See Section 5.5 path 3.
After full-materialize:
materializedfield is non-null.- All subsequent reads take the "already materialized" fast path and ignore the sparse cache (but the cache is still reachable via GC tracer until the lazy header itself becomes unreachable; nothing reads from it after this point).
- Identity is preserved:
force_materialize_lazycopies cached JSValues into the new tree where bitmap bits are set, so pointers returned before materialize still equal pointers returned after.json_tape.rs:806-858.
Perry uses a mark-sweep collector with conservative stack scanning
(crates/perry-runtime/src/gc.rs). The lazy path introduced one new
object type (GC_TYPE_LAZY_ARRAY = 9) and extended the tracer.
The arena walker iterates objects by obj_type. Before the lazy path,
valid types were 0..=7. LAZY_ARRAY extends the range to 0..=9. Every
walker (arena_walk_objects, arena_walk_objects_with_block_index,
arena_walk_objects_filtered) and every sweep-time reset path in
arena.rs has its upper bound bumped. Audited at v0.5.204 landing.
crates/perry-runtime/src/gc.rs:1431-1514. When a LazyArrayHeader
is marked live, the tracer must mark everything it transitively
depends on:
blob_str— the inputStringHeader. Without this, the blob bytes a tape references would be freed and memcpy-stringify or any subsequent materialize would read freed memory. Marking is defensive: checked for membership invalid_ptrsbefore marking.materialized— if non-null, theArrayHeader-backed tree. Its GC header gets marked and the tracer pushes it onto the worklist so its own children (array elements) are traced.materialized_elements— the sparse cache buffer. GC typeGC_TYPE_STRING(leaf), so marking alone suffices.materialized_bitmap— same.- Each
JSValuein the cache whose bitmap bit is set — traced viatry_mark_value; if the value is a pointer/string/bigint, its pointee is marked and pushed to the worklist. Cache slots without their bit set hold zero bits (positive zero, not a pointer) and are safely skipped.
- The cache buffer + bitmap are separate arena allocations, each with
their own
GcHeader. Without the tracer marking them, they would get swept by the next collection even while the lazy header is reachable, producing a use-after-free on the next cache access. The explicit mark intrace_lazy_array(lines 1461-1479) prevents this. - The cache walk checks
bit_idx < cached_lengthbefore reading*cache.add(i)— pathological cases wherecached_lengthis smaller than the bitmap storage cannot run off the end of the cache. try_mark_valueis the same value-classifier used for closure captures and object fields elsewhere; it correctly handles all NaN-boxed tags (POINTER, STRING, BIGINT, INT32, boolean/null/ undefined sentinels) and non-NaN doubles.
arena_alloc_gc can reuse slots from a size-bucketed free-list whose
memory still holds prior occupant data. The cache + bitmap are
explicitly zeroed with ptr::write_bytes in alloc_lazy_array because
the invariant "cache slot valid ↔ bitmap bit set" depends on the bitmap
starting at all-zeros. A stale bitmap bit in reused memory paired with
a stale JSValue in reused cache memory produces a ghost cache hit:
the lazy path reads the stale JSValue as if it were a live cached
entry. This was a real bug, reproduced as parsed[0].id == 5000 on
bench_json_readonly_indexed iterations 40-41 before the zero fix.
The claim is: every user-observable operation on a lazy array produces byte-for-byte identical output to the same operation on an eager parse result.
The proof is by case analysis against every possible operation. JS arrays support a bounded set of operations:
| Operation | Dispatch | Correctness |
|---|---|---|
arr.length |
js_array_length lazy fast-path |
Returns cached_length — computed during tape build, verified against Node's output on every test. |
arr[i] read |
js_array_get_f64 lazy fast-path → lazy_get |
Section 5.1. Tape walk to element i then materialize → identical JSValue to what eager parse would produce for the same subtree. |
arr[i] = v |
js_array_set_f64 → clean_arr_ptr → force_materialize_lazy |
Section 5.3 / 5.4. Force-materialize before mutate; identical to eager path from that point. |
arr.push, .pop, .shift, .unshift, .sort, .reverse, .splice |
Each runtime impl calls clean_arr_ptr |
Force-materialize first, then mutate. Identical to eager. |
arr.map, .filter, .forEach, .reduce, .find, .some, .every, .slice |
Each runtime impl calls clean_arr_ptr |
Force-materialize first, then iterate the real tree. |
[...arr] spread, Array.from(arr) |
clean_arr_ptr path |
Force-materialize + copy. |
for (const x of arr) |
iterator protocol → indexed loop → js_array_get_f64 |
Lazy fast-path handles element reads. arr.length at loop-bound comes from cached_length. |
for (let i=0; i<arr.length; i++) arr[i] |
same as above | |
JSON.stringify(arr) — unmutated |
try_stringify_lazy_array memcpy |
Section 5.5 path 2. Input blob is the authoritative representation; memcpy is correct because JSON.stringify emits whitespace-free output and the blob we're copying typically came from JSON.stringify. |
JSON.stringify(arr) — mutated |
try_stringify_lazy_array force-materialize + walk |
Section 5.5 path 3. Identical to eager path after force-materialize. |
JSON.stringify(arr, null, 2) — pretty-print |
generic stringify walker | Lazy header → redirect_lazy_to_materialized forwards to materialized tree (or force-materialize if partial cache). Identical. |
Array.isArray(arr) |
codegen → js_array_is_array (indeterminate type) |
Section 5.6. Checks obj_type against both array and lazy-array types. Returns true. |
arr instanceof Array |
js_instanceof |
Section 5.7. Same dispatch. |
typeof arr |
codegen constant "object" |
Same as any array. |
arr.constructor === Array |
property lookup — pre-existing limitation unrelated to lazy | Returns false even for eager arrays on Perry; known gap. |
Every path either (a) reads authoritatively from the tape/cache/blob with
a proof of byte-equivalence to the eager tree, or (b) force-materializes
into an eager tree and delegates to the unchanged eager code path. There
is no user-observable operation that reads lazy-header bytes as if they
were array elements (the bug that v0.5.206 added obj_type guards for
in the three codegen IndexGet paths, audited at that commit).
Perry's per-thread arenas mean a lazy header allocated in one thread
cannot be accessed from another thread without going through
SerializedValue, which deep-copies (crates/perry-runtime/src/thread.rs).
The deep-copy path forces materialization before serialization —
serialized_value::to_bytes uses clean_arr_ptr internally.
Per-thread ParserState ensures no cross-thread contention on parse.
Section 6 covers the tracer. The tracer runs under the stop-the-world discipline Perry uses for all GC; no lazy-specific concurrency concerns.
Measured best-of-5 on macOS ARM64 at v0.5.211.
| Workload | Eager | Lazy | Node 25.8 | Bun 1.3.12 |
|---|---|---|---|---|
bench_json_roundtrip (.length + stringify, 50 iters × 10k records) |
400 ms | 90 ms | 520 ms | 290 ms |
bench_json_readonly (.length only, 50 iters × 10k records) |
290 ms | 80 ms | 450 ms | 200 ms |
bench_json_readonly_indexed (.length + 3 indexed reads, 50 iters × 10k records) |
300 ms | 90 ms | 450 ms | 210 ms |
| Sequential full iteration (20 iters × 10k records) | 40 ms | 51 ms | 53 ms | not measured |
| Random full iteration (5 iters × 10k shuffled accesses) | 7 ms | 10 ms | 10 ms | not measured |
| Small-blob parse (100k × 22-byte array) | 32 ms | 35 ms | 17 ms | not measured |
| Workload | Eager | Lazy | Node | Bun |
|---|---|---|---|---|
bench_json_roundtrip |
137 MB | 130 MB | 180 MB | 81 MB |
bench_json_readonly |
120 MB | 90 MB | 169 MB | 68 MB |
bench_json_readonly_indexed |
120 MB | 90 MB | 169 MB | 68 MB |
- Lazy is a strict win on the three main benches. Time wins are 2.7–5.8× vs Node, 2.1–3.2× vs Bun, and 3.3–4.4× vs Perry's own eager path.
- Lazy is roughly parity on iteration-dominated workloads. The cursor + adaptive threshold (Section 5.8) prevent the O(n²) and O(n·k) cliffs that would otherwise occur on sequential / random iteration.
- Lazy has fixed overhead on tiny parses. The blob-size threshold of 1 KB (Section 3.1) keeps tiny parses on the eager path.
- RSS gap vs Bun remains. Perry's non-generational GC retains
more short-lived intermediate allocations than Bun's young-
generation nursery. Closing this gap requires generational GC —
scoped in
docs/generational-gc-plan.md.
Non-obvious cases and how the implementation handles them.
| Case | Handling |
|---|---|
Out-of-bounds read parsed[len+100] |
lazy_get returns UNDEFINED (element_count check against cached_length). |
Negative index parsed[-1] |
Runtime treats as unsigned via codegen coercion — returns UNDEFINED (effectively out of bounds). |
Empty array [] |
cached_length = 0 → cache + bitmap are null pointers → all read paths return UNDEFINED. Covered by test_json_lazy_edge_cases.ts. |
| Deeply nested arrays | Each nested array/object subtree is materialized via materialize_from_idx when the top-level element containing it is accessed — no special handling needed. |
| Unicode / escape sequences in strings | Tape only records positions; decoding happens in decode_string_at_offset during materialization. Identical decoder as eager path — same bugs, same fixes apply. |
| Very long strings (> 16 bytes) | No special path; tape records the offset, materialize decodes. |
| Numbers requiring arbitrary-precision fallback | Tape records the position; materialize calls the same parse_number_at_offset as eager. |
| Mid-parse GC | Parse path calls gc_suppress / gc_unsuppress around the tape build + materialize — identical contract to eager path. json.rs:1009-1029. |
| Mid-materialize GC (outside parse) | arena_alloc_gc inside materialize_from_idx may trigger GC. The lazy header is reachable from the user's local-variable stack; tracer reaches the blob + tape + cache through the header. No dangling references possible. |
| Invalid JSON / truncated input | try_parse_via_tape returns None → caller falls through to eager parser, which produces the full error message and throws. Zero difference from eager-always behavior for malformed input. |
| Top-level non-array (object, scalar) | try_parse_via_tape builds tape but alloc_lazy_array only fires for root kind KIND_ARR_START. Other roots fall through to eager. |
| Post-materialize mutation | Cached JSValue points at real heap object. Mutation applies to the object. Cache still returns same pointer on subsequent access → mutation visible. |
| Stringify of partially-cached mutated array | Path 3 in Section 5.5. Force-materialize consults cache to preserve mutations, then standard walker runs. |
new Array(10) vs JSON.parse("[...10 elements...]") |
Former is GC_TYPE_ARRAY, latter is GC_TYPE_LAZY_ARRAY until materialized. Both pass Array.isArray, both pass instanceof Array. typeof is "object" for both. |
Located in test-files/. Each compiles + runs on Perry, and the output
is compared byte-for-byte to node --experimental-strip-types:
test_json_lazy_indexed.ts— indexed reads, field access, for-loop, stringify before/after materialize.test_json_lazy_edge_cases.ts— 11 cases: empty array, scalars, mixed types, 4-level nesting, unicode + escapes, number edge cases, empty/whitespace strings, array-of-arrays, 100-element array, parse→stringify→parse roundtrip, null + negative-zero.test_json_lazy_per_element.ts— 5-element access patterns (in-bounds, out-of-bounds, 2D index chain, field chains), identity (parsed[i] === parsed[i], mutation-surviving identity), mutation through cached element, iteration viaforloop.test_json_lazy_iteration.ts— sequential, reverse, random permutation, stringify after iteration, repeated identity across loops, adaptive threshold trip.test_json_typed_{basic,array,nested,mismatch}.ts— typed-parse (JSON.parse<T>()) correctness alongside lazy.
All three benchmarks/suite/bench_json_*.ts emit a checksum the harness
compares against Node's. Perry's eager, Perry's lazy, and Node all
produce the same checksum — regression detection for any cross-path
divergence.
cargo test -p perry-runtime --lib passes 130/130 at v0.5.211. Includes
6 unit tests in json_tape::tests that pin structural invariants of
the tape builder:
- Simple object layout.
- Nested array-of-objects (inner
OBJ_START.linkmust point at the innerOBJ_END, not the outerARR_END). - Escaped-string handling (tape shape is unchanged — decoding deferred).
- Malformed-input
Nonereturns. - Top-level scalars.
TapeEntry12-byte layout guard.
run_parity_tests.sh compiles 118 test files, runs each on Perry
and Node, and diffs the output. At v0.5.211: 106 pass, 12 fail — the
same 12 pre-existing failures as v0.5.207 (pre-lazy-by-default).
No regression introduced by any of v0.5.208–211.
test-files/test_gap_*.ts — 28 tests covering TypeScript feature
parity with Node. At v0.5.211: 24/28 pass. The 4 failures
(async_advanced, console_methods, fetch_response,
typed_arrays) predate the lazy path and are unrelated to JSON.
Regression gate: parity hasn't moved since v0.5.206.
scripts/run_fastify_tests.sh compiles a small HTTP server and
exercises 5 routes over a live socket. This suite was broken between
v0.5.204 and v0.5.210 due to a pre-existing #[no_mangle] regression
on js_json_stringify (the tape-stringify work at v0.5.204
accidentally removed the attribute). Fixed at v0.5.211; all 5 routes
now pass.
Every new test in the test_json_lazy_* family is run against three
paths in the CI/local harness:
- Default mode (auto-selects lazy or eager by blob size).
PERRY_JSON_TAPE=0(force eager).PERRY_JSON_TAPE=1(force lazy even for blobs below the auto threshold). All three produce the same output. This is the invariant-test equivalent of running both compilers and checking they agree — catches any path-dependent divergence.
- Lazy fires only for top-level JSON arrays. A top-level object
like
{"items": [...]}goes through the eager parser; the inner array becomes a regular materializedArrayHeader. Future work could extend lazy to nested arrays or top-level objects (scoped indocs/lazy-per-element-plan.md§"nested lazy"). - Tiny blobs (< 1 KB) bypass lazy. The tape build + header
allocation cost outpaces the savings on tiny parses. The threshold
is configurable only via source edit (
LAZY_MIN_BLOB_BYTESinjson.rs). - Mutation forces full materialize, losing the tape's RSS advantage. This is intrinsic: once the blob is no longer the authoritative representation, we need a real tree. Expected behavior.
parsed.constructorproperty access returns a non-Arrayvalue on Perry regardless of whether the array is eager or lazy — this is a pre-existing property-lookup limitation unrelated to the lazy path.- Adaptive threshold is heuristic. The
cumulative_walk_steps > 2 * cached_lengthformula was picked because force-materialize costs O(n) and subsequent accesses are O(1), so any time we've already paid more than one full-materialize in walks, flipping is strict win. The coefficient 2 is conservative — could be tuned lower. No correctness concern either way.
For reviewers verifying this document against source:
- Tape entry layout matches
json_tape.rs:37-47. -
LazyArrayHeaderfields + offsets matchjson_tape.rs:657-720. -
alloc_lazy_arrayzeroes cache + bitmap:json_tape.rs:743-763. -
lazy_getfast-path order (materialized → bitmap → cold walk):json_tape.rs:803-911. - Walk cursor update:
json_tape.rs:878-892. - Adaptive threshold:
json_tape.rs:895-905. -
force_materialize_lazyconsults cache:json_tape.rs:806-858. -
js_array_get_f64lazy fast-path:array.rs:339-375. -
js_array_lengthlazy fast-path:array.rs:251-300. -
clean_arr_ptrforce-materializes on lazy:array.rs:60-80. -
js_array_is_arrayhandlesGC_TYPE_LAZY_ARRAY:array.rs:1863-1872. -
js_instanceofhandles lazy for array-class:object.rs:2867-2890. -
trace_lazy_arraytraces blob / materialized / cache / bitmap / each set-bit JSValue:gc.rs:1431-1514. - Arena walker bound
<= 9:arena.rs(multiple sites). -
try_stringify_lazy_arrayforce-materializes on partial cache:json.rs:2272-2298. -
redirect_lazy_to_materializedat stringify entrypoints:json.rs:2301-2310+:3282-3289. -
js_json_parsedispatch logic (size threshold + env mode):json.rs:967-998. -
tape_mode_from_envcached viaOnceLock:json.rs:1061-1080. - Every test in
test-files/test_json_{lazy,typed}_*.tspasses byte-for-byte vs Node under default +PERRY_JSON_TAPE=1modes.
- 0.5.203 — Tape builder foundation. Opt-in
PERRY_JSON_TAPE=1. Strictly more work than eager; infrastructure only. - 0.5.204 — Lazy parse top-level array + lazy stringify memcpy.
First real perf win. Also the commit that inadvertently dropped
#[no_mangle]fromjs_json_stringify(fixed v0.5.211). - 0.5.206 — Runtime
obj_typeguards added to three codegenIndexGetpaths soparsed[i]doesn't readLazyArrayHeaderfields as array elements. Added comprehensive edge-case tests. - 0.5.207 —
@perry-lazyJSDoc pragma for per-file opt-in. (Removed in v0.5.232 — runtime auto-threshold made it redundant.) - 0.5.208 — Per-element sparse materialization + bitmap. Eliminated the indexed-access cliff (was 1.3× slower than eager → became 2.9× faster).
- 0.5.209 — Walk cursor + adaptive threshold. Eliminated sequential-iteration cliff (was 80× slower → parity) and random- access cliff (was 75× slower → parity).
- 0.5.210 — Flipped lazy to default above 1024 bytes.
PERRY_JSON_TAPEsemantics changed from opt-in to escape-hatch. - 0.5.211 — Fixed pre-existing
#[no_mangle]regression from v0.5.204. Added runtime dispatch inArray.isArraycodegen for indeterminate static types. Full test sweep: 44/44 cargo workspace runs, 5/5 fastify, 4/4 thread, 106/118 parity (baseline), 24/28 gap (baseline). - 0.5.844 / #437 — Raised the current auto-mode lower bound to
64 KB so the 21 KB
json_pipeline_smalliterate-all fixture stays on the faster direct parser;PERRY_JSON_TAPE=1still forces lazy for targeted testing. - Unreleased / #1090 — Restored the auto-mode lower bound to 1 KB so JSON parse-churn workloads use the tape path for sub-collector-scale payloads while <1 KB blobs stay direct.
- Maintainer: @ralph2 (Ralph Küpper).
- Repo: https://github.com/PerryTS/perry.
- Issue tracker: https://github.com/PerryTS/perry/issues. Issue #179 is the tracking issue for this entire series.