Summary
Indexing or iterating a short string produced by concatenation segfaults. "ab" + "c" yields an inline SHORT_STRING_TAG (SSO) JSValue whose payload is the characters, not a heap address — but two places mask its low 48 bits and treat the result as a StringHeader*.
Repro
const a = "ab";
const b = "c";
const s = a + b;
console.log(s.length); // 3 — fine
console.log(s[0]); // SIGSEGV
bun 3 / a
perry 3 / <exit 139>
typeof s, s.length and printing s all work, which is what makes this so easy to miss — the value is a perfectly good string right up until something indexes it.
Also crashing on the same value:
for (const ch of s) {} // SIGSEGV
Array.from(s) // SIGSEGV
[...s] // SIGSEGV
s.split("") // SIGSEGV
Not affected
- string literals:
"abc"[0] is fine (heap-allocated at init)
join() results: ["ab","c"].join("")[0] is fine
- long concatenations:
"a".repeat(40) + "b".repeat(40) is fine — it exceeds the SSO threshold and is heap-backed
That last one is the clean discriminator: a 3-char concat crashes, a 64-char concat does not.
Cause
Two independent mask-unboxings of a possibly-SSO value:
-
crates/perry-codegen/src/expr/index_get.rs — the s[i] fast path does
unbox_to_i64(blk, &s_box) and passes the result to js_string_index_get,
which expects a real StringHeader*. Emitted IR:
%r10 = call double @js_string_concat_box(double %r8, double %r9)
%r12 = bitcast double %r11 to i64
%r13 = and i64 %r12, 281474976710655 ; low 48 bits
%r14 = call double @js_string_index_get(i64 %r13, double 0.0)
For an SSO value %r13 is the packed characters, not an address.
-
crates/perry-runtime/src/array/from_concat.rs::js_array_from_value receives
the value boxed but then does its own bits & 0x0000_FFFF_FFFF_FFFF behind a
(bits >> 48) >= 0x7FF8 test, which an SSO value passes.
Impact
This is the blocker behind #6872 and the last thing stopping the Milo compiler
(https://github.com/milo-language/milo) from working under Perry. Its codegen does:
const fmt = partFmts.join("") + "\n"; // "%.*s\n" — 5 chars, SSO
for (const ch of fmt) { ... } // SIGSEGV
Every Milo program importing std/platform, std/os, std/sync, std/runtime
or std/event died here.
String concatenation feeding an index or a for...of is about as common as
TypeScript gets, so the blast radius is wide — it just happens to require the
result to be short enough for SSO.
Environment
Summary
Indexing or iterating a short string produced by concatenation segfaults.
"ab" + "c"yields an inlineSHORT_STRING_TAG(SSO) JSValue whose payload is the characters, not a heap address — but two places mask its low 48 bits and treat the result as aStringHeader*.Repro
typeof s,s.lengthand printingsall work, which is what makes this so easy to miss — the value is a perfectly good string right up until something indexes it.Also crashing on the same value:
Not affected
"abc"[0]is fine (heap-allocated at init)join()results:["ab","c"].join("")[0]is fine"a".repeat(40) + "b".repeat(40)is fine — it exceeds the SSO threshold and is heap-backedThat last one is the clean discriminator: a 3-char concat crashes, a 64-char concat does not.
Cause
Two independent mask-unboxings of a possibly-SSO value:
crates/perry-codegen/src/expr/index_get.rs— thes[i]fast path doesunbox_to_i64(blk, &s_box)and passes the result tojs_string_index_get,which expects a real
StringHeader*. Emitted IR:For an SSO value
%r13is the packed characters, not an address.crates/perry-runtime/src/array/from_concat.rs::js_array_from_valuereceivesthe value boxed but then does its own
bits & 0x0000_FFFF_FFFF_FFFFbehind a(bits >> 48) >= 0x7FF8test, which an SSO value passes.Impact
This is the blocker behind #6872 and the last thing stopping the Milo compiler
(https://github.com/milo-language/milo) from working under Perry. Its codegen does:
Every Milo program importing
std/platform,std/os,std/sync,std/runtimeor
std/eventdied here.String concatenation feeding an index or a
for...ofis about as common asTypeScript gets, so the blast radius is wide — it just happens to require the
result to be short enough for SSO.
Environment
main@ 7ae2fb4, macOS arm64