quickjs compiles in full, then frees a -1 pointer
This is the wall behind the wall. Two tickets said quickjs was one item from a verdict; clearing both got the file to compile completely and revealed a runtime failure that no compile-time census could have predicted.
Reproduce
./compiler/pascal26 -Ilib/crtl/include -Ilib/crtl/src -I<quickjs> \
test/quickjs/runner.c /tmp/qjs # builds clean, 3052 procs
/tmp/qjs 1 # SIGSEGV (139)
1 is enough. So is 1+1, "a"+"b", and a bare function call — the engine
does not survive its own teardown of the simplest program, which puts this in
the init/eval path rather than in any JS feature.
The backtrace, and which half of it to believe
#5 js_free_rt (rt=0x7fffe7e00008, ptr=0xffffffffffffffff) quickjs.c:1454
#6 js_bytecode_function_finalizer (rt=..., val=...) quickjs.c:5503
#7 free_object (rt=..., p=0x7fffe7e18cc0) quickjs.c:5610
#8 free_gc_object quickjs.c:5630
#9 free_zero_refcount quickjs.c:5652
#10 js_free_value_rt quickjs.c:5700
#11 JS_FreeValueRT quickjs.c:5732
#12 JS_FreeValue quickjs.c:5739
#13 JS_CallFree (ctx=..., func_obj=..., argc=0, argv=0x0) quickjs.c:17408
Frames 0–4 are garbage — they show js_free_value_rt calling
malloc_usable_size calling js__malloc_usable_size, which is inside-out, and
rt=0xffffffffffffffff in a frame whose caller has a valid rt. Inlining has
interleaved them. Frames 5–13 are consistent and are the ones to work from.
js_free_rt guards only if (!ptr) return;, exactly as glibc callers do, so a
-1 walks straight through into the allocator.
Both compile fixes are exonerated, by measurement not by argument
| suspect | control | result |
|---|---|---|
malloc_usable_size (052eb5e6a) |
return 0 unconditionally — quickjs's own portable arm | still SIGSEGV, rc 139 |
__builtin_frame_address |
quickjs's exact shape: static inline feeding rt->stack_top, deltas at four depths, against gcc |
positive, monotonic, ~64B/frame vs gcc's ~48B |
The first is the load-bearing one: if the accounting path were producing the bad pointer, neutering it would change the outcome, and it does not.
Where to start
js_bytecode_function_finalizer frees several fields of a JSFunctionBytecode.
A -1 where a pointer belongs is the signature of a field never written
rather than one freed twice — a double free would be a valid-looking address.
So the first question is which field, and whether pxx zero-initialises that
struct the way the C the engine was written against does.
Do not start by hardening malloc_usable_size against implausible pointers.
glibc's faults on a garbage pointer too, and a plausibility check there would
convert this loud, locatable crash into a silently wrong accounting number —
trading the cheap failure for the expensive one.
Resolution (2026-09-16, frankb-56, Track C) — a wrong ABI, not a wrong field
The ticket guessed a field was never written. It was not: the field was read from a struct pointer that had arrived as a stack address.
Two plausible hypotheses, both killed by measurement
| hypothesis | how it died |
|---|---|
| struct/union LAYOUT differs from gcc | differential offsetof over the same unity TU — every offset identical |
| an optimiser dropped a store (the day's pattern) | crashes identically at -O0/-O1/-O2/-O3 |
What worked was printing the value at both ends of the call, same instrumented source, both compilers:
pxx [FREE_OBJ] p=0x7d90f8e18cc0 [FIN] p=0x7ffd204bf680 <- stack
gcc [FREE_OBJ] p=0x5f49ace30aa0 [FIN] p=0x5f49ace30aa0
p was correct going in; the 16-byte JSValue arrived corrupted, both halves
wrong (tag=70, not -1).
Adding that instrumentation made the SEGFAULT vanish while leaving the wrong values — the garbage was benign in that build. Anyone bisecting on "does it crash" would have concluded the probe fixed it.
The boundary — the first two probes FAILED to reproduce, which is what found it
| shape | pxx |
|---|---|
typedef void (*P)(args); P p; — pointer typedef |
ok |
typedef void F(args); F *p; — function-type typedef |
WRONG |
| same, from an array-of-struct member (quickjs's shape) | WRONG |
| scalar args through the broken spelling | ok |
| 16-byte INTEGER+INTEGER / 16-byte SSE+SSE / 8-byte struct | all WRONG |
Cause
ParseCDeclType yields a parameter's kind and its record identity; the
function-type-typedef registration loop kept only the kind. tyRecord says "a
record", not which record, so ProcParamRecId stayed REC_NONE and
symtab.inc's RecSize(ProcParamRecId[...]) had nothing to classify. The
$cfnptr path already did this, and its own comment says why: "this is the
only place the indirect arm can learn that an argument is a register-classified
struct rather than a pointer."
Result
qjs 1 exits 0; curated smoke byte-exact; js-sha256 byte-exact against the
RFC 4231 vectors. quickjs-ng runs real third-party JavaScript under pxx.
Log
- 2026-09-16 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 9d79f6124.