Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,17 @@ jobs:
python3 scripts/string_payload_access_inventory.py --self-test
python3 scripts/string_payload_access_inventory.py

# A short string (<= 5 bytes) lives inline in the NaN-box under
# SHORT_STRING_TAG with no StringHeader behind it. Masking its bits into
# a `*StringHeader` segfaults; a heap-tag-only check reads it as "not a
# string" (#11430, #11519). Existing sites are tracked per crate and may
# only decrease; the self-test plants each shape and proves it is caught.
- name: SSO string-unboxing inventory
if: ${{ !cancelled() }}
run: |
python3 scripts/sso_unbox_inventory.py --self-test
python3 scripts/sso_unbox_inventory.py

# Two reserved class ids sharing a value is silent and destructive: every
# dispatch tower matches them in a fixed order, so the later arm becomes
# unreachable and its whole method surface dies (#7576 killed the entire
Expand Down
10 changes: 10 additions & 0 deletions changelog.d/11627-sso-string-entry-points.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**Native string entry points accept short (SSO) strings (#11519, follow-up to #11430).** A string of up to 5 bytes built at runtime (`String(n)`, a template, `"a" + "b"`, `JSON.parse`) is stored inline in the NaN-box under `SHORT_STRING_TAG` with no `StringHeader` behind it. Many native entry points still unboxed a string argument with `bits & POINTER_MASK`, which turned the inline characters into an address and segfaulted, or checked for the heap tag only and read the value as "not a string".

A differential sweep found them: every passing gap test was rerun with its short string literal call arguments rewritten to runtime-built strings and compared against Node. On the branch point, 65 of the 810 rewritten tests diverged. Fixed, grouped by what broke:

- **Segfaults.** `Date.parse(s)`, `execSync` / `spawnSync` / `exec` / `spawn` commands, `JSON.parse(s, reviver)`, `new AggregateError(e, s)`, `new EvalError(s)` / `new URIError(s)`, `new StringDecoder(s)`, `Uint8Array.fromHex/fromBase64/setFromHex/setFromBase64`, `new URLSearchParams(s)` / legacy `url.parse(s)`, an `async_hooks.createHook` callback slot holding a string, `node:net` event names and `BlockList` addresses (ext-net and the stdlib net bridge), and a field typed as an array that holds a short string at runtime (`b.items[-1]`).
- **Wrong answers.** `new Date(s)`, `new Date(y, s)`, typed-array stores of a string, `Array.from(s, fn)`, `Buffer#hasOwnProperty(s)` / `propertyIsEnumerable`, `KeyObject.export({ format })`, `File` `lastModified`, `Symbol[name]`, `(s as any).length`, `perry/thread` truthiness of a short string, Temporal string arguments, `AbortSignal.addEventListener(s)`, and `node:http` event names, `res.end(String(n))`, header values, `writeHead` status message, request method and `setEncoding`.

**How.** Runtime readers borrow the bytes through a new allocation-free `crate::string::with_string_value_bytes` (a closure over `str_bytes_from_jsvalue`). Natives that only read a `*const StringHeader` during the call get `js_ffi_arg_ptr`'s scratch copy (#11486), exposed to the ext crates as `perry_ffi::string_arg_ptr` next to a new `JsValue::to_owned_string`. `js_aggregateerror_new_full` now takes the message NaN-boxed and coerces it itself (the codegen arm and `runtime_abi.tsv` changed with it); EvalError/URIError go through `js_error_new_kind_from_value`. In codegen, the string arguments of `Date.parse`, `JSON.parse(text, reviver)`, the child_process commands, `fetch`'s `method` and the `crypto.sha256/md5` helpers go through `unbox_ffi_str_arg`; `new StringDecoder(enc)` passes the raw NaN-box bits; and an array-typed receiver's runtime-key read branches to `js_dyn_index_get` for an SSO value. The `extract_closure_ptr`-style probes stop treating tag `0x7FF9` as an address.

**Guard.** New lint gate `scripts/sso_unbox_inventory.py` (+ `sso_unbox_baseline.txt`) ratchets three shapes per crate: masked bits cast to `*StringHeader` in a function with no SSO handling (`mask-cast`), a `StringHeader` read behind a heap-only string test (`heap-tag-only`), and a codegen `unbox_to_i64` result passed to a runtime parameter declared as a string (`codegen-str-arg`, now 0). Its self-test plants each shape and proves it is caught, and that SSO-aware code, comments and `cfg(test)` code are not.
10 changes: 4 additions & 6 deletions crates/perry-codegen/src/expr/array_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
});
let blk = ctx.block();
let msg_handle = unbox_to_i64(blk, &m);
// #11519: the message goes over NaN-boxed; the runtime coerces
// it. Masking it to a `*StringHeader` here turned an inline
// SSO message into a garbage address.
let err_handle = blk.call(
I64,
"js_aggregateerror_new_full",
&[
(DOUBLE, &errors_box),
(I64, &msg_handle),
(DOUBLE, &options_box),
],
&[(DOUBLE, &errors_box), (DOUBLE, &m), (DOUBLE, &options_box)],
);
Ok(nanbox_pointer_inline(blk, &err_handle))
})
Expand Down
27 changes: 23 additions & 4 deletions crates/perry-codegen/src/expr/child_proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ fn slot_ptr(ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>, slot: Option<usize>) -
}
}

/// [`slot_ptr`] for the `command`/`file` operand, which the runtime reads as a
/// `*const StringHeader`. An inline SSO command (`"ls"`, `"echo"` built at
/// runtime) has no header behind its masked bits, so it goes through
/// `js_ffi_arg_ptr`'s scratch copy instead (#11519). The runtime copies the
/// command out before it does anything else.
fn slot_str_ptr(
ctx: &mut FnCtx<'_>,
group: &RootedGroup<'_>,
slot: Option<usize>,
) -> Result<String> {
match slot {
Some(i) => {
let boxed = group.reread(ctx, i)?;
Ok(crate::expr::unbox_ffi_str_arg(ctx.block(), &boxed))
}
None => Ok("0".to_string()),
}
}

/// #3079: emit a setup-time `command`/`file` validation call. `cmd_box` is the
/// original NaN-boxed value; `name` is the static argument name (`"command"`
/// for exec/execSync, `"file"` for execFile/execFileSync/spawn/spawnSync). The
Expand Down Expand Up @@ -240,7 +259,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// been evaluated).
let cmd_box = g.reread(ctx, 0)?;
emit_cp_validate_command(ctx, &cmd_box, "command");
let cmd_str = slot_ptr(ctx, g, at[0])?;
let cmd_str = slot_str_ptr(ctx, g, at[0])?;
let opts_str = slot_ptr(ctx, g, at[1])?;
// js_child_process_exec_sync(cmd: i64, opts: i64) -> f64.
// #1937/#1938: the runtime returns an already-NaN-boxed value
Expand All @@ -263,7 +282,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
with_rooted_group(ctx, exprs.len(), |ctx, g| {
lower_cp_args(ctx, g, &exprs, None, false)?;
emit_cp_validators(ctx, g, &at, "file", true, false)?;
let cmd_str = slot_ptr(ctx, g, at[0])?;
let cmd_str = slot_str_ptr(ctx, g, at[0])?;
let args_str = slot_ptr(ctx, g, at[1])?;
let opts_str = slot_ptr(ctx, g, at[2])?;
// js_child_process_spawn_sync(cmd: i64, args: i64, opts: i64) -> i64
Expand Down Expand Up @@ -323,7 +342,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
with_rooted_group(ctx, exprs.len(), |ctx, g| {
lower_cp_args(ctx, g, &exprs, None, false)?;
emit_cp_validators(ctx, g, &at, "file", false, false)?;
let cmd_str = slot_ptr(ctx, g, at[0])?;
let cmd_str = slot_str_ptr(ctx, g, at[0])?;
let args_str = slot_ptr(ctx, g, at[1])?;
let opts_str = slot_ptr(ctx, g, at[2])?;
// #1780: spawn returns a streaming ChildProcess (EventEmitter with
Expand Down Expand Up @@ -403,7 +422,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
emit_cp_validate_command(ctx, &cmd_box, "command");
let arg1 = slot_box(ctx, g, at[1], &undef)?;
let arg2 = slot_box(ctx, g, at[2], &undef)?;
let cmd_str = slot_ptr(ctx, g, at[0])?;
let cmd_str = slot_str_ptr(ctx, g, at[0])?;
Ok(ctx.block().call(
DOUBLE,
"js_child_process_exec",
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-codegen/src/expr/env_clones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::DateParse(s) => {
let s_box = lower_expr(ctx, s)?;
let blk = ctx.block();
let s_handle = unbox_to_i64(blk, &s_box);
// #11519: an SSO string has no header behind its masked bits.
let s_handle = crate::expr::unbox_ffi_str_arg(blk, &s_box);
Ok(blk.call(DOUBLE, "js_date_parse", &[(I64, &s_handle)]))
}
Expr::ProcessVersions => {
Expand Down
64 changes: 64 additions & 0 deletions crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,70 @@ pub(crate) fn unbox_ffi_str_arg(blk: &mut LlBlock, boxed: &str) -> String {
blk.call(I64, "js_ffi_arg_ptr", &[(DOUBLE, boxed)])
}

/// `arr[idx]` through `js_array_get_index_or_string` for a receiver typed as
/// an array. It can still be an inline SSO string at runtime
/// (`{ items: String(n) }`), whose masked bits are no header at all, so that
/// case takes the generic indexer instead (#11519).
pub(crate) fn array_or_sso_index_get(ctx: &mut FnCtx<'_>, arr_box: &str, idx: &str) -> String {
split_on_short_string(
ctx,
arr_box,
|ctx| {
ctx.block().call(
DOUBLE,
"js_dyn_index_get",
&[(DOUBLE, arr_box), (DOUBLE, idx)],
)
},
|ctx| {
let arr_handle = unbox_to_i64(ctx.block(), arr_box);
ctx.block().call(
DOUBLE,
"js_array_get_index_or_string",
&[(I64, &arr_handle), (DOUBLE, idx)],
)
},
)
}

/// Emit `if (value is an inline SSO string) { sso } else { other }` and merge
/// the two `double` results (#11519). For a slow path whose runtime entry
/// unboxes `value` as a heap header: SSO values have none.
fn split_on_short_string(
ctx: &mut FnCtx<'_>,
value: &str,
sso: impl FnOnce(&mut FnCtx<'_>) -> String,
other: impl FnOnce(&mut FnCtx<'_>) -> String,
) -> String {
let blk = ctx.block();
let bits = blk.bitcast_double_to_i64(value);
let tag = blk.lshr(I64, &bits, "48");
let is_sso = blk.icmp_eq(I64, &tag, "32761"); // SHORT_STRING_TAG >> 48 = 0x7FF9
let sso_idx = ctx.new_block("sso.split.sso");
let other_idx = ctx.new_block("sso.split.other");
let done_idx = ctx.new_block("sso.split.done");
let sso_label = ctx.block_label(sso_idx);
let other_label = ctx.block_label(other_idx);
let done_label = ctx.block_label(done_idx);
ctx.block().cond_br(&is_sso, &sso_label, &other_label);

ctx.current_block = sso_idx;
let sso_value = sso(ctx);
let sso_end = ctx.block().label.clone();
ctx.block().br(&done_label);

ctx.current_block = other_idx;
let other_value = other(ctx);
let other_end = ctx.block().label.clone();
ctx.block().br(&done_label);

ctx.current_block = done_idx;
ctx.block().phi(
DOUBLE,
&[(&sso_value, &sso_end), (&other_value, &other_end)],
)
}

/// Built-in constructor / namespace names that the runtime pre-populates
/// on the globalThis singleton (`populate_global_this_builtins` in
/// crates/perry-runtime/src/object.rs). Used by codegen to decide whether
Expand Down
10 changes: 1 addition & 9 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,15 +306,7 @@ fn lower_array_index_get_via_runtime_key(
idx_double: &str,
coerce_numeric_fallback: bool,
) -> String {
let arr_handle = {
let blk = ctx.block();
unbox_to_i64(blk, arr_box)
};
let boxed = ctx.block().call(
DOUBLE,
"js_array_get_index_or_string",
&[(I64, &arr_handle), (DOUBLE, idx_double)],
);
let boxed = crate::expr::array_or_sso_index_get(ctx, arr_box, idx_double);
if coerce_numeric_fallback {
ctx.block()
.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &boxed)])
Expand Down
6 changes: 4 additions & 2 deletions crates/perry-codegen/src/expr/instance_misc1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1747,7 +1747,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::JsonParseReviver { text, reviver } => {
rooting::with_operands_rooted(ctx, &[text, reviver], |ctx, vals| {
let blk = ctx.block();
let s_handle = unbox_to_i64(blk, &vals[0]);
// #11519: the text may be an inline SSO string (`"12"`).
let s_handle = crate::expr::unbox_ffi_str_arg(blk, &vals[0]);
let r_handle = unbox_to_i64(blk, &vals[1]);
let result_i64 = blk.call(
I64,
Expand All @@ -1760,7 +1761,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::JsonParseWithReviver(text, reviver) => {
rooting::with_operands_rooted(ctx, &[text, reviver], |ctx, vals| {
let blk = ctx.block();
let s_handle = unbox_to_i64(blk, &vals[0]);
// #11519: the text may be an inline SSO string (`"12"`).
let s_handle = crate::expr::unbox_ffi_str_arg(blk, &vals[0]);
let r_handle = unbox_to_i64(blk, &vals[1]);
let result_i64 = blk.call(
I64,
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-codegen/src/expr/logical_collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// conversion happens BELOW the re-read, which is the only
// place it can be correct (slice 1b's `BufferSlice` finding).
let url_handle = blk.call(I64, "js_fetch_input_ptr", &[(DOUBLE, &vals[0])]);
let method_handle = unbox_to_i64(blk, &vals[1]);
// #11519: `method: "GET"` built at runtime is an inline SSO
// string; the runtime copies it out on entry.
let method_handle = crate::expr::unbox_ffi_str_arg(blk, &vals[1]);
// The shared BodyInit classifier: a stream or async-iterable
// body is handed to `js_fetch_with_options` out of band.
let body_handle =
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-codegen/src/expr/misc_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Expr::CryptoSha256(operand) => {
let data_box = lower_expr(ctx, operand)?;
let blk = ctx.block();
let data_handle = unbox_to_i64(blk, &data_box);
// #11519: an SSO string has no header behind its masked bits.
let data_handle = crate::expr::unbox_ffi_str_arg(blk, &data_box);
let result = blk.call(I64, "js_crypto_sha256", &[(I64, &data_handle)]);
Ok(nanbox_string_inline(blk, &result))
}
Expr::CryptoMd5(operand) => {
let data_box = lower_expr(ctx, operand)?;
let blk = ctx.block();
let data_handle = unbox_to_i64(blk, &data_box);
let data_handle = crate::expr::unbox_ffi_str_arg(blk, &data_box);
let result = blk.call(I64, "js_crypto_md5", &[(I64, &data_handle)]);
Ok(nanbox_string_inline(blk, &result))
}
Expand Down
16 changes: 8 additions & 8 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,14 @@ pub(crate) use channel::{
};
pub(crate) use collection_receiver::unbox_collection_receiver;
pub(crate) use helpers::{
array_store_needs_layout_note, array_store_needs_write_barrier, buffer_alias_metadata_suffix,
class_field_store_layout_note_is_conforming, class_field_store_needs_layout_note,
class_field_store_needs_string_addref, emit_all_pointer_array_declaration,
emit_string_addref_if_heap_string, expr_has_numeric_pointer_free_array_layout,
expr_produces_fresh_heap_allocation, expr_produces_non_pointer_bits_by_construction,
is_global_this_builtin_function_name, is_global_this_builtin_name,
lower_expr_with_expected_type, lower_js_args_array, store_needs_string_addref,
unbox_ffi_str_arg, unbox_str_handle, unbox_to_i64,
array_or_sso_index_get, array_store_needs_layout_note, array_store_needs_write_barrier,
buffer_alias_metadata_suffix, class_field_store_layout_note_is_conforming,
class_field_store_needs_layout_note, class_field_store_needs_string_addref,
emit_all_pointer_array_declaration, emit_string_addref_if_heap_string,
expr_has_numeric_pointer_free_array_layout, expr_produces_fresh_heap_allocation,
expr_produces_non_pointer_bits_by_construction, is_global_this_builtin_function_name,
is_global_this_builtin_name, lower_expr_with_expected_type, lower_js_args_array,
store_needs_string_addref, unbox_ffi_str_arg, unbox_str_handle, unbox_to_i64,
};
pub(crate) use i32_fast_path::{
can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region,
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-codegen/src/expr/string_regex_proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let s_box = lower_expr(ctx, string)?;
let idx_d = lower_expr(ctx, index)?;
let blk = ctx.block();
let s_handle = unbox_to_i64(blk, &s_box);
// #11519: an SSO receiver has no header behind its masked bits.
let s_handle = crate::expr::unbox_ffi_str_arg(blk, &s_box);
let idx_i32 = blk.fptosi(DOUBLE, &idx_d, I32);
// Runtime returns NaN-boxed f64 directly (string or undefined).
Ok(blk.call(DOUBLE, "js_string_at", &[(I64, &s_handle), (I32, &idx_i32)]))
Expand All @@ -64,7 +65,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let s_box = lower_expr(ctx, string)?;
let idx_d = lower_expr(ctx, index)?;
let blk = ctx.block();
let s_handle = unbox_to_i64(blk, &s_box);
let s_handle = crate::expr::unbox_ffi_str_arg(blk, &s_box);
let idx_i32 = blk.fptosi(DOUBLE, &idx_d, I32);
Ok(blk.call(
DOUBLE,
Expand Down
21 changes: 15 additions & 6 deletions crates/perry-codegen/src/lower_call/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,19 @@ pub(super) fn lower_builtin_new<'a>(
None => lower_expr(ctx, &Expr::String(String::new()))?,
};
let blk = ctx.block();
let msg_handle = unbox_to_i64(blk, &msg_box);
let runtime = if class_name == "EvalError" {
"js_evalerror_new"
// The message goes over NaN-boxed and the runtime coerces it: a
// masked inline SSO message (`new EvalError(String(n))`) was read
// as a header address (#11519).
let kind = if class_name == "EvalError" {
"6" // ERROR_KIND_EVAL_ERROR
} else {
"js_urierror_new"
"7" // ERROR_KIND_URI_ERROR
};
let err_handle = blk.call(I64, runtime, &[(I64, &msg_handle)]);
let err_handle = blk.call(
I64,
"js_error_new_kind_from_value",
&[(I32, kind), (DOUBLE, &msg_box)],
);
Ok(Some(nanbox_pointer_inline(blk, &err_handle)))
}
// `new RegExp(pattern)` / `new RegExp(pattern, flags)` — call
Expand Down Expand Up @@ -611,7 +617,10 @@ pub(super) fn lower_builtin_new<'a>(
// #6986: `enc_box` was held across the discard loop's lowering.
let enc_box = adopt_leading_arg_discard_rest(ctx, args, group)?;
let blk = ctx.block();
let enc_handle = unbox_to_i64(blk, &enc_box);
// The raw NaN-box bits, not a mask: the runtime tells undefined, a
// heap string and an inline SSO name (`"ut" + "f8"`) apart by tag
// (#11519); a masked SSO value was read as a header address.
let enc_handle = blk.bitcast_double_to_i64(&enc_box);
let handle = blk.call(I64, "js_string_decoder_new", &[(I64, &enc_handle)]);
Ok(Some(nanbox_pointer_inline(blk, &handle)))
}
Expand Down
Loading
Loading