Skip to content

runtime/codegen: calling a non-callable number SIGSEGVs instead of throwing TypeError (low-48 mantissa bits pass get_valid_func_ptr range check) #5504

Description

@proggeramlug

Summary

Calling a non-callable number whose low 48 mantissa bits happen to form an in-range address segfaults instead of throwing TypeError: x is not a function. The closure-call path masks the callee value's low 48 bits and treats them as a *const ClosureHeader; get_valid_func_ptr's only guard is a numeric range check (0x1000 ..< 0x1_0000_0000_0000), which a mantissa-derived address passes, so the subsequent read_volatile(closure + 12) dereferences a wild pointer.

Found while compiling the OpenAI Codex CLI bundle (last Node/TS release, @openai/codex@0.1.2505291658): after the #5498 parse fix, it now compiles and links to a native binary, but segfaults on startup (no-args and --help). Backtrace (debug-symbols build):

frame #0: js_closure_call1 + 40        ; ldr w8, [x19, #0xc]  → EXC_BAD_ACCESS
frame #1: perry_closure_cli_js__862
frame #2: perry_closure_cli_js__852
frame #3: perry_closure_cli_js__8103
frame #4: main

Minimal repro

// crash.ts
const g: any = (() => 1e-8)();   // a tiny number, dynamically typed
g(1);                            // expected: TypeError; actual: SIGSEGV
perry compile crash.ts -o crash && ./crash    # exit 139 (SIGSEGV)

Why it's intermittent (and why it slipped past tests)

The runtime masks the callee to its low 48 bits and treats that as the closure pointer. Whether it crashes depends entirely on those bits:

Callee value f64 bits low-48 bits result
5 0x4014_0000_0000_0000 0x0000_0000_0000 ✅ low-48 == 0 → null → throws correctly
2.5 0x4004_0000_0000_0000 0x0000_0000_0000 ✅ throws correctly
1e-8 0x3E45_798E_E230_8C3A 0x798E_E230_8C3A ❌ in-range "pointer" → deref +0xc (0x798ee2308c46) → SIGSEGV

Integers and simple fractions have zero low-48 mantissa bits, so they accidentally hit the null/throw path. Any value with non-zero low-48 bits (tiny/irrational floats, and whatever Codex computes at startup — observed bits 0xf0001) dereferences a garbage pointer.

Root cause

crates/perry-runtime/src/closure/dispatch.rs:454 get_valid_func_ptr:

let addr = closure as u64;
if !(0x1000..0x0001_0000_0000_0000).contains(&addr) {
    return std::ptr::null();
}
let type_tag = unsafe { std::ptr::read_volatile((closure as *const u8).add(12) as *const u32) };

A range check alone cannot distinguish a real heap closure pointer from a masked-mantissa value — both land in 0x1000..2^48. The +12 read then faults.

The deeper cause is on the codegen side: the call-emission path unboxes the callee to a pointer (low-48 mask) and routes to js_closure_callN without first checking the value's NaN-box tag is an object/closure tag. A non-callable callee (number/undefined/etc.) should throw TypeError: <x> is not a function before any pointer deref — which is what already happens for {f:5}.f() and [1].map(3) (those report "value is not a function" / "number 3 is not a function").

Suggested fix

Tag-check the callee in codegen before masking to a closure pointer: if the value isn't object/closure-tagged, emit the not-a-function throw. (A runtime-only tightening of get_valid_func_ptr can't fully fix this, since a mantissa address is indistinguishable from a real pointer by range.)

Environment

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