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
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 subsequentread_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):Minimal repro
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:
50x4014_0000_0000_00000x0000_0000_00002.50x4004_0000_0000_00000x0000_0000_00001e-80x3E45_798E_E230_8C3A0x798E_E230_8C3A+0xc(0x798ee2308c46) → SIGSEGVIntegers 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:454get_valid_func_ptr:A range check alone cannot distinguish a real heap closure pointer from a masked-mantissa value — both land in
0x1000..2^48. The+12read 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_callNwithout first checking the value's NaN-box tag is an object/closure tag. A non-callable callee (number/undefined/etc.) should throwTypeError: <x> is not a functionbefore 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_ptrcan't fully fix this, since a mantissa address is indistinguishable from a real pointer by range.)Environment
12e976f3a)@openai/codex@0.1.2505291658dist/cli.js(compiles + links after parser: esbuild ESM bundle with CJS helper shims misclassified as CommonJS → CJS-wrapped → parse fails (ImportExportInScript / shebang ExpectedIdent) #5498/fix(parser): #5498 — classify minified esbuild ESM bundles as ESM, not CJS #5501; segfaults at startup via this path)