Skip to content

SIGSEGV: mis-boxed JSValue reaches JSON.stringify replacer walk; CLOSURE_MAGIC probe derefs it without a heap-range guard #6872

Description

@proggeramlug

Summary

A mis-boxed JSValue reaches the JSON.stringify(value, replacer) walk, and the runtime's CLOSURE_MAGIC probe dereferences it without a heap-range guard → SIGSEGV.

Adding the missing guard stops the crash at that probe, but the crash then reappears elsewhere (SIGBUS in js_object_get_field_by_name) on the same workload, so the guard is necessary but not the root cause — something upstream is producing a corrupted value.

Filed as one issue because the two halves share a repro; happy to split if preferred.

Symptom

Compiling any Milo program that imports std/io with a Perry-built Milo compiler (https://github.com/milo-language/milo) segfaults with no output:

$ ./milo-perry emit-ir importer.milo
$ echo $?
139

Deterministic. Unaffected by PERRY_GEN_GC=0, PERRY_GC_FORCE_EVACUATE=0, PERRY_WRITE_BARRIERS=0 — all still 139, so this is not the generational-GC family.

Backtrace

Built with PERRY_DEBUG_SYMBOLS=1:

stop reason = EXC_BAD_ACCESS (code=1, address=0x908000020d)
  frame #0: perry_runtime::json::replacer::stringify_array_with_replacer_pretty + 1604
  frame #1: perry_runtime::json::replacer::stringify_object_with_replacer_pretty + 2424
  frame #2: js_json_stringify_full + 4016
  frame #3: perry_fn_src_resolver_ts__resolveImports + 65684
  frame #4: perry_fn_src_main_ts__frontendToHIR + 3368
  ...

Faulting instruction, with x8 = 0x0000009080000201:

->  ldr    w8, [x8, #0xc]
    mov    w9, #0x4f53
    movk   w9, #0x434c, lsl #16     ; 0x434C4F53 == CLOSURE_MAGIC "CLOS"
    cmp    w8, w9

The JS call site is an ordinary 2-arg stringify with a closure replacer (src/resolver.ts):

const stripSpan = (k: string, v: unknown) =>
  k === "span" ? undefined : typeof v === "bigint" ? `${v}n` : v;
JSON.stringify(p.type, stripSpan);

Part 1 — missing heap-range guard

crates/perry-runtime/src/json/stringify.rs::is_closure_value (~line 328) guards only the handle band before probing the magic:

if crate::value::addr_class::is_handle_band(ptr as usize) {
    return false;
}
let type_tag = *((ptr as *const u8).add(CLOSURE_TYPE_TAG_OFFSET) as *const u32);
type_tag == crate::closure::CLOSURE_MAGIC

0x9080000201 is aligned and above the handle band but far below the macOS heap floor, so it passes and the probe faults.

Its sibling crates/perry-runtime/src/closure/dynamic_props.rs::is_closure_ptr already fixed exactly this class and carries the comment explaining why the band check alone is insufficient. The fix is to use the same predicate:

if !crate::value::addr_class::is_plausible_heap_addr(ptr as usize) {
    return false;
}

This is systemic across the module: every raw-pointer probe under crates/perry-runtime/src/json/ guards with is_handle_band only — ~15 sites in stringify.rs, replacer.rs, reviver.rs, raw_json.rs.

Part 2 — the corrupted value (the actual bug)

With the guard applied and the runtime rebuilt, the same workload no longer SIGSEGVs in the JSON walk. It now SIGBUSes elsewhere:

stop reason = EXC_BAD_ACCESS (code=2, address=0x60800021fa)
  frame #0: js_object_get_field_by_name + 3724
  frame #1: js_object_get_field_by_name_f64 + 176
  frame #2: perry_method_src_checker_ts__TypeChecker__checkFunction + 1700
  ...

Note the shape of both bad addresses — 0x90_8000_0201 and 0x60_8000_21fa. Same 8000 pattern in the middle, neither is a plausible heap pointer. So a bad JSValue is being produced upstream and the unguarded probes are just where it lands first. Guarding the remaining probes would convert crashes into wrong answers, not correct behavior.

I did not track down where the bad value originates — that's the open part.

Possibly related but distinct: #1824 (same js_object_get_field_by_name-on-garbage-pointer symptom). That one was async/fetch accumulation over ~260 sequential awaits; this reproduces synchronously and deterministically with no async, fetch, or DB involved.

Reproducing

git clone https://github.com/milo-language/milo
cd milo && bun install && bun run scripts/bundle-stdlib.ts
perry src/main.ts -o milo-perry
printf 'from "std/io" import {}\npub fn main(): i32 { return 0 }\n' > /tmp/t.milo
./milo-perry emit-ir /tmp/t.milo      # 139

Note: two other Perry bugs must be worked around first for the Milo compiler to get this far — spread args dropped by splice/unshift, and an uninitialized let in a loop not being reset per iteration (filed separately).

Environment

  • perry 0.5.1239, macOS arm64

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions