diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2851339fab..90cc94f048 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/changelog.d/11627-sso-string-entry-points.md b/changelog.d/11627-sso-string-entry-points.md new file mode 100644 index 0000000000..d79f8d1750 --- /dev/null +++ b/changelog.d/11627-sso-string-entry-points.md @@ -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. diff --git a/crates/perry-codegen/src/expr/array_methods.rs b/crates/perry-codegen/src/expr/array_methods.rs index ffe0925356..5c8433138a 100644 --- a/crates/perry-codegen/src/expr/array_methods.rs +++ b/crates/perry-codegen/src/expr/array_methods.rs @@ -154,15 +154,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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)) }) diff --git a/crates/perry-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index b430c64892..a09cc7f006 100644 --- a/crates/perry-codegen/src/expr/child_proc.rs +++ b/crates/perry-codegen/src/expr/child_proc.rs @@ -128,6 +128,25 @@ fn slot_ptr(ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>, slot: Option) - } } +/// [`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, +) -> Result { + 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 @@ -240,7 +259,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // 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 @@ -263,7 +282,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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 @@ -323,7 +342,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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 @@ -403,7 +422,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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", diff --git a/crates/perry-codegen/src/expr/env_clones.rs b/crates/perry-codegen/src/expr/env_clones.rs index 81a2d686f4..14e3dca2d2 100644 --- a/crates/perry-codegen/src/expr/env_clones.rs +++ b/crates/perry-codegen/src/expr/env_clones.rs @@ -202,7 +202,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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 => { diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index 3b8d293904..c1ead80a9e 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -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 diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 025550dfe4..30ff8fa6f1 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -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)]) diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 3bf9ddf758..eb411d6e0c 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -1747,7 +1747,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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, @@ -1760,7 +1761,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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, diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 779407509c..e1f981621b 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -656,7 +656,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // 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 = diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index b1d349643c..1c863d06c0 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -293,14 +293,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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)) } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e2eae9837a..5beb4754ff 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -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, diff --git a/crates/perry-codegen/src/expr/string_regex_proc.rs b/crates/perry-codegen/src/expr/string_regex_proc.rs index 6d0c05e2e1..8630db6ec3 100644 --- a/crates/perry-codegen/src/expr/string_regex_proc.rs +++ b/crates/perry-codegen/src/expr/string_regex_proc.rs @@ -55,7 +55,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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)])) @@ -64,7 +65,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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, diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 8ec832f874..18b7059fce 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -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 @@ -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))) } diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 623fea225f..2e4bd2d86a 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -448,7 +448,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_aggregateerror_new", I64, &[I64, I64]); module.declare_function("js_error_new_with_cause", I64, &[I64, DOUBLE]); // #2838/#2836: full AggregateError ctor — errors as raw value, options. - module.declare_function("js_aggregateerror_new_full", I64, &[DOUBLE, I64, DOUBLE]); + module.declare_function("js_aggregateerror_new_full", I64, &[DOUBLE, DOUBLE, DOUBLE]); // #2836: Error/subclass ctor honoring a runtime `{ cause }` options value. module.declare_function("js_error_new_kind_with_options", I64, &[I32, I64, DOUBLE]); // #2904: Error.isError(value) duck-check. diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 9ae4f23380..059313f6b0 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -31,7 +31,7 @@ js_ads_request_consent ptr js_ads_rewarded_load ptr ptr js_ads_rewarded_show ptr js_aggregateerror_new ptr ptr,ptr -js_aggregateerror_new_full ptr f64,ptr,f64 +js_aggregateerror_new_full ptr f64,f64,f64 js_arena_alloc ptr i32u js_arena_stats void ptr,ptr js_argon2_hash ptr ptr diff --git a/crates/perry-ext-events/src/messages.rs b/crates/perry-ext-events/src/messages.rs index 414e070014..2fa79149de 100644 --- a/crates/perry-ext-events/src/messages.rs +++ b/crates/perry-ext-events/src/messages.rs @@ -26,6 +26,11 @@ pub(super) fn describe_received(value: f64) -> String { if jsval.is_number() { return format!("type number ({})", jsval.to_number()); } + if jsval.is_short_string() { + // Inline SSO string (#11519): no header behind its bits. + let text = jsval.to_owned_string().unwrap_or_default(); + return format!("type string ('{text}')"); + } if jsval.is_string() { let text = unsafe { read_string(JsString::from_raw(jsval.as_string_ptr())) }; return match text { diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index 4ffc0f4c07..ff308b8124 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -309,16 +309,8 @@ unsafe fn read_bool_field(obj_f64: f64, field: &str) -> Option { unsafe fn read_string_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = JsValue::from_bits(bits); - if !val.is_string() { - return None; - } - let ptr = val.as_string_ptr(); - if ptr.is_null() { - return None; - } - let js = JsString::from_raw(ptr); - perry_ffi::read_string(js).map(String::from) + // Heap or inline SSO string (#11519). + JsValue::from_bits(bits).to_owned_string() } extern "C" fn agent_connection_abort_listener(closure: *const RawClosureHeader) -> f64 { diff --git a/crates/perry-ext-http/src/client_dispatch_ext.rs b/crates/perry-ext-http/src/client_dispatch_ext.rs index 4ce70b7bb3..7059984848 100644 --- a/crates/perry-ext-http/src/client_dispatch_ext.rs +++ b/crates/perry-ext-http/src/client_dispatch_ext.rs @@ -19,8 +19,6 @@ use std::sync::Once; -use perry_ffi::StringHeader; - const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -195,7 +193,9 @@ unsafe fn incoming_message_method(handle: i64, name: &str, args: &[f64]) -> Opti return None; } let self_ref = f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)); - let arg_ptr = |n: usize| (args[n].to_bits() & PTR_MASK) as *const StringHeader; + // `string_arg_ptr`, not a bare mask: an inline SSO event name or encoding + // has no header behind its bits (#11519). The natives copy it out. + let arg_ptr = |n: usize| perry_ffi::string_arg_ptr(args[n]); Some(match name { "pause" | "resume" => self_ref, "setEncoding" if !args.is_empty() => { diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index fc903c409d..74a66625be 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -84,8 +84,10 @@ extern "C" fn client_once_wrapper(closure: *const RawClosureHeader, rest: f64) - .to_bool(); let callback = (callback_value.to_bits() & PTR_MASK) as i64; let wrapper = (wrapper_value.to_bits() & PTR_MASK) as i64; - let event_ptr = (event_value.to_bits() & PTR_MASK) as *const StringHeader; - let event = read_str(event_ptr).unwrap_or_default(); + // Heap or inline SSO event name (#11519). + let event = JsValue::from_bits(event_value.to_bits()) + .to_owned_string() + .unwrap_or_default(); let removed = with_handle_mut::(handle, |request| { if factory_callback { if request.response_raw_wrapper == wrapper { diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index bc7a9d6151..1e4f4ebdf3 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -540,6 +540,10 @@ unsafe fn read_str(ptr: *const StringHeader) -> Option { unsafe fn extract_string_value(val_f64: f64) -> Option { let bits = val_f64.to_bits(); let upper = bits >> 48; + if upper == 0x7FF9 { + // Inline SSO string (#11519). + return JsValue::from_bits(bits).to_owned_string(); + } let ptr: *const StringHeader = if upper == 0x7FFF || upper == 0x7FFD { (bits & PTR_MASK) as *const StringHeader } else if upper == 0 && bits >= 0x10000 { diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 9ea3214929..5dd20baf02 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -1483,14 +1483,17 @@ fn server_response_method_bytes_for_handle(handle: i64, name: &str) -> Option<&' } /// Strip a NaN-boxed string arg to the raw `*const StringHeader` pointer the -/// existing `js_node_http_server_on` / `_im_on` FFI expects. +/// existing `js_node_http_server_on` / `_im_on` FFI expects. An inline SSO +/// event name (`"da" + "ta"`, a template) goes through `string_arg_ptr`'s +/// scratch header rather than a bare mask (#11519); every consumer copies the +/// name out before returning. #[inline] fn string_arg(value: f64) -> *const StringHeader { let v = JsValue::from_bits(value.to_bits()); - if !v.is_string() { + if !v.is_string() && !v.is_short_string() { return std::ptr::null(); } - (value.to_bits() & PTR_MASK) as *const StringHeader + perry_ffi::string_arg_ptr(value) } /// Strip a NaN-boxed closure/function arg to the raw closure-pointer i64 the diff --git a/crates/perry-ext-http/src/server/http2_settings.rs b/crates/perry-ext-http/src/server/http2_settings.rs index b5726f3b4e..5d0427f3ae 100644 --- a/crates/perry-ext-http/src/server/http2_settings.rs +++ b/crates/perry-ext-http/src/server/http2_settings.rs @@ -261,6 +261,11 @@ fn describe_received(value: JsValue) -> String { format!("Received type boolean ({})", value.to_bool()) } else if value.is_int32() || value.is_number() { format!("Received type number ({})", fmt_number(value.to_number())) + } else if value.is_short_string() { + format!( + "Received type string ('{}')", + value.to_owned_string().unwrap_or_default() + ) } else if value.is_string() { format!("Received type string ('{}')", read_js_string(value)) } else { diff --git a/crates/perry-ext-http/src/server/request.rs b/crates/perry-ext-http/src/server/request.rs index 5e03f142b5..ce12bc6b2f 100644 --- a/crates/perry-ext-http/src/server/request.rs +++ b/crates/perry-ext-http/src/server/request.rs @@ -1123,7 +1123,8 @@ pub unsafe extern "C" fn js_node_http_im_add_header_line( let existing_f64 = f64::from_bits(existing.bits()); match kind { HeaderFieldKind::List | HeaderFieldKind::Cookie => { - if JsValue::from_bits(existing.bits()).is_string() { + let existing_js = JsValue::from_bits(existing.bits()); + if existing_js.is_string() || existing_js.is_short_string() { let sep = if matches!(kind, HeaderFieldKind::Cookie) { "; " } else { diff --git a/crates/perry-ext-http/src/server/response.rs b/crates/perry-ext-http/src/server/response.rs index 79b22c833a..f70c76d249 100644 --- a/crates/perry-ext-http/src/server/response.rs +++ b/crates/perry-ext-http/src/server/response.rs @@ -821,13 +821,12 @@ pub unsafe extern "C" fn js_node_http_res_write_head( let mut headers_value: Option = None; if v3.is_pointer() { headers_value = Some(f64::from_bits(arg3 as u64)); - if v2.is_string() { - status_message = read_string_header(v2.as_string_ptr()); - } + // `to_owned_string` also reads an inline SSO message (#11519). + status_message = v2.to_owned_string(); } else if v2.is_pointer() { headers_value = Some(f64::from_bits(arg2 as u64)); - } else if v2.is_string() { - status_message = read_string_header(v2.as_string_ptr()); + } else { + status_message = v2.to_owned_string(); } let headers_json = headers_value.and_then(|hv| { @@ -1428,8 +1427,9 @@ pub unsafe extern "C" fn js_node_http_server_response_standalone_new(req: f64) - (req.to_bits() & PTR_MASK) as *const perry_ffi::ObjectHeader, key.as_raw(), ); - if JsValue::from_bits(m.bits()).is_string() { - sr.standalone_req_method = read_string_header((m.bits() & PTR_MASK) as *mut _); + // Heap or inline SSO (`"GET"`, `"POST"` built at runtime, #11519). + if let Some(method) = JsValue::from_bits(m.bits()).to_owned_string() { + sr.standalone_req_method = Some(method); } } register_handle(sr) diff --git a/crates/perry-ext-http/src/server/types.rs b/crates/perry-ext-http/src/server/types.rs index e545aeb9ff..ab2c06a66c 100644 --- a/crates/perry-ext-http/src/server/types.rs +++ b/crates/perry-ext-http/src/server/types.rs @@ -98,7 +98,7 @@ pub unsafe fn extract_port(opts: f64, default_port: u16) -> u16 { /// the `listen(port, hostname, cb)` overload). pub unsafe fn extract_host(opts: f64, default_host: &str) -> String { let v = JsValue::from_bits(opts.to_bits()); - if v.is_string() { + if v.is_string() || v.is_short_string() { if let Some(s) = jsvalue_to_owned_string(opts) { return s; } @@ -191,7 +191,7 @@ pub(super) unsafe fn parse_listen_values(values: impl IntoIterator) out.opts = f64::from_bits(bits); continue; } - if v.is_string() { + if v.is_string() || v.is_short_string() { if let Some(s) = jsvalue_to_owned_string(f64::from_bits(bits)) { out.host = Some(s); } @@ -209,6 +209,10 @@ pub fn jsvalue_to_owned_string(value: f64) -> Option { if v.is_undefined() || v.is_null() { return None; } + if v.is_short_string() { + // Inline SSO (#11519): `res.end(String(n))`, an `"on" + "e"` event name. + return v.to_owned_string(); + } if v.is_string() { let bits = value.to_bits(); let ptr = (bits & PTR_MASK) as *mut StringHeader; diff --git a/crates/perry-ext-http/src/tls_client.rs b/crates/perry-ext-http/src/tls_client.rs index e286dd836e..1dabc8139d 100644 --- a/crates/perry-ext-http/src/tls_client.rs +++ b/crates/perry-ext-http/src/tls_client.rs @@ -684,11 +684,8 @@ unsafe fn live_pfx_entries(options: f64) -> Option, String)>> { } fn string_value(value: perry_ffi::JsValue) -> Option { - if !value.is_string() { - return None; - } - perry_ffi::read_string(unsafe { perry_ffi::JsString::from_raw(value.as_string_ptr()) }) - .map(String::from) + // Heap or inline SSO string (#11519): a short passphrase fits inline. + value.to_owned_string() } unsafe fn buffer_value( diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs index eb849954d9..f01a070a8e 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -109,6 +109,15 @@ fn unbox_to_i64(v: f64) -> i64 { (v.to_bits() & POINTER_MASK) as i64 } +/// Unbox a string argument (an event name, an address, an encoding) for a native that takes a +/// `*const StringHeader` as `i64` and copies the name out on entry. An inline +/// SSO name (`"da" + "ta"`, `String(n)`) has no header behind its masked bits, +/// so it goes through `string_arg_ptr`'s scratch copy (#11519); every other +/// value unboxes exactly as [`unbox_to_i64`] does. +fn str_arg(v: f64) -> i64 { + perry_ffi::string_arg_ptr(v) as i64 +} + /// Coerce a nanboxed JS number to a plain `f64`. perry stores small integers as /// a tagged int32 (`0x7FFE` high bits), not a raw double, so passing the raw /// nanboxed value where a real number is expected (e.g. `BlockList.addSubnet`'s @@ -275,7 +284,7 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option undefined() } "emit" if !args.is_empty() => { - let event = unbox_to_i64(args[0]); + let event = str_arg(args[0]); let rest = args.get(1..).unwrap_or(&[]); crate::js_ext_net_socket_emit(handle, event, rest.as_ptr(), rest.len()) } @@ -289,23 +298,19 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option undefined() } "on" | "addListener" if args.len() >= 2 => { - crate::js_net_socket_on(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + crate::js_net_socket_on(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } // #10441 — same shape as `once` below, but inserted at the FRONT of // the listener list. "prependListener" if args.len() >= 2 => { - crate::js_net_socket_prepend_listener( - handle, - unbox_to_i64(args[0]), - unbox_to_i64(args[1]), - ); + crate::js_net_socket_prepend_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "prependOnceListener" if args.len() >= 2 => { crate::js_net_socket_prepend_once_listener( handle, - unbox_to_i64(args[0]), + str_arg(args[0]), unbox_to_i64(args[1]), ); nanbox_handle(handle) @@ -341,24 +346,20 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option "getSession" => nanbox_ptr(crate::js_ext_net_socket_tls_session(handle)), "isSessionReused" => crate::js_ext_net_socket_tls_session_reused(handle), "once" if args.len() >= 2 => { - crate::js_net_socket_once(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + crate::js_net_socket_once(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "off" | "removeListener" if args.len() >= 2 => { - crate::js_net_socket_remove_listener( - handle, - unbox_to_i64(args[0]), - unbox_to_i64(args[1]), - ); + crate::js_net_socket_remove_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "removeAllListeners" => { - let event = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let event = args.first().copied().map(str_arg).unwrap_or(0); crate::js_net_socket_remove_all_listeners(handle, event); nanbox_handle(handle) } "listenerCount" if !args.is_empty() => { - crate::js_net_socket_listener_count(handle, unbox_to_i64(args[0])) + crate::js_net_socket_listener_count(handle, str_arg(args[0])) } "getMaxListeners" => crate::js_ext_net_socket_get_max_listeners(handle), "setMaxListeners" if !args.is_empty() => { @@ -366,11 +367,11 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option } "eventNames" => json_str_to_value(crate::js_net_socket_event_names(handle)), "listeners" if !args.is_empty() => { - let arr = crate::js_net_socket_listeners(handle, unbox_to_i64(args[0])); + let arr = crate::js_net_socket_listeners(handle, str_arg(args[0])); nanbox_ptr(arr as *mut ArrayHeader) } "rawListeners" if !args.is_empty() => { - let arr = crate::js_net_socket_raw_listeners(handle, unbox_to_i64(args[0])); + let arr = crate::js_net_socket_raw_listeners(handle, str_arg(args[0])); nanbox_ptr(arr as *mut ArrayHeader) } "address" => json_str_to_value(crate::js_net_socket_address(handle)), @@ -395,9 +396,10 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option let enc_ptr = args .first() .map(|a| { + // Heap or inline SSO (`"utf8"`, `"hex"`, #11519). let bits = a.to_bits(); - if (bits >> 48) == 0x7FFF { - (bits & 0x0000_FFFF_FFFF_FFFF) as i64 + if (bits >> 48) == 0x7FFF || (bits >> 48) == 0x7FF9 { + str_arg(*a) } else { 0 } @@ -447,52 +449,44 @@ unsafe fn server_method(handle: i64, method: &str, args: &[f64]) -> Option } "address" => json_str_to_value(crate::js_net_server_address(handle)), "on" | "addListener" if args.len() >= 2 => { - crate::js_net_server_on(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + crate::js_net_server_on(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "once" if args.len() >= 2 => { - crate::js_net_server_once(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + crate::js_net_server_once(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "prependListener" if args.len() >= 2 => { - crate::js_net_server_prepend_listener( - handle, - unbox_to_i64(args[0]), - unbox_to_i64(args[1]), - ); + crate::js_net_server_prepend_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "prependOnceListener" if args.len() >= 2 => { crate::js_net_server_prepend_once_listener( handle, - unbox_to_i64(args[0]), + str_arg(args[0]), unbox_to_i64(args[1]), ); nanbox_handle(handle) } "off" | "removeListener" if args.len() >= 2 => { - crate::js_net_server_remove_listener( - handle, - unbox_to_i64(args[0]), - unbox_to_i64(args[1]), - ); + crate::js_net_server_remove_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "removeAllListeners" => { - let event = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let event = args.first().copied().map(str_arg).unwrap_or(0); crate::js_net_server_remove_all_listeners(handle, event); nanbox_handle(handle) } "listenerCount" if !args.is_empty() => { - crate::js_net_server_listener_count(handle, unbox_to_i64(args[0])) + crate::js_net_server_listener_count(handle, str_arg(args[0])) } "eventNames" => json_str_to_value(crate::js_net_server_event_names(handle)), "listeners" if !args.is_empty() => { - let arr = crate::js_net_server_listeners(handle, unbox_to_i64(args[0])); + let arr = crate::js_net_server_listeners(handle, str_arg(args[0])); nanbox_ptr(arr as *mut ArrayHeader) } "rawListeners" if !args.is_empty() => { - let arr = crate::js_net_server_raw_listeners(handle, unbox_to_i64(args[0])); + let arr = crate::js_net_server_raw_listeners(handle, str_arg(args[0])); nanbox_ptr(arr as *mut ArrayHeader) } "ref" | "unref" => nanbox_handle(handle), @@ -519,18 +513,18 @@ unsafe fn block_list_method(handle: i64, method: &str, args: &[f64]) -> Option { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); + let family = args.get(1).copied().map(str_arg).unwrap_or(0); crate::js_net_block_list_add_address(handle, address, family) } "addRange" => { - let start = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let end = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(2).copied().map(unbox_to_i64).unwrap_or(0); + let start = args.first().copied().map(str_arg).unwrap_or(0); + let end = args.get(1).copied().map(str_arg).unwrap_or(0); + let family = args.get(2).copied().map(str_arg).unwrap_or(0); crate::js_net_block_list_add_range(handle, start, end, family) } "addSubnet" => { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); // A missing `prefix` must reach `js_net_validate_block_list_prefix` // as a non-numeric value so it throws `ERR_INVALID_ARG_TYPE`, matching // Node (the parameter is required). Defaulting to a real number here @@ -540,12 +534,12 @@ unsafe fn block_list_method(handle: i64, method: &str, args: &[f64]) -> Option { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); + let family = args.get(1).copied().map(str_arg).unwrap_or(0); crate::js_net_block_list_check(handle, address, family) } "rules" | "toJSON" => crate::js_net_block_list_to_json(handle), diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index cbe00681ca..d4050b737e 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -198,6 +198,27 @@ impl JsValue { Some(len) } + /// Copy a string value — heap `STRING_TAG` or inline SSO — into an owned + /// `String`, replacing invalid UTF-8 lossily. Returns `None` for every + /// non-string value (#11519). + /// + /// Prefer this over `is_string()` + `as_string_ptr()`: that pair is + /// heap-only, so a short string built at runtime (`"da" + "ta"`, + /// `String(5)`, a template) silently reads as "not a string". + pub fn to_owned_string(self) -> Option { + if self.is_short_string() { + let mut buf = [0u8; SHORT_STRING_MAX_LEN]; + let len = self.short_string_to_buf(&mut buf)?; + return Some(String::from_utf8_lossy(&buf[..len]).into_owned()); + } + if self.is_string() { + // SAFETY: a STRING_TAG value carries a live `StringHeader`, and + // `copy_string_from_raw` copies it out before returning. + return Some(unsafe { crate::copy_string_from_raw(self.as_string_ptr()) }); + } + None + } + /// True if the value is a heap object pointer (`POINTER_TAG` — /// covers ObjectHeader, ArrayHeader, ClosureHeader, etc). #[inline] @@ -311,6 +332,26 @@ impl std::fmt::Debug for JsValue { } } +// ── string arguments for `*const StringHeader` natives ─────────── + +extern "C" { + fn js_ffi_arg_ptr(value: f64) -> i64; +} + +/// Unbox `value` to the `*const StringHeader` a native entry expects (#11519). +/// +/// An inline SSO string has no header behind its bits, so it is copied into a +/// per-thread scratch header; a heap string unboxes exactly as +/// `bits & POINTER_MASK` does. The scratch copy is recycled after 16 further +/// SSO arguments, so the callee must only read the string during the call +/// (copying it out if it keeps it) — the same contract every existing +/// `*const StringHeader` native already has for a GC-movable heap string. +#[inline] +pub fn string_arg_ptr(value: f64) -> *const StringHeader { + // SAFETY: `js_ffi_arg_ptr` is a pure runtime entry that accepts any value. + unsafe { js_ffi_arg_ptr(value) as *const StringHeader } +} + // ── object / array allocation primitives ───────────────────────── extern "C" { diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 1c2daad4a9..1f5ed3cd3b 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -100,7 +100,7 @@ pub use jsvalue::{ alloc_null_proto_object, alloc_object, alloc_set, build_object_shape, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_get_field, js_object_live_slot_count, js_object_set_field, object_field_by_name, - set_add, set_delete, JsValue, SHORT_STRING_MAX_LEN, + set_add, set_delete, string_arg_ptr, JsValue, SHORT_STRING_MAX_LEN, }; mod closure; diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 68d7ef5a40..501917915e 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -762,6 +762,27 @@ pub fn array_from_full(c: f64, items: f64, mapfn: f64, this_arg: f64) -> f64 { if item_bits == TAG_NULL { throw_not_iterable("object null"); } + // An inline SSO string has no header for the iterable / array-like probes + // below to read (`js_nanbox_get_pointer` answers 0 and the result came out + // empty: `Array.from("ab" + "c", f)`, #11519). Materialize it, as + // `js_array_from_value` does, rooting the other operands across that one + // allocation. + if JSValue::from_bits(item_bits).is_short_string() { + let scope = crate::gc::RuntimeHandleScope::new(); + let (c, mapfn, this_arg) = ( + scope.root_nanbox_f64(c), + scope.root_nanbox_f64(mapfn), + scope.root_nanbox_f64(this_arg), + ); + let hdr = crate::string::js_string_materialize_to_heap(items); + let items = crate::value::js_nanbox_string(hdr as i64); + return array_from_full( + c.get_nanbox_f64(), + items, + mapfn.get_nanbox_f64(), + this_arg.get_nanbox_f64(), + ); + } // Proxy GetMethod is observable. Resolve it once, retain it across // construction, and root iterator state across callbacks that can collect. diff --git a/crates/perry-runtime/src/buffer/u8_codec.rs b/crates/perry-runtime/src/buffer/u8_codec.rs index 2c2d806a48..16a8f8b5b4 100644 --- a/crates/perry-runtime/src/buffer/u8_codec.rs +++ b/crates/perry-runtime/src/buffer/u8_codec.rs @@ -90,16 +90,27 @@ unsafe fn buffer_from_addr(addr: usize) -> *mut BufferHeader { unbox_ptr(addr as u64) as *mut BufferHeader } -/// Read the bytes of a `StringHeader` (passed as an i64 handle, possibly -/// NaN-boxed). Returns `None` when the handle is null. -unsafe fn string_bytes<'a>(str_handle: i64) -> Option<&'a [u8]> { - let addr = unbox_ptr(str_handle as u64); +/// Copy the input string's bytes out; `None` when the handle is null. The +/// handle is a NaN-boxed string, heap or inline SSO (#11519: +/// `Uint8Array.fromHex("4" + "869")` masked an SSO value into an address), or +/// a raw `StringHeader` pointer. The copy also keeps the bytes valid across +/// the result buffer's allocation, which can move a heap string. Payloads up +/// to 64 bytes are copied without allocating. +unsafe fn string_bytes(str_handle: i64) -> Option { + let raw = str_handle as u64; + if let Some(copy) = crate::string::with_string_value_bytes( + f64::from_bits(raw), + crate::string::OwnedStringBytes::copy_from_slice, + ) { + return Some(copy); + } + let addr = unbox_ptr(raw); if addr < 0x1000 { return None; } - let hdr = addr as *const StringHeader; - let bytes = (hdr as *const u8).add(std::mem::size_of::()); - Some(std::slice::from_raw_parts(bytes, (*hdr).byte_len as usize)) + Some(crate::string::OwnedStringBytes::copy_from_header( + addr as *const StringHeader, + )) } fn throw_syntax(message: &[u8]) -> ! { @@ -167,19 +178,11 @@ unsafe fn opt_string_field(opts_bits: f64, name: &[u8]) -> Option { } let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let val = crate::object::js_object_get_field_by_name(obj, key); - let vbits = val.bits(); - if (vbits >> 48) as u16 != 0x7FFF { - return None; - } - let ptr = (vbits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader; - if ptr.is_null() { - return None; - } - let bytes = std::slice::from_raw_parts( - (ptr as *const u8).add(std::mem::size_of::()), - (*ptr).byte_len as usize, - ); - std::str::from_utf8(bytes).ok().map(str::to_string) + // Heap or inline SSO string (#11519): `"loose"` fits inline. + crate::string::with_string_value_bytes(f64::from_bits(val.bits()), |bytes| { + std::str::from_utf8(bytes).ok().map(str::to_string) + }) + .flatten() } // --------------------------------------------------------------------------- @@ -492,6 +495,7 @@ pub extern "C" fn js_u8_from_base64(str_handle: i64, opts_bits: f64) -> *mut Buf let Some(input) = string_bytes(str_handle) else { throw_type(b"input argument must be a string"); }; + let input = input.as_bytes(); let url = opt_is_base64url(opts_bits); let last_chunk = opt_last_chunk_handling(opts_bits); let max = base64_max_bytes(input); @@ -510,6 +514,7 @@ pub extern "C" fn js_u8_from_hex(str_handle: i64) -> *mut BufferHeader { let Some(input) = string_bytes(str_handle) else { throw_type(b"input argument must be a string"); }; + let input = input.as_bytes(); let max = input.len() / 2; let buf = buffer_alloc(max as u32); let dst = std::slice::from_raw_parts_mut(buffer_data_mut(buf), max); @@ -531,6 +536,7 @@ pub extern "C" fn js_u8_set_from_base64(addr: i64, str_handle: i64, opts_bits: f let Some(input) = string_bytes(str_handle) else { throw_type(b"input argument must be a string"); }; + let input = input.as_bytes(); let url = opt_is_base64url(opts_bits); let last_chunk = opt_last_chunk_handling(opts_bits); let cap = (*buf).length as usize; @@ -551,6 +557,7 @@ pub extern "C" fn js_u8_set_from_hex(addr: i64, str_handle: i64) -> f64 { let Some(input) = string_bytes(str_handle) else { throw_type(b"input argument must be a string"); }; + let input = input.as_bytes(); let cap = (*buf).length as usize; let dst = std::slice::from_raw_parts_mut(buffer_data_mut(buf), cap); let res = hex_decode_strict(input, dst); diff --git a/crates/perry-runtime/src/child_process/registry.rs b/crates/perry-runtime/src/child_process/registry.rs index a589fc5a90..6a3696a832 100644 --- a/crates/perry-runtime/src/child_process/registry.rs +++ b/crates/perry-runtime/src/child_process/registry.rs @@ -9,7 +9,7 @@ use std::sync::{ }; use crate::object::ObjectHeader; -use crate::string::{js_string_from_bytes, StringHeader}; +use crate::string::js_string_from_bytes; // ============================================================================ // Background Process Registry @@ -20,18 +20,14 @@ static NEXT_HANDLE_ID: AtomicU64 = AtomicU64::new(1); static PROCESS_REGISTRY: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -/// Helper: extract a Rust string from a NaN-boxed f64 string value +/// Helper: extract a Rust string from a NaN-boxed f64 string value — a heap +/// `STRING_TAG` string or an inline SSO one (#11519; masking an SSO value's +/// bits used to dereference its inline characters as an address). pub(crate) unsafe fn extract_string_from_nanboxed(val: f64) -> Option { - use crate::value::POINTER_MASK; - let bits = val.to_bits(); - let ptr = (bits & POINTER_MASK) as *const StringHeader; - if ptr.is_null() || (ptr as usize) < 0x1000 { - return None; - } - let len = (*ptr).byte_len as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data_ptr, len); - std::str::from_utf8(bytes).ok().map(|s| s.to_string()) + crate::string::with_string_value_bytes(val, |bytes| { + std::str::from_utf8(bytes).ok().map(|s| s.to_string()) + }) + .flatten() } /// Build an object with two f64 fields and named keys. diff --git a/crates/perry-runtime/src/cluster.rs b/crates/perry-runtime/src/cluster.rs index 48e4c0fb90..3aad7432a2 100644 --- a/crates/perry-runtime/src/cluster.rs +++ b/crates/perry-runtime/src/cluster.rs @@ -1555,7 +1555,8 @@ fn array_ptr(value: f64) -> Option<*mut ArrayHeader> { fn is_closure_value(value: f64) -> bool { let bits = value.to_bits(); let top16 = bits >> 48; - let raw = if (0x7FF8..=0x7FFF).contains(&top16) { + // 0x7FF9 is an inline SSO string: characters, not an address (#11519). + let raw = if (0x7FF8..=0x7FFF).contains(&top16) && top16 != 0x7FF9 { (bits & crate::value::POINTER_MASK) as usize } else if top16 == 0 { bits as usize diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index f71ac25c86..506dff71b3 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -454,29 +454,19 @@ pub extern "C" fn js_date_new_from_timestamp(timestamp: f64) -> f64 { } /// Create a new Date from a value that could be a number or a NaN-boxed string. -/// Checks for STRING_TAG (0x7FFF) in the top 16 bits; if found, parses the string -/// as a date. Otherwise treats the value as a numeric timestamp. +/// A string — heap `STRING_TAG` (0x7FFF) or inline SSO `SHORT_STRING_TAG` +/// (0x7FF9) — is parsed as a date. Otherwise treats the value as a numeric timestamp. #[no_mangle] pub extern "C" fn js_date_new_from_value(value: f64) -> f64 { let bits = value.to_bits(); let tag = (bits >> 48) & 0xFFFF; - let result = if tag == 0x7FFF { - // NaN-boxed string — extract pointer and parse - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; - if ptr.is_null() || (ptr as usize) < 0x1000 { - f64::NAN - } else { - unsafe { - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - if let Ok(s) = std::str::from_utf8(bytes) { - parse_date_string(s) - } else { - f64::NAN - } - } - } + let result = if tag == 0x7FFF || tag == 0x7FF9 { + // NaN-boxed string, heap or inline SSO (#11519) — parse its bytes. + crate::string::with_string_value_bytes(value, |bytes| match std::str::from_utf8(bytes) { + Ok(s) => parse_date_string(s), + Err(_) => f64::NAN, + }) + .unwrap_or(f64::NAN) } else if is_date_value(value) { // `new Date(anotherDate)` copies the source's time value (and would // otherwise read the pointer bits as a bogus timestamp). @@ -899,28 +889,20 @@ fn jsvalue_to_number(v: f64) -> f64 { let bits = v.to_bits(); let tag = (bits >> 48) & 0xFFFF; match tag { - 0x7FFF => { - // NaN-boxed heap string. - let ptr = (bits & NANBOX_PTR_MASK) as *const crate::StringHeader; - if ptr.is_null() || (ptr as usize) < 0x1000 { - return f64::NAN; - } - unsafe { - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - match std::str::from_utf8(bytes) { - Ok(s) => { - let t = s.trim(); - if t.is_empty() { - 0.0 - } else { - t.parse::().unwrap_or(f64::NAN) - } + 0x7FFF | 0x7FF9 => { + // NaN-boxed string, heap or inline SSO (#11519). + crate::string::with_string_value_bytes(v, |bytes| match std::str::from_utf8(bytes) { + Ok(s) => { + let t = s.trim(); + if t.is_empty() { + 0.0 + } else { + t.parse::().unwrap_or(f64::NAN) } - Err(_) => f64::NAN, } - } + Err(_) => f64::NAN, + }) + .unwrap_or(f64::NAN) } 0x7FFC => { // boxed sentinel: undefined / null / false / true diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 85846243df..17158a0c16 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -830,15 +830,23 @@ pub extern "C" fn js_error_new_kind_with_options_from_value( /// `TypeError` when it is omitted or non-iterable), stores `.message`, and /// applies the `{ cause }` option from `options` if present. /// -/// `errors` and `options` arrive as raw NaN-boxed values (the iterable must -/// not be pre-coerced to an array pointer — Sets / strings / generators must -/// reach `materialize_iterable` intact). +/// `errors`, `message` and `options` all arrive as raw NaN-boxed values. The +/// iterable must not be pre-coerced to an array pointer — Sets / strings / +/// generators must reach `materialize_iterable` intact. `message` used to be a +/// codegen-masked `*StringHeader`, which turned an inline SSO message +/// (`new AggregateError(e, String(n))`) into a garbage address (#11519); it is +/// now coerced here like every other Error constructor's message. #[no_mangle] pub extern "C" fn js_aggregateerror_new_full( errors: f64, - message: *mut StringHeader, + message: f64, options: f64, ) -> *mut ErrorHeader { + // Every operand is rooted: the iterable walk, the error allocation and the + // `cause` read can each collect. + let scope = crate::gc::RuntimeHandleScope::new(); + let message_h = scope.root_nanbox_f64(message); + let options_h = scope.root_nanbox_f64(options); // #2838: reuse the spec-shaped iterable→array converter that backs the // Promise combinators (`Promise.any`/`all`/…). It accepts arrays, strings, // Set/Map, buffers, generators, and any object exposing `[Symbol.iterator]` @@ -849,17 +857,15 @@ pub extern "C" fn js_aggregateerror_new_full( Ok(arr) => arr, Err(_) => throw_not_iterable_type_error(), }; + let arr_h = scope.root_raw_mut_ptr(arr); + let err = js_error_new_kind_from_value(ERROR_KIND_AGGREGATE_ERROR, message_h.get_nanbox_f64()); + let err_h = scope.root_raw_mut_ptr(err); unsafe { - let ptr = alloc_error( - ERROR_KIND_AGGREGATE_ERROR, - b"AggregateError", - message, - !message.is_null(), - ); - error_set_errors(ptr, arr); - apply_cause_from_options(ptr, options); - ptr + err_h.with_mut_ptr(|err| arr_h.with_mut_ptr(|arr| error_set_errors(err, arr))); + let options = options_h.get_nanbox_f64(); + err_h.with_mut_ptr(|err| apply_cause_from_options(err, options)); } + err_h.with_mut_ptr(|err| err) } /// #2904: `Error.isError(value)` — V8/Node duck-check that returns `true` @@ -1685,11 +1691,8 @@ static KEEP_ERROR_NEW_KIND_WITH_OPTIONS: extern "C" fn( ) -> *mut ErrorHeader = js_error_new_kind_with_options; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] -static KEEP_AGGREGATEERROR_NEW_FULL: extern "C" fn( - f64, - *mut StringHeader, - f64, -) -> *mut ErrorHeader = js_aggregateerror_new_full; +static KEEP_AGGREGATEERROR_NEW_FULL: extern "C" fn(f64, f64, f64) -> *mut ErrorHeader = + js_aggregateerror_new_full; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] static KEEP_ERROR_IS_ERROR: extern "C" fn(f64) -> f64 = js_error_is_error; diff --git a/crates/perry-runtime/src/fs/stream.rs b/crates/perry-runtime/src/fs/stream.rs index 0ed1721d77..9e972ffca5 100644 --- a/crates/perry-runtime/src/fs/stream.rs +++ b/crates/perry-runtime/src/fs/stream.rs @@ -1628,7 +1628,9 @@ fn stream_on_common(id: usize, event_value: f64, cb: f64, once: bool) { pub(crate) fn extract_closure_ptr(v: f64) -> *const ClosureHeader { let bits = v.to_bits(); let top16 = bits >> 48; - let raw = if (0x7FF8..=0x7FFF).contains(&top16) { + // An inline SSO string (0x7FF9) carries characters, not an address: its + // masked bits used to reach `is_closure_ptr` as a pointer (#11519). + let raw = if (0x7FF8..=0x7FFF).contains(&top16) && top16 != 0x7FF9 { (bits & 0x0000_FFFF_FFFF_FFFF) as usize } else if top16 == 0 { bits as usize diff --git a/crates/perry-runtime/src/object/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index d1b5363125..a7fddb34af 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -366,19 +366,11 @@ unsafe fn buffer_secret_export_format(bits: f64) -> Option { } let key = crate::string::js_string_from_bytes(b"format".as_ptr(), 6); let val = js_object_get_field_by_name(obj, key); - let vbits = val.bits(); - if (vbits >> 48) as u16 != 0x7FFF { - return None; - } - let ptr = (vbits & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; - if ptr.is_null() { - return None; - } - let bytes = std::slice::from_raw_parts( - (ptr as *const u8).add(std::mem::size_of::()), - (*ptr).byte_len as usize, - ); - Some(String::from_utf8_lossy(bytes).to_ascii_lowercase()) + // Heap or inline SSO string (#11519): `"pem"`/`"der"`/`"jwk"` built at + // runtime are short enough to be SSO. + crate::string::with_string_value_bytes(f64::from_bits(val.bits()), |bytes| { + String::from_utf8_lossy(bytes).to_ascii_lowercase() + }) } unsafe fn secret_key_jwk_object(buf_ptr: *mut crate::buffer::BufferHeader) -> f64 { @@ -406,20 +398,11 @@ unsafe fn secret_key_jwk_object(buf_ptr: *mut crate::buffer::BufferHeader) -> f6 } unsafe fn js_string_from_value(bits: f64) -> Option { - let raw = bits.to_bits(); - let top16 = (raw >> 48) as u16; - if top16 != 0x7FFF { - return None; - } - let ptr = (raw & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; - if ptr.is_null() { - return None; - } - let bytes = std::slice::from_raw_parts( - (ptr as *const u8).add(std::mem::size_of::()), - (*ptr).byte_len as usize, - ); - std::str::from_utf8(bytes).ok().map(str::to_string) + // Heap or inline SSO string (#11519). + crate::string::with_string_value_bytes(bits, |bytes| { + std::str::from_utf8(bytes).ok().map(str::to_string) + }) + .flatten() } unsafe fn object_field_string_value(obj_bits: f64, name: &[u8]) -> Option { @@ -1170,30 +1153,16 @@ pub unsafe fn dispatch_buffer_method( false } else { let key_bits = args[0].to_bits(); - if (key_bits >> 48) == 0x7FFF { - // string key - let sptr = - (key_bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::string::StringHeader; - if sptr.is_null() { - false - } else { - let slen = (*sptr).byte_len as usize; - let sdata = - (sptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(sdata, slen); - if let Ok(s) = std::str::from_utf8(bytes) { - // Only numeric-string indices that are in bounds - // count as own properties for Buffer/Uint8Array. - if let Ok(idx) = s.parse::() { - let buf_len = (*buf_ptr).length; - idx < buf_len - } else { - false - } - } else { - false - } - } + if let Some(own) = crate::string::with_string_value_bytes(args[0], |bytes| { + // A string key — heap or inline SSO (#11519). Only + // numeric-string indices that are in bounds count as own + // properties for Buffer/Uint8Array. + std::str::from_utf8(bytes) + .ok() + .and_then(|s| s.parse::().ok()) + .is_some_and(|idx| idx < (*buf_ptr).length) + }) { + own } else if (key_bits >> 48) == 0x7FFE { // int32 key let idx = (key_bits & 0xFFFF_FFFF) as i32; @@ -1222,27 +1191,16 @@ pub unsafe fn dispatch_buffer_method( false } else { let key_bits = args[0].to_bits(); - if (key_bits >> 48) == 0x7FFF { - let sptr = - (key_bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::string::StringHeader; - if sptr.is_null() { - false - } else { - let slen = (*sptr).byte_len as usize; - let sdata = - (sptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(sdata, slen); - if let Ok(s) = std::str::from_utf8(bytes) { - if let Ok(idx) = s.parse::() { - let buf_len = (*buf_ptr).length; - idx < buf_len - } else { - false - } - } else { - false - } - } + if let Some(own) = crate::string::with_string_value_bytes(args[0], |bytes| { + // A string key — heap or inline SSO (#11519). Only + // numeric-string indices that are in bounds count as own + // properties for Buffer/Uint8Array. + std::str::from_utf8(bytes) + .ok() + .and_then(|s| s.parse::().ok()) + .is_some_and(|idx| idx < (*buf_ptr).length) + }) { + own } else if (key_bits >> 48) == 0x7FFE { let idx = (key_bits & 0xFFFF_FFFF) as i32; let buf_len = (*buf_ptr).length as i32; diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 40de0c9758..464520fb15 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -941,6 +941,27 @@ pub fn str_bytes_from_jsvalue( None } +/// Run `f` over the bytes of a string value in either representation — heap +/// `STRING_TAG` or inline SSO `SHORT_STRING_TAG` — and return its result, or +/// `None` when `value` is not a string (#11519). +/// +/// This is the closure form of [`str_bytes_from_jsvalue`]: the SSO bytes live +/// in a stack scratch buffer owned by this frame, so no allocation happens on +/// either path. `f` must not allocate on the GC heap while it holds the slice +/// (a heap string's payload could move); copy the bytes out first if it needs +/// to. +#[inline] +pub fn with_string_value_bytes(value: f64, f: impl FnOnce(&[u8]) -> R) -> Option { + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let (ptr, len) = str_bytes_from_jsvalue(value, &mut scratch)?; + if ptr.is_null() || len == 0 { + return Some(f(&[])); + } + // SAFETY: `str_bytes_from_jsvalue` returned a live heap payload or a view + // of `scratch`, both valid for the duration of this call. + Some(f(unsafe { slice::from_raw_parts(ptr, len as usize) })) +} + /// Sibling of [`str_bytes_from_jsvalue`] that additionally reports whether the /// operand is pure ASCII. /// diff --git a/crates/perry-runtime/src/symbol/constructors.rs b/crates/perry-runtime/src/symbol/constructors.rs index 9547753b5c..4474ecd1ab 100644 --- a/crates/perry-runtime/src/symbol/constructors.rs +++ b/crates/perry-runtime/src/symbol/constructors.rs @@ -141,15 +141,18 @@ pub unsafe extern "C" fn js_symbol_for(key_f64: f64) -> f64 { /// which is exactly what lets the `__knownSymbol` fallback to `Symbol.for` fire). #[no_mangle] pub unsafe extern "C" fn js_symbol_computed_member(ctor_f64: f64, key_f64: f64) -> f64 { - let bits = key_f64.to_bits(); - if bits & 0xFFFF_0000_0000_0000 == STRING_TAG { - let key_ptr = (bits & POINTER_MASK) as *const StringHeader; - if let Some(name) = str_from_header(key_ptr) { - if is_well_known_symbol_member_name(&name) { - let wk_ptr = well_known_symbol(&name); - return f64::from_bits(POINTER_TAG | (wk_ptr as u64 & POINTER_MASK)); - } - } + // A heap or inline SSO string key (#11519) — `"match"` and `"split"` are + // short enough to arrive inline when built at runtime. + let well_known = crate::string::with_string_value_bytes(key_f64, |bytes| { + std::str::from_utf8(bytes) + .ok() + .filter(|name| is_well_known_symbol_member_name(name)) + .map(str::to_string) + }) + .flatten(); + if let Some(name) = well_known { + let wk_ptr = well_known_symbol(&name); + return f64::from_bits(POINTER_TAG | (wk_ptr as u64 & POINTER_MASK)); } // Not a well-known symbol name — behave exactly like a plain `Symbol[key]` // read on the constructor value. diff --git a/crates/perry-runtime/src/temporal/duration.rs b/crates/perry-runtime/src/temporal/duration.rs index cb3cb7d69a..b25971ab32 100644 --- a/crates/perry-runtime/src/temporal/duration.rs +++ b/crates/perry-runtime/src/temporal/duration.rs @@ -73,7 +73,7 @@ pub(crate) fn coerce_duration(v: f64) -> Duration { } let jv = JSValue::from_bits(v.to_bits()); // String → ISO-8601 duration parse. - if jv.is_string() { + if jv.is_any_string() { let s = super::dispatch::read_string(v); return ok_or_throw(Duration::from_utf8(s.as_bytes())); } diff --git a/crates/perry-runtime/src/temporal/instant.rs b/crates/perry-runtime/src/temporal/instant.rs index e8286febb8..776eb97109 100644 --- a/crates/perry-runtime/src/temporal/instant.rs +++ b/crates/perry-runtime/src/temporal/instant.rs @@ -35,7 +35,7 @@ fn require_ns(v: f64) -> i128 { 0 }; } - if jv.is_string() { + if jv.is_any_string() { let s = dispatch::read_string(v); let t = s.trim(); if t.is_empty() { @@ -107,7 +107,7 @@ fn coerce_instant(v: f64) -> Instant { } } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { return ok_or_throw(Instant::from_utf8(dispatch::read_string(v).as_bytes())); } // Non-Temporal object → ToString → parse as an instant string. diff --git a/crates/perry-runtime/src/temporal/options.rs b/crates/perry-runtime/src/temporal/options.rs index f6330de25c..49979b7793 100644 --- a/crates/perry-runtime/src/temporal/options.rs +++ b/crates/perry-runtime/src/temporal/options.rs @@ -338,7 +338,7 @@ pub fn calendar_slot(v: f64) -> temporal_rs::Calendar { return Calendar::default(); } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { let s = read_string(v); reject_bare_islamic(&s); return ok_or_throw(s.parse::()); @@ -364,7 +364,7 @@ pub fn calendar_slot(v: f64) -> temporal_rs::Calendar { /// Non-string values defer to [`calendar_slot`] (undefined → ISO, a Temporal /// value → its `[[Calendar]]`, anything else → TypeError). pub fn calendar_identifier(v: f64) -> temporal_rs::Calendar { - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { let s = read_string(v); reject_bare_islamic(&s); return ok_or_throw(temporal_rs::Calendar::try_from_utf8(s.as_bytes())); @@ -561,7 +561,7 @@ fn num_field(obj: *const crate::object::ObjectHeader, name: &str) -> Option /// `obj.` as a string, or `None` if absent / undefined / not a string. fn str_field(obj: *const crate::object::ObjectHeader, name: &str) -> Option { let raw = field(obj, name); - if is_undefined(raw) || !JSValue::from_bits(raw.to_bits()).is_string() { + if is_undefined(raw) || !JSValue::from_bits(raw.to_bits()).is_any_string() { return None; } Some(read_string(raw)) @@ -580,7 +580,7 @@ fn str_field_coerce(obj: *const crate::object::ObjectHeader, name: &str) -> Opti return None; } let jv = JSValue::from_bits(raw.to_bits()); - if jv.is_string() { + if jv.is_any_string() { return Some(read_string(raw)); } if unsafe { crate::symbol::js_is_symbol(raw) } != 0 { @@ -665,7 +665,7 @@ pub fn rounding_options(arg: f64) -> RoundingOptions { o.rounding_mode = None; o.increment = None; - if JSValue::from_bits(arg.to_bits()).is_string() { + if JSValue::from_bits(arg.to_bits()).is_any_string() { o.smallest_unit = Some(parse_unit(&read_string(arg))); return o; } @@ -696,7 +696,7 @@ pub fn duration_round_options(arg: f64) -> (RoundingOptions, Option) o.rounding_mode = None; o.increment = None; - if JSValue::from_bits(arg.to_bits()).is_string() { + if JSValue::from_bits(arg.to_bits()).is_any_string() { o.smallest_unit = Some(parse_unit(&read_string(arg))); return (o, None); } @@ -721,7 +721,7 @@ pub fn duration_round_options(arg: f64) -> (RoundingOptions, Option) /// `relativeTo`. Options are read in spec (alphabetical) order: `relativeTo`, /// `unit`. pub fn total_options(arg: f64) -> (Unit, Option) { - if JSValue::from_bits(arg.to_bits()).is_string() { + if JSValue::from_bits(arg.to_bits()).is_any_string() { return (parse_unit(&read_string(arg)), None); } match as_obj(arg) { @@ -764,7 +764,7 @@ fn read_fractional_second_digits( if unsafe { crate::symbol::js_is_symbol(raw) } != 0 { type_error("Cannot convert a Symbol value to a string".to_string()); } - let s = if jv.is_string() { + let s = if jv.is_any_string() { read_string(raw) } else { let sh = crate::value::js_jsvalue_to_string_coerce(raw); @@ -919,7 +919,7 @@ fn relative_to_field(obj: *const crate::object::ObjectHeader) -> Option return None; } let jv = JSValue::from_bits(raw.to_bits()); - if jv.is_string() { + if jv.is_any_string() { return Some(read_string(raw)); } if jv.is_pointer() && unsafe { crate::symbol::js_is_symbol(raw) } == 0 { @@ -1347,7 +1347,7 @@ fn require_string_field(obj: *const crate::object::ObjectHeader, name: &str) -> // IS a string (and then fails the month-code/offset syntax check). match unsafe { crate::value::to_string_primitive::ordinary_to_primitive_string(raw) } { Some(prim) => { - if JSValue::from_bits(prim.to_bits()).is_string() { + if JSValue::from_bits(prim.to_bits()).is_any_string() { return Some(read_string(prim)); } // non-string primitive result → require-string fails (TypeError) @@ -1413,7 +1413,7 @@ pub fn optional_plain_time(v: f64) -> Option { if let Some(super::TemporalValue::PlainTime(t)) = super::temporal_value_ref(v) { return Some(*t); } - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { return Some(ok_or_throw(read_string(v).parse::())); } if let Some(o) = as_obj(v) { @@ -1449,7 +1449,7 @@ pub fn optional_instant_timezone(arg: f64) -> Option { /// Resolve a time-zone argument — a tz-identifier string or a /// `Temporal.ZonedDateTime` whose zone is reused. pub fn timezone(v: f64) -> TimeZone { - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { // A string identifier: an invalid one is a `RangeError`. return ok_or_throw(TimeZone::try_from_str(&read_string(v))); } @@ -1470,7 +1470,7 @@ pub fn transition_direction(v: f64) -> TransitionDirection { // (the option is required) and for any non-object primitive (boolean, number, // bigint, symbol, null). Only a malformed *string* / object `direction` value // is a RangeError. - let s = if JSValue::from_bits(v.to_bits()).is_string() { + let s = if JSValue::from_bits(v.to_bits()).is_any_string() { read_string(v) } else if is_undefined(v) { type_error("getTimeZoneTransition: direction option is required".to_string()); diff --git a/crates/perry-runtime/src/temporal/plain_date.rs b/crates/perry-runtime/src/temporal/plain_date.rs index 93500e0477..598df52809 100644 --- a/crates/perry-runtime/src/temporal/plain_date.rs +++ b/crates/perry-runtime/src/temporal/plain_date.rs @@ -69,7 +69,7 @@ fn coerce_date_with_overflow( ), None => {} } - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { return ok_or_throw(dispatch::read_string(v).parse::()); } super::options::plain_date_from_bag(v, overflow) @@ -89,7 +89,7 @@ pub fn from_static(args: &[f64]) -> f64 { // LAST (`observable-get-overflow` / `order-of-operations`). let item = raw_arg(args, 0); let opts = raw_arg(args, 1); - if JSValue::from_bits(item.to_bits()).is_string() { + if JSValue::from_bits(item.to_bits()).is_any_string() { let d = ok_or_throw(dispatch::read_string(item).parse::()); let _ = super::options::overflow(opts); return wrap(d); @@ -162,7 +162,7 @@ pub fn get(d: &PlainDate, name: &str) -> Option { /// Parse the `toZonedDateTime` argument: either a bare time-zone identifier /// string or an options object `{ timeZone, plainTime }`. fn to_zoned_args(v: f64) -> (TimeZone, Option) { - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { return (super::options::timezone(v), None); } let jv = JSValue::from_bits(v.to_bits()); diff --git a/crates/perry-runtime/src/temporal/plain_date_time.rs b/crates/perry-runtime/src/temporal/plain_date_time.rs index c4ee9d55d1..d8793ad847 100644 --- a/crates/perry-runtime/src/temporal/plain_date_time.rs +++ b/crates/perry-runtime/src/temporal/plain_date_time.rs @@ -86,7 +86,7 @@ fn coerce_dt_with_opts(v: f64, opts: f64) -> PlainDateTime { ), None => {} } - if JSValue::from_bits(v.to_bits()).is_string() { + if JSValue::from_bits(v.to_bits()).is_any_string() { let dt = ok_or_throw(dispatch::read_string(v).parse::()); let _ = super::options::overflow(opts); return dt; diff --git a/crates/perry-runtime/src/temporal/plain_month_day.rs b/crates/perry-runtime/src/temporal/plain_month_day.rs index 92032e57fd..bc95801b52 100644 --- a/crates/perry-runtime/src/temporal/plain_month_day.rs +++ b/crates/perry-runtime/src/temporal/plain_month_day.rs @@ -55,7 +55,7 @@ fn coerce_md(v: f64) -> PlainMonthDay { return md.clone(); } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { return ok_or_throw(dispatch::read_string(v).parse::()); } if jv.is_pointer() { @@ -81,7 +81,7 @@ pub fn from_static(args: &[f64]) -> f64 { // never threw.) let item = raw_arg(args, 0); let opts = raw_arg(args, 1); - if JSValue::from_bits(item.to_bits()).is_string() { + if JSValue::from_bits(item.to_bits()).is_any_string() { let md = ok_or_throw(dispatch::read_string(item).parse::()); let _ = super::options::overflow(opts); return wrap(md); diff --git a/crates/perry-runtime/src/temporal/plain_time.rs b/crates/perry-runtime/src/temporal/plain_time.rs index 1c58f6a6e3..a0066874b1 100644 --- a/crates/perry-runtime/src/temporal/plain_time.rs +++ b/crates/perry-runtime/src/temporal/plain_time.rs @@ -59,7 +59,7 @@ fn coerce_time_overflow(v: f64, overflow: temporal_rs::options::Overflow) -> Pla _ => {} } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { let s = dispatch::read_string(v); return ok_or_throw(s.parse::()); } @@ -102,7 +102,7 @@ pub fn from_static(args: &[f64]) -> f64 { // `overflow` LAST. let item = raw_arg(args, 0); let opts = raw_arg(args, 1); - if JSValue::from_bits(item.to_bits()).is_string() { + if JSValue::from_bits(item.to_bits()).is_any_string() { let t = ok_or_throw(dispatch::read_string(item).parse::()); let _ = super::options::overflow(opts); return wrap(t); diff --git a/crates/perry-runtime/src/temporal/plain_year_month.rs b/crates/perry-runtime/src/temporal/plain_year_month.rs index ad47ce6d56..9990b40ce0 100644 --- a/crates/perry-runtime/src/temporal/plain_year_month.rs +++ b/crates/perry-runtime/src/temporal/plain_year_month.rs @@ -51,7 +51,7 @@ fn coerce_ym(v: f64) -> PlainYearMonth { return ym.clone(); } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { return ok_or_throw(dispatch::read_string(v).parse::()); } if jv.is_pointer() { @@ -76,7 +76,7 @@ pub fn from_static(args: &[f64]) -> f64 { // then `overflow` LAST. let item = raw_arg(args, 0); let opts = raw_arg(args, 1); - if JSValue::from_bits(item.to_bits()).is_string() { + if JSValue::from_bits(item.to_bits()).is_any_string() { let ym = ok_or_throw(dispatch::read_string(item).parse::()); let _ = super::options::overflow(opts); return wrap(ym); diff --git a/crates/perry-runtime/src/temporal/zoned_date_time.rs b/crates/perry-runtime/src/temporal/zoned_date_time.rs index 65c663ed57..12b9a8a21a 100644 --- a/crates/perry-runtime/src/temporal/zoned_date_time.rs +++ b/crates/perry-runtime/src/temporal/zoned_date_time.rs @@ -34,7 +34,7 @@ fn require_ns(v: f64) -> i128 { b if b == crate::value::TAG_FALSE => return 0, _ => {} } - if jv.is_string() { + if jv.is_any_string() { return dispatch::read_string(v) .trim() .parse::() @@ -51,7 +51,7 @@ fn require_ns(v: f64) -> i128 { fn timezone_arg(v: f64) -> TimeZone { let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { // `ToTemporalTimeZoneIdentifier` (strict): only IANA names and UTC-offset // strings are accepted. An ISO-datetime string like // "1997-12-04T12:34[+01:00]" must throw a RangeError. @@ -118,7 +118,7 @@ fn coerce_zdt_with_options(v: f64, opts: f64) -> ZonedDateTime { )); } let jv = JSValue::from_bits(v.to_bits()); - if jv.is_string() { + if jv.is_any_string() { // Parse the string BEFORE reading options (spec order: an invalid string // throws a RangeError before `GetOptionsObject` — which would otherwise // throw a TypeError for a non-object options value). `from_utf8` couples diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 8f4ce017d1..8f8f3b90fa 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -185,6 +185,11 @@ fn is_truthy_bits(bits: u64) -> bool { } return unsafe { (*ptr).byte_len > 0 }; } + // Inline SSO string (#11519): truthy iff non-empty. Its bits are a NaN + // pattern, so the f64 fallthrough below would call every one falsy. + if crate::value::JSValue::from_bits(bits).is_short_string() { + return crate::value::JSValue::from_bits(bits).short_string_len() > 0; + } // Pointer (object/array/closure): always truthy if (bits & TAG_MASK) == POINTER_TAG || (bits & TAG_MASK) == BIGINT_TAG { return true; diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index a598345205..16357914e3 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -1123,11 +1123,13 @@ pub(crate) fn jsvalue_to_f64(v: f64) -> f64 { let bits = v.to_bits(); let top16 = bits >> 48; // Plain double — positive, negative, ±Inf, and all NaN patterns that - // are NOT NaN-box tags. Tagged values occupy top16 in 0x7FFA..0x7FFF - // (BIGINT_TAG=0x7FFA, 0x7FFC=undefined/null/bool, POINTER_TAG=0x7FFD, - // INT32_TAG=0x7FFE, STRING_TAG=0x7FFF). Negative doubles (top16≥0x8000) - // and non-tag NaN patterns (top16 in 0x7FF8..0x7FF9) return as-is. - if !(0x7FFA..0x8000).contains(&top16) { + // are NOT NaN-box tags. Tagged values occupy top16 in + // 0x7FF9..=0x7FFF (SHORT_STRING_TAG=0x7FF9, BIGINT_TAG=0x7FFA, + // 0x7FFC=undefined/null/bool, POINTER_TAG=0x7FFD, INT32_TAG=0x7FFE, + // STRING_TAG=0x7FFF). Negative doubles (top16≥0x8000) and the canonical + // NaN (top16 0x7FF8) return as-is. An inline SSO string must reach the + // string arm below, not be stored as its raw NaN bits (#11519). + if !(0x7FF9..0x8000).contains(&top16) { return v; } // ECMA-262 IntegerIndexedElementSet on a non-bigint view performs @@ -1159,22 +1161,15 @@ pub(crate) fn jsvalue_to_f64(v: f64) -> f64 { if bits == 0x7FFC_0000_0000_0001 { return f64::NAN; // undefined -> NaN } - // Strings: try to parse, else 0/NaN - if top16 == 0x7FFF { - let str_ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::string::StringHeader; - if !str_ptr.is_null() && (str_ptr as usize) >= 0x1000 { - unsafe { - let len = (*str_ptr).byte_len as usize; - let data = - (str_ptr as *const u8).add(std::mem::size_of::()); - if let Ok(s) = std::str::from_utf8(std::slice::from_raw_parts(data, len)) { - if let Ok(n) = s.trim().parse::() { - return n; - } - } - } - } - return f64::NAN; + // Strings (heap or inline SSO, #11519): try to parse, else NaN. + if top16 == 0x7FFF || top16 == 0x7FF9 { + return crate::string::with_string_value_bytes(v, |bytes| { + std::str::from_utf8(bytes) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(f64::NAN) + }) + .unwrap_or(f64::NAN); } // POINTER_TAG object (Symbols already threw above; BigInt handled above): // a non-bigint view performs `ToNumber(value)`, which for an object runs diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index ad1eac3336..0682bb56e1 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -74,19 +74,38 @@ pub(crate) fn create_string_f64(s: &str) -> f64 { } /// Get string content from a NaN-boxed StringHeader pointer (passed as f64) +#[inline] pub(crate) fn get_string_content(ptr_f64: f64) -> String { - // Extract the pointer from NaN-boxed value using proper unboxing - let ptr_i64 = crate::value::js_nanbox_get_string_pointer(ptr_f64); - let ptr: *mut StringHeader = ptr_i64 as *mut StringHeader; - if ptr.is_null() || ptr_i64 == 0 { - return String::new(); + let jsval = crate::value::JSValue::from_bits(ptr_f64.to_bits()); + // Heap string first: the URL helpers call this once per stored field, and + // this arm is exactly the pre-#11519 body. + if jsval.is_string() { + let ptr = jsval.as_string_ptr(); + if ptr.is_null() { + return String::new(); + } + unsafe { + let len = (*ptr).byte_len as usize; + let slice = std::slice::from_raw_parts(crate::string::string_data(ptr), len); + return String::from_utf8_lossy(slice).into_owned(); + } } - unsafe { - let len = (*ptr).byte_len as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let slice = std::slice::from_raw_parts(data_ptr, len); - String::from_utf8_lossy(slice).into_owned() + if jsval.is_short_string() { + return short_string_content(jsval); } + String::new() +} + +/// The inline-SSO arm of [`get_string_content`] (#11519): a short string built +/// at runtime (`new URLSearchParams("a" + "=1")`, `url.parse(String(n))`) used +/// to read as "" because only the heap tag was unboxed. Out of line so the +/// heap path stays as small as it was. +#[cold] +#[inline(never)] +fn short_string_content(jsval: crate::value::JSValue) -> String { + let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let n = jsval.short_string_to_buf(&mut buf); + String::from_utf8_lossy(&buf[..n]).into_owned() } pub(crate) fn string_from_header(ptr: *mut crate::StringHeader) -> String { diff --git a/crates/perry-runtime/src/validators.rs b/crates/perry-runtime/src/validators.rs index fbaa8e31d7..b1e9f4f308 100644 --- a/crates/perry-runtime/src/validators.rs +++ b/crates/perry-runtime/src/validators.rs @@ -36,7 +36,8 @@ fn jv(value: f64) -> JSValue { fn is_closure_value(value: f64) -> bool { let bits = value.to_bits(); let top16 = bits >> 48; - let raw = if (0x7FF8..=0x7FFF).contains(&top16) { + // 0x7FF9 is an inline SSO string: characters, not an address (#11519). + let raw = if (0x7FF8..=0x7FFF).contains(&top16) && top16 != 0x7FF9 { (bits & 0x0000_FFFF_FFFF_FFFF) as usize } else if top16 == 0 { bits as usize diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index 68b2539752..96a6bf031b 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -326,6 +326,23 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( // Check if this is a NaN-boxed string - handle string properties like .length let bits = obj_value.to_bits(); + // Inline SSO string (#11519): it carries no header, so the heap arm + // below cannot see it, and the pointer path further down unboxes it to 0. + let sso = crate::value::JSValue::from_bits(bits); + if sso.is_short_string() { + if property_name_ptr.is_null() { + return f64::from_bits(TAG_UNDEFINED); + } + let name_slice = if property_name_len > 0 { + std::slice::from_raw_parts(property_name_ptr as *const u8, property_name_len) + } else { + std::ffi::CStr::from_ptr(property_name_ptr as *const std::ffi::c_char).to_bytes() + }; + if name_slice == b"length" { + return sso.short_string_utf16_len() as f64; + } + return f64::from_bits(TAG_UNDEFINED); + } if (bits & TAG_MASK) == STRING_TAG { let str_ptr = (bits & POINTER_MASK) as *const crate::string::StringHeader; if !str_ptr.is_null() { diff --git a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs index b81438381a..77a6fb6f51 100644 --- a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs +++ b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs @@ -86,6 +86,12 @@ pub(crate) unsafe fn dispatch_zlib_stream(handle: i64, method: &str, args: &[f64 not(target_os = "android") ))] pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, args: &[f64]) -> f64 { + // A string argument (event name, servername) for an ext-net native that + // reads it as a `*const StringHeader` on entry: an inline SSO value goes + // through `js_ffi_arg_ptr`'s scratch header, not a bare mask (#11519). + fn str_arg(v: f64) -> i64 { + perry_runtime::value::js_ffi_arg_ptr(v) + } fn unbox_to_i64(v: f64) -> i64 { (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 } @@ -183,7 +189,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg f64::from_bits(0x7FFC_0000_0000_0001) } "emit" if !args.is_empty() => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let rest = args.get(1..).unwrap_or(&[]); js_ext_net_socket_emit(handle, event_ptr, rest.as_ptr(), rest.len()) } @@ -192,7 +198,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg f64::from_bits(0x7FFC_0000_0000_0001) } "on" | "addListener" if args.len() >= 2 => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let cb_ptr = unbox_to_i64(args[1]); js_ext_net_socket_on(handle, event_ptr, cb_ptr); nanbox_handle(handle) @@ -205,7 +211,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg nanbox_handle(handle) } "upgradeToTLS" if !args.is_empty() => { - let servername_ptr = unbox_to_i64(args[0]); + let servername_ptr = str_arg(args[0]); let verify = if args.len() >= 2 { args[1] } else { 1.0 }; let promise = js_net_socket_upgrade_tls(handle, servername_ptr, verify); f64::from_bits(0x7FFD_0000_0000_0000u64 | (promise as u64 & 0x0000_FFFF_FFFF_FFFF)) @@ -215,13 +221,13 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // is the dominant case; the static class info is lost between // the connection event push and the user callback). "once" if args.len() >= 2 => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let cb_ptr = unbox_to_i64(args[1]); js_ext_net_socket_once(handle, event_ptr, cb_ptr); nanbox_handle(handle) } "off" | "removeListener" if args.len() >= 2 => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let cb_ptr = unbox_to_i64(args[1]); js_ext_net_socket_remove_listener(handle, event_ptr, cb_ptr); nanbox_handle(handle) @@ -230,12 +236,12 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // Bare `removeAllListeners()` passes no event, padded as // `undefined`; the FFI treats a null/non-string ptr as // "drain every event". - let event_ptr = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let event_ptr = args.first().copied().map(str_arg).unwrap_or(0); js_ext_net_socket_remove_all_listeners(handle, event_ptr); nanbox_handle(handle) } "listenerCount" if !args.is_empty() => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); js_ext_net_socket_listener_count(handle, event_ptr) } "getMaxListeners" => js_ext_net_socket_get_max_listeners(handle), @@ -253,12 +259,12 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // for any-typed receivers. FFI returns a *mut ArrayHeader cast to i64; // NaN-box with POINTER_TAG (0x7FFD) so callers see a real JS array. "listeners" if !args.is_empty() => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let arr = js_ext_net_socket_listeners(handle, event_ptr); f64::from_bits(0x7FFD_0000_0000_0000u64 | (arr as u64 & 0x0000_FFFF_FFFF_FFFF)) } "rawListeners" if !args.is_empty() => { - let event_ptr = unbox_to_i64(args[0]); + let event_ptr = str_arg(args[0]); let arr = js_net_socket_raw_listeners(handle, event_ptr); f64::from_bits(0x7FFD_0000_0000_0000u64 | (arr as u64 & 0x0000_FFFF_FFFF_FFFF)) } diff --git a/crates/perry-stdlib/src/common/dispatch_http.rs b/crates/perry-stdlib/src/common/dispatch_http.rs index df760d8a29..644daeb70a 100644 --- a/crates/perry-stdlib/src/common/dispatch_http.rs +++ b/crates/perry-stdlib/src/common/dispatch_http.rs @@ -176,16 +176,22 @@ pub(super) unsafe fn dispatch_client_incoming_method( } let self_ref = f64::from_bits(0x7FFD_0000_0000_0000u64 | (handle as u64 & PTR_MASK)); + // String arguments go through `js_ffi_arg_ptr`, not a bare mask: an inline + // SSO event name or encoding (`"data"`, `"end"`, `"utf8"` built at + // runtime) has no header behind its bits (#11519). The natives copy the + // name out before returning. let value = match method_name { "setEncoding" if !args.is_empty() => { - let ptr = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; + let ptr = + perry_runtime::value::js_ffi_arg_ptr(args[0]) as *const perry_runtime::StringHeader; unsafe { js_http_incoming_message_set_encoding(handle, ptr); } self_ref } "on" | "addListener" if args.len() >= 2 => { - let event = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; + let event = + perry_runtime::value::js_ffi_arg_ptr(args[0]) as *const perry_runtime::StringHeader; let callback = (args[1].to_bits() & PTR_MASK) as i64; unsafe { js_http_on(handle, event, callback); @@ -193,7 +199,8 @@ pub(super) unsafe fn dispatch_client_incoming_method( self_ref } "once" if args.len() >= 2 => { - let event = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; + let event = + perry_runtime::value::js_ffi_arg_ptr(args[0]) as *const perry_runtime::StringHeader; let callback = (args[1].to_bits() & PTR_MASK) as i64; unsafe { js_http_once(handle, event, callback); diff --git a/crates/perry-stdlib/src/common/net_method_values.rs b/crates/perry-stdlib/src/common/net_method_values.rs index ce123f9818..63521f9678 100644 --- a/crates/perry-stdlib/src/common/net_method_values.rs +++ b/crates/perry-stdlib/src/common/net_method_values.rs @@ -54,6 +54,19 @@ fn unbox_to_i64(v: f64) -> i64 { (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 } +/// A string argument (event name, address, family) for a net native that reads +/// it as a `*const StringHeader` on entry. An inline SSO value goes through +/// `js_ffi_arg_ptr`'s scratch header rather than a bare mask (#11519); every +/// other value unboxes exactly as [`unbox_to_i64`] does. +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] +fn str_arg(v: f64) -> i64 { + perry_runtime::value::js_ffi_arg_ptr(v) +} + #[cfg(all( feature = "external-net-pump", not(target_os = "ios"), @@ -372,25 +385,25 @@ pub(crate) unsafe fn dispatch_external_block_list_method( let result = match method { "addAddress" => { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); + let family = args.get(1).copied().map(str_arg).unwrap_or(0); js_net_block_list_add_address(handle, address, family) } "addRange" => { - let start = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let end = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(2).copied().map(unbox_to_i64).unwrap_or(0); + let start = args.first().copied().map(str_arg).unwrap_or(0); + let end = args.get(1).copied().map(str_arg).unwrap_or(0); + let family = args.get(2).copied().map(str_arg).unwrap_or(0); js_net_block_list_add_range(handle, start, end, family) } "addSubnet" => { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); let prefix = args.get(1).copied().unwrap_or_else(undefined); - let family = args.get(2).copied().map(unbox_to_i64).unwrap_or(0); + let family = args.get(2).copied().map(str_arg).unwrap_or(0); js_net_block_list_add_subnet(handle, address, prefix, family) } "check" => { - let address = args.first().copied().map(unbox_to_i64).unwrap_or(0); - let family = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); + let address = args.first().copied().map(str_arg).unwrap_or(0); + let family = args.get(1).copied().map(str_arg).unwrap_or(0); js_net_block_list_check(handle, address, family) } "rules" | "toJSON" => js_net_block_list_to_json(handle), @@ -457,32 +470,32 @@ pub(crate) unsafe fn dispatch_external_server_method( } "address" => json_str_to_value(js_net_server_address(handle)), "on" | "addListener" if args.len() >= 2 => { - js_net_server_on(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + js_net_server_on(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "once" if args.len() >= 2 => { - js_net_server_once(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + js_net_server_once(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "off" | "removeListener" if args.len() >= 2 => { - js_net_server_remove_listener(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); + js_net_server_remove_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } "removeAllListeners" => { - let event = args.first().copied().map(unbox_to_i64).unwrap_or(0); + let event = args.first().copied().map(str_arg).unwrap_or(0); js_net_server_remove_all_listeners(handle, event); nanbox_handle(handle) } "listenerCount" if !args.is_empty() => { - js_net_server_listener_count(handle, unbox_to_i64(args[0])) + js_net_server_listener_count(handle, str_arg(args[0])) } "eventNames" => json_str_to_value(js_net_server_event_names(handle)), "listeners" if !args.is_empty() => { - let arr = js_net_server_listeners(handle, unbox_to_i64(args[0])); + let arr = js_net_server_listeners(handle, str_arg(args[0])); nanbox_handle(arr) } "rawListeners" if !args.is_empty() => { - let arr = js_net_server_raw_listeners(handle, unbox_to_i64(args[0])); + let arr = js_net_server_raw_listeners(handle, str_arg(args[0])); nanbox_handle(arr) } "ref" | "unref" => nanbox_handle(handle), diff --git a/crates/perry-stdlib/src/fetch_blob.rs b/crates/perry-stdlib/src/fetch_blob.rs index 571f7d9005..a5c07d9368 100644 --- a/crates/perry-stdlib/src/fetch_blob.rs +++ b/crates/perry-stdlib/src/fetch_blob.rs @@ -166,17 +166,15 @@ pub(crate) fn normalize_blob_type(raw: &str) -> String { /// undefined and other objects -> NaN. unsafe fn blob_to_number(value: f64) -> f64 { let bits = value.to_bits(); - // Heap string? - if (bits >> 48) == 0x7FFF { - let p = (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader; - if let Some(s) = string_from_header(p) { - let t = s.trim(); - if t.is_empty() { - return 0.0; - } - return t.parse::().unwrap_or(f64::NAN); - } - return f64::NAN; + // Heap or inline SSO string (#11519)? + if let Some(n) = + perry_runtime::string::with_string_value_bytes(value, |b| match std::str::from_utf8(b) { + Ok(s) if s.trim().is_empty() => 0.0, + Ok(s) => s.trim().parse::().unwrap_or(f64::NAN), + Err(_) => f64::NAN, + }) + { + return n; } // SSO / non-string: materialize via ToString then parse only if it was // a string-like value; otherwise use the numeric coercion of the value. diff --git a/crates/perry-stdlib/src/string_decoder.rs b/crates/perry-stdlib/src/string_decoder.rs index c747b98c21..8b2214aa52 100644 --- a/crates/perry-stdlib/src/string_decoder.rs +++ b/crates/perry-stdlib/src/string_decoder.rs @@ -340,22 +340,15 @@ pub unsafe fn string_decoder_own_property_names(handle: i64) -> f64 { /// detected from the top 16 bits. unsafe fn encoding_name_from_bits(bits: i64) -> Option { let u = bits as u64; - let top16 = u >> 48; - // SHORT_STRING_TAG = 0x7FFA. Payload is bytes inline in the - // remaining 48 bits, length in bits 44..47 of the top 16. - if top16 == 0x7FFA { - let len = ((u >> 44) & 0xF) as usize; - if len == 0 { - return Some(String::new()); - } - if len > 6 { - return None; - } - let mut bytes = [0u8; 6]; - for (i, b) in bytes.iter_mut().enumerate().take(len) { - *b = ((u >> (i * 8)) & 0xFF) as u8; - } - return Some(String::from_utf8_lossy(&bytes[..len]).into_owned()); + // A NaN-boxed string, heap or inline SSO (#11519). The inline arm here used + // to decode tag 0x7FFA -- BIGINT_TAG -- with a made-up layout, so a real SSO + // encoding name (SHORT_STRING_TAG 0x7FF9) fell through to the pointer read + // below and dereferenced its characters. + if JSValue::from_bits(u).is_any_string() { + return perry_runtime::string::with_string_value_bytes(f64::from_bits(u), |b| { + (b.len() <= 32).then(|| String::from_utf8_lossy(b).into_owned()) + }) + .flatten(); } // STRING_TAG / POINTER_TAG / raw pointer — all keep the heap address // in the low 48 bits. diff --git a/scripts/sso_unbox_baseline.txt b/scripts/sso_unbox_baseline.txt new file mode 100644 index 0000000000..1b67324a11 --- /dev/null +++ b/scripts/sso_unbox_baseline.txt @@ -0,0 +1,18 @@ +# SSO string unboxing baseline (#11519): see scripts/sso_unbox_inventory.py. +# Format: rule | crate | count +# Regenerate: python3 scripts/sso_unbox_inventory.py --write-baseline +# Counts may only decrease; a new crate starts at zero. + +heap-tag-only | perry-ext-better-sqlite3 | 1 +heap-tag-only | perry-ext-events | 1 +heap-tag-only | perry-ext-http | 2 +heap-tag-only | perry-ext-nodemailer | 1 +heap-tag-only | perry-ffi | 2 +heap-tag-only | perry-runtime | 54 +heap-tag-only | perry-stdlib | 8 +mask-cast | perry-ext-events | 3 +mask-cast | perry-ext-http | 2 +mask-cast | perry-ext-ws | 1 +mask-cast | perry-ffi | 1 +mask-cast | perry-runtime | 38 +mask-cast | perry-stdlib | 4 diff --git a/scripts/sso_unbox_inventory.py b/scripts/sso_unbox_inventory.py new file mode 100644 index 0000000000..7de102fee6 --- /dev/null +++ b/scripts/sso_unbox_inventory.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Ratchet the places that unbox a string value as if it were a heap pointer. + +Since #10762 short strings (up to ``SHORT_STRING_MAX_LEN`` bytes) are stored +inline in the NaN-box under ``SHORT_STRING_TAG`` (0x7FF9). There is no +``StringHeader`` behind such a value, so the classic unboxing +``(bits & POINTER_MASK) as *const StringHeader`` turns its characters into an +address: a segfault, or garbage. Code that first checks for ``STRING_TAG`` +(0x7FFF) avoids the crash but then treats the short string as "not a string" +and returns a wrong answer (#11430, #11519). + +Three source shapes are counted per crate: + +* ``mask-cast``: a function in a runtime-side crate that casts masked bits to + ``*const/*mut StringHeader`` and contains no sign of handling the inline + representation at all (no ``is_short_string``, ``is_any_string``, + ``SHORT_STRING``/``0x7FF9``, or a call to one of the SSO-aware accessors). + Many such functions are correct because their input can never be SSO -- keys + read back out of an object's keys array, internal caches -- which is why this + is a ratchet over debt rather than a ban. New code should use + ``str_bytes_from_jsvalue``/``with_string_value_bytes`` (borrow, no + allocation), ``js_ffi_arg_ptr``/``perry_ffi::string_arg_ptr`` (a native that + only reads during the call), ``JsValue::to_owned_string`` (ext crates), or + ``js_get_string_pointer_unified`` (a heap copy). +* ``heap-tag-only``: a function in a runtime-side crate that reads a + ``StringHeader`` behind a heap-only string test -- ``== STRING_TAG``, + ``== 0x7FFF``, ``.is_string()``, ``.as_string_ptr()``, + ``js_nanbox_get_string_pointer`` -- with no SSO marker anywhere in its body. + Such a function does not crash on a short string; it silently treats it as + "not a string" (``new Date("20" + "20")`` was an Invalid Date). One finding + per function. +* ``codegen-str-arg``: a perry-codegen call that passes the result of + ``unbox_to_i64`` (a bare mask) to a runtime entry whose parameter at that + position is declared ``*const/*mut StringHeader``. Operands loaded from a + string-literal handle global are exempt: literals are always heap strings. + Use ``unbox_ffi_str_arg`` / ``unbox_ffi_str_arg_inline`` / ``unbox_str_handle``. + +The committed baseline is debt, not an allowance for new code. A category may +never increase in a crate, and a decrease must lower the baseline in the same +change. New crates implicitly start at zero. + +Usage: + python3 scripts/sso_unbox_inventory.py + python3 scripts/sso_unbox_inventory.py --self-test + python3 scripts/sso_unbox_inventory.py --write-baseline + python3 scripts/sso_unbox_inventory.py --list +""" + +from __future__ import annotations + +import argparse +import importlib.util +import re +import sys +import tempfile +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_BASELINE = REPO_ROOT / "scripts" / "sso_unbox_baseline.txt" +RULES = ("mask-cast", "heap-tag-only", "codegen-str-arg") + +# Reuse the Rust comment/string masker and brace matcher of the sibling +# payload-access ratchet rather than keeping a second copy in sync. +_SPEC = importlib.util.spec_from_file_location( + "string_payload_access_inventory", + Path(__file__).resolve().parent / "string_payload_access_inventory.py", +) +_PAYLOAD = importlib.util.module_from_spec(_SPEC) +assert _SPEC.loader is not None +sys.modules[_SPEC.name] = _PAYLOAD +_SPEC.loader.exec_module(_PAYLOAD) +mask_non_code = _PAYLOAD.mask_non_code +matching_brace = _PAYLOAD.matching_brace + +MASK = ( + r"(?:POINTER_MASK|PTR_MASK|PAYLOAD_MASK|NANBOX_PTR_MASK|" + r"0x0000_FFFF_FFFF_FFFF|0x0000_ffff_ffff_ffff|0x0000FFFFFFFFFFFF|" + r"0xFFFF_FFFF_FFFF\b)" +) +MASK_CAST_RE = re.compile( + MASK + r"[^;{}]*?\bas\s+\*\s*(?:const|mut)\s+(?:[A-Za-z_][A-Za-z0-9_]*::)*StringHeader\b" +) +SSO_MARKER_RE = re.compile( + r"is_short_string|is_any_string|SHORT_STRING|0x7FF9|0x7ff9|short_string_to_buf|" + r"str_bytes_from_jsvalue|str_bytes_ascii_from_jsvalue|with_string_value_bytes|" + r"js_get_string_pointer_unified|js_ffi_arg_ptr|string_arg_ptr|to_owned_string|" + r"js_string_materialize_to_heap|js_value_to_str_ptr_for_ffi" +) +FUNCTION_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^{;]*?>)?\s*\(", re.MULTILINE) +HEAP_TAG_RE = re.compile( + r"[=!]=\s*(?:crate::value::|perry_runtime::value::)?(?:STRING_TAG|0x7FFF|0x7fff)\b|" + r"(?:STRING_TAG|0x7FFF)\s*=>|\.is_string\s*\(\s*\)|\.as_string_ptr\s*\(\s*\)|" + r"js_nanbox_get_string_pointer\s*\(" +) +TEST_MOD_RE = re.compile(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*\{") +TEST_FN_RE = re.compile(r"#\s*\[\s*test\s*\]") +EXTERN_FN_RE = re.compile( + r'extern\s+"C(?:-unwind)?"\s+fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)', re.S +) +# A codegen call: `"js_name", &[ (TY, &a), (TY, &b), ... ]`. +CALL_RE = re.compile(r'"([A-Za-z_][A-Za-z0-9_]*)"\s*,\s*&\[(.*?)\]\s*,?\s*\)', re.S) +UNBOX_LET_RE = re.compile( + r"let\s+(?:mut\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z0-9_]+\s*)?=\s*" + r"(?:crate::expr::|super::|helpers::)*unbox_to_i64\s*\(\s*[^,]+,\s*&\s*([A-Za-z_][A-Za-z0-9_.\[\]]*)\s*\)" +) +LITERAL_LOAD_RE_TMPL = r"let\s+{name}\s*=\s*[^;]*?\.load\s*\(\s*DOUBLE\s*,\s*&[^;]*?global" + + +@dataclass(frozen=True) +class Finding: + crate: str + rel_path: str + line_no: int + rule: str + detail: str + + def render(self) -> str: + return f"{self.rel_path}:{self.line_no}: [{self.rule}] {self.detail}" + + +def blank_test_modules(code: str) -> str: + """Blank `#[cfg(test)] mod … { … }` bodies; tests build SSO values on purpose.""" + out = code + cursor = 0 + while match := TEST_MOD_RE.search(out, cursor): + opening = match.end() - 1 + closing = matching_brace(out, opening) + if closing is None: + break + body = out[match.start() : closing + 1] + out = out[: match.start()] + re.sub(r"[^\n]", " ", body) + out[closing + 1 :] + cursor = closing + 1 + return out + + +def functions(code: str): + """Yield (name, start, body) for every top-level-or-nested fn with a body.""" + cursor = 0 + while match := FUNCTION_RE.search(code, cursor): + opening = code.find("{", match.end()) + semicolon = code.find(";", match.end()) + if opening < 0 or (0 <= semicolon < opening): + cursor = match.end() + continue + closing = matching_brace(code, opening) + if closing is None: + break + prefix = code[max(0, match.start() - 200) : match.start()] + is_test = bool(TEST_FN_RE.search(prefix.split("}")[-1])) + yield match.group(1), match.start(), code[match.start() : closing + 1], is_test + cursor = closing + 1 + + +def scan_mask_casts(crate: str, rel_path: str, text: str) -> list[Finding]: + code = blank_test_modules(mask_non_code(text)) + findings: list[Finding] = [] + for name, start, body, is_test in functions(code): + if is_test or SSO_MARKER_RE.search(body): + continue + for match in MASK_CAST_RE.finditer(body): + line = code.count("\n", 0, start + match.start()) + 1 + findings.append( + Finding(crate, rel_path, line, "mask-cast", f"fn {name}: masked bits cast to StringHeader") + ) + heap_test = HEAP_TAG_RE.search(body) + if heap_test and "StringHeader" in body or heap_test and "as_string_ptr" in body: + line = code.count("\n", 0, start + heap_test.start()) + 1 + findings.append( + Finding(crate, rel_path, line, "heap-tag-only", f"fn {name}: heap-only string test") + ) + return findings + + +def string_param_table(root: Path) -> dict[str, set[int]]: + """`extern "C" fn` name -> indexes of its `*const/*mut StringHeader` params.""" + table: dict[str, set[int]] = defaultdict(set) + for crate_dir in crate_dirs(root): + if crate_dir.name in ("perry-codegen", "perry-hir"): + continue + for path in sorted((crate_dir / "src").rglob("*.rs")) if (crate_dir / "src").is_dir() else []: + text = path.read_text(encoding="utf-8") + if "StringHeader" not in text: + continue + code = mask_non_code_keep_strings(text) + for match in EXTERN_FN_RE.finditer(code): + params = [p.strip() for p in re.split(r",(?![^<]*>)", match.group(2)) if p.strip()] + body = code[match.end() : match.end() + 3000] + for idx, param in enumerate(params): + if ":" not in param: + continue + pname, ptype = (part.strip() for part in param.split(":", 1)) + pname = pname.replace("mut ", "").strip() + if "StringHeader" in ptype: + table[match.group(1)].add(idx) + elif ptype in ("i64", "u64", "usize") and re.search( + r"\b" + re.escape(pname) + r"\s+as\s+(?:usize\s+as\s+)?\*\s*(?:const|mut)\s+" + r"(?:[A-Za-z_][A-Za-z0-9_]*::)*StringHeader\b|" + r"string_from_header_i64\s*\(\s*" + re.escape(pname) + r"\s*\)", + body, + ): + # An `i64` parameter the body reads as a StringHeader. + table[match.group(1)].add(idx) + return table + + +def scan_codegen(crate: str, rel_path: str, text: str, table: dict[str, set[int]]) -> list[Finding]: + code = blank_test_modules(mask_non_code_keep_strings(text)) + findings: list[Finding] = [] + for name, start, body, is_test in functions(code): + if is_test or "unbox_to_i64" not in body: + continue + for call in CALL_RE.finditer(body): + callee = call.group(1) + if callee not in table: + continue + args = re.split(r"\)\s*,\s*\(", call.group(2)) + for idx, arg in enumerate(args): + if idx not in table[callee]: + continue + refs = re.findall(r"&\s*([A-Za-z_][A-Za-z0-9_]*)", arg) + if not refs: + continue + operand = unboxed_operand(body[: call.start()], refs[-1]) + if operand is None: + continue + line = code.count("\n", 0, start + call.start()) + 1 + findings.append( + Finding( + crate, + rel_path, + line, + "codegen-str-arg", + f"fn {name}: unbox_to_i64({operand}) -> {callee} arg {idx}", + ) + ) + return findings + + +def unboxed_operand(before: str, var: str) -> str | None: + """The operand `var` was masked from, if its nearest binding is a bare + `unbox_to_i64` of a non-literal value; otherwise None.""" + binding = None + for match in re.finditer(r"let\s+(?:mut\s+)?" + re.escape(var) + r"\b[^=;]*=", before): + binding = match + if binding is None: + return None + unbox = UNBOX_LET_RE.match(before, binding.start()) + if unbox is None or unbox.group(1) != var: + return None + operand = unbox.group(2) + if re.search(LITERAL_LOAD_RE_TMPL.format(name=re.escape(operand)), before): + return None + return operand + + +def mask_non_code_keep_strings(text: str) -> str: + """Blank comments only: codegen names its callees with string literals.""" + out = re.sub(r"//[^\n]*", lambda m: " " * len(m.group(0)), text) + return re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), out, flags=re.S) + + +def crate_dirs(root: Path = REPO_ROOT) -> list[Path]: + crates = root / "crates" + return sorted(path for path in crates.iterdir() if (path / "Cargo.toml").is_file()) + + +def is_test_path(rel: Path) -> bool: + name = rel.name + return ( + "tests" in rel.parts + or name == "tests.rs" + or name.endswith("_tests.rs") + or name.startswith("test_") + ) + + +def collect_inventory(root: Path = REPO_ROOT) -> tuple[list[Finding], int]: + findings: list[Finding] = [] + files_scanned = 0 + table = string_param_table(root) + for crate_dir in crate_dirs(root): + crate = crate_dir.name + for path in sorted(crate_dir.rglob("*.rs")): + rel = path.relative_to(root) + # Filter on the path RELATIVE to the repo root -- see the same + # comment in string_payload_access_inventory.py (agents run under a + # dot-prefixed `.claude/worktrees/` directory). + if any(part.startswith(".") or part == "target" for part in rel.parts): + continue + if is_test_path(rel): + continue + files_scanned += 1 + text = path.read_text(encoding="utf-8") + if crate == "perry-codegen": + if "unbox_to_i64" in text: + findings.extend(scan_codegen(crate, rel.as_posix(), text, table)) + elif crate != "perry-hir" and "StringHeader" in text: + findings.extend(scan_mask_casts(crate, rel.as_posix(), text)) + return findings, files_scanned + + +def counts_for(findings: list[Finding]) -> Counter[tuple[str, str]]: + return Counter((finding.rule, finding.crate) for finding in findings) + + +def load_baseline(path: Path) -> dict[tuple[str, str], int]: + baseline: dict[tuple[str, str], int] = {} + if not path.is_file(): + return baseline + errors: list[str] = [] + for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = [part.strip() for part in line.split("|", 2)] + if len(parts) != 3 or parts[0] not in RULES or not parts[2].isdigit(): + errors.append(f"{path.name}:{line_no}: expected 'rule | crate | count', got: {raw}") + continue + key = (parts[0], parts[1]) + if key in baseline: + errors.append(f"{path.name}:{line_no}: duplicate entry for {key}") + continue + baseline[key] = int(parts[2]) + if errors: + print("\n".join(errors), file=sys.stderr) + raise SystemExit(2) + return baseline + + +def compare_counts(actual, baseline): + regressions = [] + stale = [] + for rule, crate in sorted(set(actual) | set(baseline)): + found = actual[(rule, crate)] + allowed = baseline.get((rule, crate), 0) + if found > allowed: + regressions.append((rule, crate, allowed, found)) + elif found < allowed: + stale.append((rule, crate, allowed, found)) + return regressions, stale + + +def write_baseline(path: Path, actual: Counter[tuple[str, str]]) -> None: + lines = [ + "# SSO string unboxing baseline (#11519): see scripts/sso_unbox_inventory.py.", + "# Format: rule | crate | count", + "# Regenerate: python3 scripts/sso_unbox_inventory.py --write-baseline", + "# Counts may only decrease; a new crate starts at zero.", + "", + ] + for rule, crate in sorted(actual): + if actual[(rule, crate)]: + lines.append(f"{rule} | {crate} | {actual[(rule, crate)]}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +PLANTED_RUNTIME = r''' +pub extern "C" fn js_planted_date_parse(value: f64) -> f64 { + let bits = value.to_bits(); + if (bits >> 48) == 0x7FFF { + let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; + return unsafe { (*ptr).byte_len as f64 }; + } + f64::NAN +} + +fn planted_value_reader(v: JSValue) -> usize { + if v.is_string() { + return unsafe { (*v.as_string_ptr()).byte_len as usize }; + } + 0 +} + +fn planted_unguarded(bits: u64) -> usize { + let p = (bits & POINTER_MASK) as usize as *mut StringHeader; + p as usize +} +''' + +CLEAN_RUNTIME = r''' +pub extern "C" fn js_clean_date_parse(value: f64) -> f64 { + crate::string::with_string_value_bytes(value, |b| b.len() as f64).unwrap_or(f64::NAN) +} + +fn handles_both(bits: u64) -> usize { + if JSValue::from_bits(bits).is_short_string() { + return 0; + } + (bits & POINTER_MASK) as *const StringHeader as usize +} + +// (bits & POINTER_MASK) as *const StringHeader in a comment is not code. +const DOC: &str = "(bits & POINTER_MASK) as *const StringHeader"; + +#[cfg(test)] +mod tests { + fn planted_in_test(bits: u64) -> usize { + (bits & POINTER_MASK) as *const StringHeader as usize + } +} +''' + +PLANTED_EXTERN = r''' +#[no_mangle] +pub extern "C" fn js_planted_native(handle: i64, name: *const StringHeader, n: f64) -> f64 { 0.0 } +''' + +PLANTED_CODEGEN = r''' +fn lower_planted(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { + let s_box = lower_expr(ctx, e)?; + let blk = ctx.block(); + let s_handle = unbox_to_i64(blk, &s_box); + Ok(blk.call(DOUBLE, "js_planted_native", &[(I64, &h), (I64, &s_handle), (DOUBLE, &n)])) +} + +fn lower_literal_key(ctx: &mut FnCtx<'_>) -> Result { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_handle = unbox_to_i64(blk, &key_box); + Ok(blk.call(DOUBLE, "js_planted_native", &[(I64, &h), (I64, &key_handle), (DOUBLE, &n)])) +} + +fn lower_object_arg(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { + let o_box = lower_expr(ctx, e)?; + let blk = ctx.block(); + let o_handle = unbox_to_i64(blk, &o_box); + Ok(blk.call(DOUBLE, "js_planted_native", &[(I64, &o_handle), (I64, &s), (DOUBLE, &n)])) +} + +fn lower_fixed(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { + let s_box = lower_expr(ctx, e)?; + let blk = ctx.block(); + let s_handle = unbox_ffi_str_arg(blk, &s_box); + Ok(blk.call(DOUBLE, "js_planted_native", &[(I64, &h), (I64, &s_handle), (DOUBLE, &n)])) +} +''' + + +def run_self_tests() -> int: + failures: list[str] = [] + + def expect(condition: bool, message: str) -> None: + if not condition: + failures.append(message) + + planted = scan_mask_casts("synthetic", "crates/synthetic/src/lib.rs", PLANTED_RUNTIME) + expect( + sum(f.rule == "mask-cast" for f in planted) == 2, + f"planted heap-only and unguarded mask casts: expected 2 mask-cast findings, got {planted}", + ) + expect( + sum(f.rule == "heap-tag-only" for f in planted) == 2, + f"planted `== 0x7FFF` and `.is_string()` readers: expected 2 heap-tag-only findings, got {planted}", + ) + clean = scan_mask_casts("synthetic", "crates/synthetic/src/lib.rs", CLEAN_RUNTIME) + expect(not clean, f"SSO-aware code, comments, strings or cfg(test) produced findings: {clean}") + + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + for crate, rel, text in ( + ("perry-runtime", "src/lib.rs", PLANTED_RUNTIME + PLANTED_EXTERN), + ("perry-codegen", "src/expr/planted.rs", PLANTED_CODEGEN), + ): + crate_dir = root / "crates" / crate + (crate_dir / Path(rel).parent).mkdir(parents=True, exist_ok=True) + (crate_dir / "Cargo.toml").write_text( + f'[package]\nname = "{crate}"\nversion = "0.0.0"\n', encoding="utf-8" + ) + (crate_dir / rel).write_text(text, encoding="utf-8") + table = string_param_table(root) + expect(table.get("js_planted_native") == {1}, f"string-param table wrong: {dict(table)}") + findings, scanned = collect_inventory(root) + expect(scanned == 2, f"expected 2 files scanned, got {scanned}") + counts = counts_for(findings) + expect( + counts[("mask-cast", "perry-runtime")] == 2 + and counts[("heap-tag-only", "perry-runtime")] == 2, + f"end-to-end runtime counts wrong: {dict(counts)}", + ) + # Exactly the user-value operand: not the literal key, not the object + # operand in a non-string position, not the already-fixed call. + expect( + counts[("codegen-str-arg", "perry-codegen")] == 1, + f"end-to-end codegen-str-arg count wrong: {[f.render() for f in findings]}", + ) + regressions, stale = compare_counts(counts, {}) + expect(bool(regressions) and not stale, "a zero baseline did not reject the planted offenders") + regressions, stale = compare_counts(counts, dict(counts)) + expect(not regressions and not stale, "a matching baseline was not accepted") + lowered = dict(counts) + lowered[("mask-cast", "perry-runtime")] += 1 + regressions, stale = compare_counts(counts, lowered) + expect(not regressions and bool(stale), "a removed offender did not require a repin") + + if failures: + for failure in failures: + print(f"self-test failure: {failure}", file=sys.stderr) + return 1 + print("sso-unbox inventory self-tests passed") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--list", action="store_true", help="print every finding") + parser.add_argument("--write-baseline", action="store_true") + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + args = parser.parse_args(argv) + + if args.self_test: + return run_self_tests() + + findings, files_scanned = collect_inventory() + if files_scanned == 0: + print( + "sso-unbox inventory: SCANNED NO FILES -- this is a broken scan, not a " + "converted tree. Do NOT run --write-baseline.", + file=sys.stderr, + ) + return 1 + actual = counts_for(findings) + if args.write_baseline: + write_baseline(args.baseline, actual) + print(f"wrote {args.baseline.relative_to(REPO_ROOT)}") + return 0 + if args.list: + for finding in findings: + print(finding.render()) + + baseline = load_baseline(args.baseline) + regressions, stale = compare_counts(actual, baseline) + if regressions: + print("SSO string-unboxing ratchet increased:", file=sys.stderr) + for rule, crate, allowed, found in regressions: + print(f" {rule} | {crate}: baseline {allowed}, found {found}", file=sys.stderr) + for finding in findings: + if finding.rule == rule and finding.crate == crate: + print(f" {finding.render()}", file=sys.stderr) + print( + "A short string (<= 5 bytes) is stored inline under SHORT_STRING_TAG and has " + "no StringHeader; see this script's docstring for the SSO-aware accessors.", + file=sys.stderr, + ) + if stale: + print("SSO string-unboxing baseline is stale; record the progress:", file=sys.stderr) + for rule, crate, allowed, found in stale: + print(f" {rule} | {crate}: baseline {allowed}, found {found}", file=sys.stderr) + if regressions or stale: + print("Run: python3 scripts/sso_unbox_inventory.py --write-baseline", file=sys.stderr) + return 1 + + totals = Counter() + for (rule, _crate), count in actual.items(): + totals[rule] += count + print( + f"sso-unbox inventory: {files_scanned} files; {totals['mask-cast']} mask casts, " + f"{totals['heap-tag-only']} heap-only string readers and " + f"{totals['codegen-str-arg']} codegen string args held by the ratchet" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index 4fe7ef880f..fa68c534bf 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -9,7 +9,7 @@ inline-offset | perry-ext-net | 1 inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 -inline-offset | perry-runtime | 348 +inline-offset | perry-runtime | 337 inline-offset | perry-stdlib | 26 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 diff --git a/test-files/test_gap_11519_buffer_options_short_strings.ts b/test-files/test_gap_11519_buffer_options_short_strings.ts new file mode 100644 index 0000000000..408a088573 --- /dev/null +++ b/test-files/test_gap_11519_buffer_options_short_strings.ts @@ -0,0 +1,16 @@ +// #11519: Buffer / KeyObject / File surfaces reading a string option or key +// that may be a SHORT (SSO, <= 5 bytes, built at runtime) string. They checked +// the heap string tag only, so the inline value read as "not a string". +import { createSecretKey } from "node:crypto"; +const S = (s: string): string => s.charAt(0) + s.slice(1); + +const b: any = Buffer.from("hey"); +const probe = (x: any) => x; +const dyn = probe(b); +console.log("hasOwnProperty:", dyn.hasOwnProperty(S("0")), dyn.hasOwnProperty(S("2")), dyn.hasOwnProperty(S("3")), dyn.hasOwnProperty(String(1))); +console.log("propertyIsEnumerable:", dyn.propertyIsEnumerable(S("1")), dyn.propertyIsEnumerable(S("9"))); +const key = createSecretKey(Buffer.from("secret")); +console.log("export jwk:", JSON.stringify(key.export({ format: S("jwk") as any }))); +console.log("export buf:", (key.export({ format: S("buffer") as any }) as Buffer).toString()); +const f = new File(["x"], "a.txt", { lastModified: S("12") as any }); +console.log("lastModified:", f.lastModified, new File([], "b", { lastModified: String(0) as any }).lastModified); diff --git a/test-files/test_gap_11519_builtin_short_strings.ts b/test-files/test_gap_11519_builtin_short_strings.ts new file mode 100644 index 0000000000..a02e0e12fa --- /dev/null +++ b/test-files/test_gap_11519_builtin_short_strings.ts @@ -0,0 +1,39 @@ +// #11519: builtins handed a SHORT (SSO, <= 5 bytes, built at runtime) string. +// Each of these read the argument through a heap-only accessor or a bare mask: +// `Array.from(s, fn)` came back empty, `Uint8Array.fromHex/fromBase64`, a +// `StringDecoder` encoding, an `EvalError` message and an async_hooks +// callback slot holding a string segfaulted, a `Temporal` time zone and an +// array-typed field holding a string at runtime misbehaved. +import { StringDecoder } from "node:string_decoder"; +import { createHook } from "node:async_hooks"; +const S = (s: string): string => s.charAt(0) + s.slice(1); + +console.log("Array.from map:", Array.from(S("abc"), (c) => c + "!").join(), Array.from(`${123}`, Number).join()); +const af = Array.from; +console.log("Array.from alias:", af(S("xyz")).join("|"), Array.from(S("ab")).length); +console.log("fromHex:", Array.from(Uint8Array.fromHex(S("4869"))).join(), Array.from(Uint8Array.fromHex(S(""))).length); +console.log("fromBase64:", Array.from(Uint8Array.fromBase64(S("SGk="))).join(), Array.from(Uint8Array.fromBase64(S("AQID"))).join()); +const u = new Uint8Array(2); +console.log("setFromHex:", JSON.stringify(u.setFromHex(S("0aff"))), Array.from(u).join()); + +const d = new StringDecoder(S("utf8")); +console.log("StringDecoder:", JSON.stringify(d.write(Buffer.from("hé"))), JSON.stringify(d.end()), d.encoding); +console.log("StringDecoder hex:", new StringDecoder(S("hex")).write(Buffer.from([1, 255]))); + +for (const m of ["", "e", "12345"]) { + const e = new EvalError(S(m)); + const u2 = new URIError(S(m)); + console.log(`Eval/URIError len ${m.length}:`, JSON.stringify(e.message), JSON.stringify(u2.message), String(e)); +} + +try { + createHook({ init: S("x") } as any); +} catch (err: any) { + console.log("createHook:", err.name, err.code); +} + +console.log("Temporal tz:", typeof Temporal.Now.plainDateISO(S("UTC")).day, Temporal.Now.zonedDateTimeISO(S("UTC")).timeZoneId); + +type Bag = { items: string[] }; +const neg = (b: Bag) => "" + b.items[-1] + "|" + b.items[0.5] + "|" + b.items[1]; +console.log("array-typed sso:", neg({ items: S("xy") as any }), neg({ items: ["p", "q"] })); diff --git a/test-files/test_gap_11519_child_process_short_strings.ts b/test-files/test_gap_11519_child_process_short_strings.ts new file mode 100644 index 0000000000..d0ff8b27d2 --- /dev/null +++ b/test-files/test_gap_11519_child_process_short_strings.ts @@ -0,0 +1,19 @@ +// #11519: child_process commands passed as a SHORT (SSO, <= 5 bytes, built at +// runtime) string. `execSync` / `spawnSync` / `exec` / `spawn` unboxed the +// command with a bare mask, so `execSync("ec" + "ho")` segfaulted. +import { execSync, spawnSync, exec, spawn } from "node:child_process"; +const S = (s: string): string => s.charAt(0) + s.slice(1); + +console.log("execSync:", JSON.stringify(execSync(S("echo")).toString()), JSON.stringify(execSync(S("true")).toString())); +console.log("execSync tpl:", JSON.stringify(execSync(`${"ec"}${"ho"}`, { encoding: "utf8" }))); +const r = spawnSync(S("echo"), [S("a"), String(12)], { encoding: "utf8" }); +console.log("spawnSync:", r.status, JSON.stringify(r.stdout)); +const t = spawnSync(S("true")); +console.log("spawnSync true:", t.status); +exec(S("echo"), (err, stdout) => { + console.log("exec:", err === null, JSON.stringify(stdout)); + const child = spawn(S("echo"), [S("hi")]); + let out = ""; + child.stdout.on("data", (d: any) => (out += d)); + child.on("close", (code: number) => console.log("spawn:", code, JSON.stringify(out))); +}); diff --git a/test-files/test_gap_11519_date_typedarray_short_strings.ts b/test-files/test_gap_11519_date_typedarray_short_strings.ts new file mode 100644 index 0000000000..a6557ce34e --- /dev/null +++ b/test-files/test_gap_11519_date_typedarray_short_strings.ts @@ -0,0 +1,46 @@ +// #11519: Date and typed-array element stores must accept a SHORT (SSO) string. +// A string of up to 5 bytes built at runtime -- `String(n)`, a template, a +// `+` concatenation, `JSON.parse` -- is stored inline in the NaN-box with no +// StringHeader behind it. `new Date(s)`, `new Date(y, s)` and the typed-array +// ToNumber checked for the heap tag only, so they read the inline string as +// NaN; `Date.parse(s)` masked it into an address and segfaulted. +const S = (s: string): string => (s.length ? s.charAt(0) + s.slice(1) : JSON.parse('""')); + +// Edge lengths: empty, 4 and 5 bytes (5 is the SSO maximum), and a 7-byte heap +// control. (Loose one-digit / five-digit year strings are left out: Perry's date +// parser disagrees with V8's legacy heuristics there for heap strings too.) +for (const v of ["", "1999", " 2020", "2020-01"]) { + const s = S(v); + console.log(`len ${v.length}:`, new Date(s).getTime(), Date.parse(s), `${new Date(0).setUTCFullYear(s as any)}`); +} +console.log("new Date(tpl):", new Date(`${2020}`).getUTCFullYear(), new Date(String(1999)).toISOString()); +console.log("new Date(json):", new Date(JSON.parse('"2001"')).getUTCFullYear()); +console.log("new Date(y, m, d):", new Date(2020, S("1") as any, S("15") as any).getDate(), new Date(2020, String(11) as any).getMonth()); +console.log("Date.UTC:", Date.UTC(S("2020") as any, S("1") as any, `${3}` as any)); +console.log("Date.parse(tpl):", Date.parse(`${1970}`), Date.parse(String(2000))); + +// Through an untyped setter: the generic element store. (A store the compiler +// can see is a `Uint8Array` writes 0 for ANY string, heap or inline -- a +// separate gap, not an SSO one.) +const setA = (a: any, i: number, v: any) => { + a[i] = v; +}; +const u8 = new Uint8Array(4); +setA(u8, 0, S("7")); +setA(u8, 1, S("255")); +setA(u8, 2, S("x")); +setA(u8, 3, S("")); +console.log("u8:", u8.join()); +const f64 = new Float64Array(3); +f64[0] = S("2.5") as any; +f64[1] = `${-4}` as any; +f64[2] = S("1e3") as any; +console.log("f64:", f64.join()); +const i32 = new Int32Array(2); +i32[0] = String(12345) as any; +i32[1] = S("-9") as any; +console.log("i32:", i32.join()); +const any: any = new Float32Array(2); +any[0] = S("0.5"); +any[1] = String(3); +console.log("f32 any:", any.join()); diff --git a/test-files/test_gap_11519_http_short_strings.ts b/test-files/test_gap_11519_http_short_strings.ts new file mode 100644 index 0000000000..2978bd123d --- /dev/null +++ b/test-files/test_gap_11519_http_short_strings.ts @@ -0,0 +1,35 @@ +// #11519: node:http surfaces given a SHORT (SSO, <= 5 bytes, built at runtime) +// string: event names (`req.on("da" + "ta")`), a response body written with +// `res.end(String(n))`, header names/values, a request method and path. The +// http natives unboxed these with a bare mask or accepted the heap tag only, +// so listeners never registered and the exchange hung or lost its body. +import * as http from "node:http"; +const S = (s: string): string => s.charAt(0) + s.slice(1); + +const srv = http.createServer((req, res) => { + let body = ""; + req.on(S("data"), (c: any) => { + body += c; + }); + req.on(S("end"), () => { + res.setHeader(S("X-A"), S("1")); + res.writeHead(200, { [S("X-C")]: String(3), "Content-Length": S("3") }); + res.write(S("w:")); + res.end(String(body.length)); + }); +}); +srv.listen(0, () => { + const port = (srv.address() as any).port; + const req = http.request({ port, method: S("POST"), path: S("/p") }, (res) => { + let d = ""; + res.setEncoding(S("utf8") as any); + res.on(S("data"), (c: any) => (d += c)); + res.on(S("end"), () => { + console.log(res.statusCode, res.headers["x-a"], res.headers["x-c"], res.headers["content-length"], JSON.stringify(d)); + srv.close(); + }); + }); + req.setHeader(S("X-B"), S("2")); + req.write(S("abc")); + req.end(S("de")); +}); diff --git a/test-files/test_gap_11519_misc_short_string_args.ts b/test-files/test_gap_11519_misc_short_string_args.ts new file mode 100644 index 0000000000..fb214b1e19 --- /dev/null +++ b/test-files/test_gap_11519_misc_short_string_args.ts @@ -0,0 +1,27 @@ +// #11519: more native entry points that took a string argument as a masked +// `*StringHeader` and so read a SHORT (SSO, <= 5 bytes, built at runtime) +// string's inline characters as an address: `JSON.parse(text, reviver)`, +// `new AggregateError(errors, message)`, `str.at(i)` / `str.codePointAt(i)` +// on a short receiver, and a well-known `Symbol[name]` lookup. +const S = (s: string): string => (s.length ? s.charAt(0) + s.slice(1) : JSON.parse('""')); + +console.log("reviver:", JSON.stringify(JSON.parse(S("12"), (_k, v) => v)), JSON.parse(String(42), (_k, v) => v * 2)); +console.log("reviver arr:", JSON.stringify(JSON.parse(S("[1,2]"), (_k, v) => (typeof v === "number" ? v + 1 : v)))); +console.log("reviver empty-ish:", JSON.parse(S("0"), (_k, v) => v), JSON.parse(S("null"), (_k, v) => v)); + +for (const m of ["", "m", "boom", "12345", "123456"]) { + const e = new AggregateError([1, 2], S(m)); + console.log(`aggregate len ${m.length}:`, JSON.stringify(e.message), e.errors.length, String(e)); +} +console.log("aggregate tpl:", new AggregateError([], `${7}`).message); + +for (const v of ["a", "ab", "abcde", "abcdef"]) { + const s = S(v); + console.log(`at/codePointAt len ${v.length}:`, s.at(0), s.at(-1), s.at(9), s.codePointAt(1), s.codePointAt(9)); +} +const n = String(98765); +let acc = 0; +for (let i = 0; i < n.length; i++) acc += n.codePointAt(i)! + (n.at(i) === "5" ? 100 : 0); +console.log("loop:", acc); + +console.log("Symbol[name]:", (Symbol as any)[S("match")] === Symbol.match, (Symbol as any)[S("split")] === Symbol.split); diff --git a/test-files/test_gap_11519_net_url_short_strings.ts b/test-files/test_gap_11519_net_url_short_strings.ts new file mode 100644 index 0000000000..de2e8bc464 --- /dev/null +++ b/test-files/test_gap_11519_net_url_short_strings.ts @@ -0,0 +1,40 @@ +// #11519: node:net event names and BlockList addresses, URL / URLSearchParams +// strings and AbortSignal event names given as SHORT (SSO, <= 5 bytes, built +// at runtime) strings. The net natives masked the event name into an address; +// the URL helpers read the heap string tag only, so the value read as "". +import * as net from "node:net"; +import * as url from "node:url"; +const S = (s: string): string => s.charAt(0) + s.slice(1); + +const p = new URLSearchParams(S("a=1")); +console.log("URLSearchParams:", p.get(S("a")), p.has(S("a")), p.toString()); +p.forEach((v, k) => console.log("forEach:", k, v)); +console.log("url.parse:", url.parse(S("a/b")).pathname, url.parse(S("?q=1")).query); +console.log("URL:", new URL(S("/x"), "http://h.test/").href); + +const ac = new AbortController(); +ac.signal.addEventListener(S("abort"), () => console.log("abort listener fired")); +ac.abort(); + +const bl = new net.BlockList(); +bl.addAddress(S("::1"), S("ipv6")); +console.log("BlockList:", bl.check("::1", "ipv6"), bl.check(S("::2"), S("ipv6"))); + +const server = net.createServer((sock) => { + let got = ""; + sock.on(S("data"), (d: any) => (got += d)); + sock.on(S("end"), () => { + sock.end(S("pong")); + console.log("server got:", got); + }); +}); +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as any).port; + const c = net.connect(port, "127.0.0.1", () => c.end(S("ping"))); + let reply = ""; + c.on(S("data"), (d: any) => (reply += d)); + c.on(S("close"), () => { + console.log("client got:", reply, "listeners:", c.listenerCount(S("data"))); + server.close(); + }); +});