diff --git a/Cargo.lock b/Cargo.lock index e756715b6e..4b73c47b1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6063,6 +6063,7 @@ dependencies = [ "brotli", "flate2", "perry-ffi", + "zstd", ] [[package]] diff --git a/changelog.d/7021-real-npm-cli-compile-gaps.md b/changelog.d/7021-real-npm-cli-compile-gaps.md new file mode 100644 index 0000000000..18dbfa3552 --- /dev/null +++ b/changelog.d/7021-real-npm-cli-compile-gaps.md @@ -0,0 +1,7 @@ +**Compile fixes from taking a real npm CLI (Socket Firewall) to a native binary** — five independent blockers, each fixed at its own layer: + +- **Auto-optimize feature skew**: cross-features the on-disk checkout's `perry-runtime`/`perry-stdlib` don't declare are now dropped (with a warning) instead of failing the whole cargo resolve and silently falling back to a link that's missing the routed ext entrypoints. The cargo-failure fallback message now explains the consequence and remedy. +- **`perry-ext-zlib` zstd surface**: `zlib.createZstdCompress`/`createZstdDecompress`, the zstd one-shots, and the streaming write-codec are now implemented in the ext wrapper, so routing `node:zlib` no longer strips the only zstd implementation out of the link (undici's web-fetch content decoding references it unconditionally). +- **`class X extends DOMException`**: new `js_dom_exception_subclass_init` wired through both the explicit `super()` lowering and the implicit-ctor chain walk — undici's module-init inheritability probe no longer aborts startup with `DOMException is not a function`. +- **panic-runtime dedup**: prebuilt (panic=unwind) wrapper staticlibs co-linked with a panic=abort auto-optimized stdlib no longer die on `__rust_drop_panic` — the `panic_unwind` member is nominated for the nosharedeps fixed-point (kept only when the stdlib can't cover it), and panic symbols referenced by a sibling member are no longer localized. Allocator shims remain always-localized (a global wrapper malloc shim would break runtime pointer classification). +- **`RegExp` call form via a rebound global**: `var R = globalThis.RegExp; R(src)` now constructs (with the spec's `RegExp(re)` identity shortcut) instead of returning `undefined` from the noop thunk — lodash's `runInContext` module init relied on exactly this. diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 1edbbbd7fa..e60e61bf43 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -214,6 +214,38 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } + // `class X extends DOMException` with a synthesized/pass-through + // constructor (`super(...args)`) must initialize the same surface + // as the fixed-arity super-call path above. Array reads past the + // spread argument count produce `undefined`, matching the optional + // message/name parameters. + let is_dom_exception = ctx + .classes + .get(¤t_class_name) + .and_then(|c| c.extends_name.as_deref()) + .map(|p| p == "DOMException") + .unwrap_or(false); + if is_dom_exception { + let zero_idx = "0".to_string(); + let one_idx = "1".to_string(); + let message = + ctx.block() + .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &zero_idx)]); + let name = + ctx.block() + .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &one_idx)]); + ctx.block().call( + DOUBLE, + "js_dom_exception_subclass_init", + &[(DOUBLE, &this_box), (DOUBLE, &message), (DOUBLE, &name)], + ); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } if let Some(&child_cid) = ctx.class_ids.get(¤t_class_name) { let cid_str = child_cid.to_string(); let blk = ctx.block(); @@ -384,6 +416,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | "Response" | "Event" | "CustomEvent" + | "DOMException" ) || (is_stream_family_name && !has_extends_expr) || is_other_builtin_constructor_name(parent_name.as_str())) @@ -709,6 +742,37 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } + // `class X extends DOMException` (undici's WebSocketError + // and its module-init inheritability probe): `super(message, + // name)` stamps the DOMException surface (`message`/`name`/ + // `code`) onto `this`. The X → DOMException registry edge + // (registered at class-definition time) keeps `instanceof`. + if parent_name.as_str() == "DOMException" { + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let mut lowered: Vec = Vec::with_capacity(super_args.len()); + for a in super_args { + lowered.push(lower_expr(ctx, a)?); + } + let arg0 = lowered.first().cloned().unwrap_or_else(|| undef.clone()); + let arg1 = lowered.get(1).cloned().unwrap_or_else(|| undef.clone()); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => undef.clone(), + }; + ctx.block().call( + DOUBLE, + "js_dom_exception_subclass_init", + &[(DOUBLE, &this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], + ); + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } // `class X extends Promise` — `super(executor)` runs the // ECMA-262 27.2.3.1 Promise constructor against a hidden // backing `Promise` cell stashed on `this`. Inherited diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 9ba7831af2..be98b4d0c2 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -38,6 +38,7 @@ pub(crate) enum NativeInstanceBase { Set, Event, CustomEvent, + DomException, } /// The native base a parent NAME denotes, if any. @@ -56,6 +57,7 @@ pub(crate) fn native_instance_base(name: &str) -> Option { "Set" => Some(NativeInstanceBase::Set), "Event" => Some(NativeInstanceBase::Event), "CustomEvent" => Some(NativeInstanceBase::CustomEvent), + "DOMException" => Some(NativeInstanceBase::DomException), _ => None, } } @@ -170,6 +172,23 @@ pub(crate) fn emit_native_instance_base_init( ], ); } + NativeInstanceBase::DomException => { + // `super(message, name)` — both optional (`new DOMException()` is + // legal; the runtime defaults name to "Error"). + let arg0 = lowered_args + .first() + .cloned() + .unwrap_or_else(|| undef.clone()); + let arg1 = lowered_args + .get(1) + .cloned() + .unwrap_or_else(|| undef.clone()); + ctx.block().call( + DOUBLE, + "js_dom_exception_subclass_init", + &[(DOUBLE, this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], + ); + } } } diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 02e97422de..6edb3addde 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -1264,6 +1264,13 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { ); module.declare_function("js_custom_event_new", I64, &[DOUBLE, DOUBLE, I32]); module.declare_function("js_dom_exception_new", I64, &[DOUBLE, DOUBLE]); + // `super(message, name)` from `class X extends DOMException` — stamps the + // DOMException surface (`message`/`name`/`code`) onto the subclass `this`. + module.declare_function( + "js_dom_exception_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); module.declare_function("js_event_target_add_event_listener", VOID, &[I64, I64, I64]); module.declare_function( "js_event_target_add_event_listener_with_options", diff --git a/crates/perry-ext-zlib/Cargo.toml b/crates/perry-ext-zlib/Cargo.toml index ba58b7e549..cb3838d191 100644 --- a/crates/perry-ext-zlib/Cargo.toml +++ b/crates/perry-ext-zlib/Cargo.toml @@ -14,6 +14,12 @@ crate-type = ["staticlib", "rlib"] [dependencies] perry-ffi.workspace = true flate2 = "1" +# zstd codecs: `zlib.zstdCompressSync` / `createZstdDecompress`. When `zlib` +# routes here, perry-stdlib's `compression` module is compiled out — this +# archive must carry the full zstd surface too, or programs whose compiled JS +# references it (undici's web-fetch content decoding does, unconditionally) +# die at link with undefined `js_zlib_*zstd*` symbols. +zstd.workspace = true # Brotli stream + one-shot support (#1843). Matches the version the # `compression` feature pulls into perry-stdlib. brotli = "8.0.2" diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs index 7a1b144767..f4eda4fd8e 100644 --- a/crates/perry-ext-zlib/src/stream.rs +++ b/crates/perry-ext-zlib/src/stream.rs @@ -207,6 +207,72 @@ pub unsafe extern "C" fn js_zlib_brotli_decompress(data_value: f64, callback_val }); } +fn throw_zstd_error(err: &std::io::Error) -> ! { + perry_ffi::throw_with_code(&format!("zstd: {}", err), "Z_DATA_ERROR", ErrorKind::Error) +} + +/// `zlib.zstdCompressSync(data)` -> Buffer. `_opts` is accepted (codegen +/// passes the options slot through) but zstd params are not wired up — +/// matches perry-stdlib's copy. +/// +/// # Safety +/// `data_value` is the raw NaN-boxed data argument (string or Buffer). +#[no_mangle] +pub unsafe extern "C" fn js_zlib_zstd_compress_sync( + data_value: f64, + _opts: f64, +) -> *mut BufferHeader { + let data_bits = data_value.to_bits() as i64; + js_zlib_validate_buffer_arg(data_bits); + match read_input_from_bits(data_bits) + .map(|d| zstd::stream::encode_all(d.as_slice(), ZSTD_DEFAULT_LEVEL)) + { + Some(Ok(out)) => alloc_buffer(&out), + Some(Err(e)) => throw_zstd_error(&e), + None => std::ptr::null_mut(), + } +} + +/// `zlib.zstdDecompressSync(data)` -> Buffer. +/// +/// # Safety +/// `data_value` is the raw NaN-boxed data argument (string or Buffer). +#[no_mangle] +pub unsafe extern "C" fn js_zlib_zstd_decompress_sync( + data_value: f64, + _opts: f64, +) -> *mut BufferHeader { + let data_bits = data_value.to_bits() as i64; + js_zlib_validate_buffer_arg(data_bits); + match read_input_from_bits(data_bits).map(|d| zstd::stream::decode_all(d.as_slice())) { + Some(Ok(out)) => alloc_buffer(&out), + Some(Err(e)) => throw_zstd_error(&e), + None => std::ptr::null_mut(), + } +} + +/// `zlib.zstdCompress(data, callback)` -> undefined. +/// +/// # Safety +/// `data_value` and `callback_value` are raw NaN-boxed JS values. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_zstd_compress(data_value: f64, callback_value: f64) { + queue_one_shot_callback(data_value, callback_value, "ZstdCompress", |b| { + zstd::stream::encode_all(b, ZSTD_DEFAULT_LEVEL) + }); +} + +/// `zlib.zstdDecompress(data, callback)` -> undefined. +/// +/// # Safety +/// `data_value` and `callback_value` are raw NaN-boxed JS values. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_zstd_decompress(data_value: f64, callback_value: f64) { + queue_one_shot_callback(data_value, callback_value, "ZstdDecompress", |b| { + zstd::stream::decode_all(b) + }); +} + // ── stream codec ───────────────────────────────────────────────────────────── #[derive(Clone, Copy)] @@ -220,8 +286,15 @@ enum Codec { Unzip, BrotliCompress, BrotliDecompress, + ZstdCompress, + ZstdDecompress, } +/// Node's `zlib` zstd default (matches perry-stdlib's copy). zstd levels run +/// 1..=22 and don't share the deflate 0..=9 scale, so the `{ level }` option +/// resolved by `js_zlib_resolve_level` is not applied to zstd codecs. +const ZSTD_DEFAULT_LEVEL: i32 = 3; + fn run_codec(codec: Codec, input: &[u8]) -> std::io::Result> { let mut out = Vec::new(); match codec { @@ -253,6 +326,8 @@ fn run_codec(codec: Codec, input: &[u8]) -> std::io::Result> { } Codec::BrotliCompress => out = brotli_compress_bytes(input), Codec::BrotliDecompress => out = brotli_decompress_bytes(input)?, + Codec::ZstdCompress => out = zstd::stream::encode_all(input, ZSTD_DEFAULT_LEVEL)?, + Codec::ZstdDecompress => out = zstd::stream::decode_all(input)?, } Ok(out) } @@ -275,6 +350,8 @@ enum CodecState { DeflateDec(flate2::write::DeflateDecoder>), BrotliEnc(brotli::CompressorWriter>), BrotliDec(brotli::DecompressorWriter>), + ZstdEnc(zstd::stream::write::Encoder<'static, Vec>), + ZstdDec(zstd::stream::write::Decoder<'static, Vec>), } impl CodecState { @@ -288,6 +365,8 @@ impl CodecState { CodecState::DeflateDec(w) => w.write_all(data), CodecState::BrotliEnc(w) => w.write_all(data), CodecState::BrotliDec(w) => w.write_all(data), + CodecState::ZstdEnc(w) => w.write_all(data), + CodecState::ZstdDec(w) => w.write_all(data), } } @@ -301,6 +380,8 @@ impl CodecState { CodecState::DeflateDec(w) => w.flush(), CodecState::BrotliEnc(w) => w.flush(), CodecState::BrotliDec(w) => w.flush(), + CodecState::ZstdEnc(w) => w.flush(), + CodecState::ZstdDec(w) => w.flush(), } } @@ -315,6 +396,8 @@ impl CodecState { CodecState::DeflateDec(w) => std::mem::take(w.get_mut()), CodecState::BrotliEnc(w) => std::mem::take(w.get_mut()), CodecState::BrotliDec(w) => std::mem::take(w.get_mut()), + CodecState::ZstdEnc(w) => std::mem::take(w.get_mut()), + CodecState::ZstdDec(w) => std::mem::take(w.get_mut()), } } @@ -331,6 +414,14 @@ impl CodecState { // DecompressorWriter::into_inner returns Result (Err on an // unterminated stream); take the decoded bytes either way. CodecState::BrotliDec(w) => Ok(w.into_inner().unwrap_or_else(|v| v)), + // Encoder::finish writes the zstd frame epilogue then hands back + // the inner Vec; Decoder::into_inner is tolerant of an + // unterminated frame (same stance as BrotliDec above). + CodecState::ZstdEnc(w) => w.finish(), + CodecState::ZstdDec(mut w) => { + w.flush()?; + Ok(w.into_inner()) + } } } } @@ -358,6 +449,15 @@ fn make_codec_state_with_level(codec: Codec, level: Compression) -> Option { CodecState::BrotliDec(brotli::DecompressorWriter::new(Vec::new(), 4096)) } + // zstd context allocation is fallible; `None` falls back to the same + // buffer-until-end `run_codec` path `createUnzip` uses, so a failed + // allocation degrades to one-shot semantics instead of erroring. + Codec::ZstdCompress => CodecState::ZstdEnc( + zstd::stream::write::Encoder::new(Vec::new(), ZSTD_DEFAULT_LEVEL).ok()?, + ), + Codec::ZstdDecompress => { + CodecState::ZstdDec(zstd::stream::write::Decoder::new(Vec::new()).ok()?) + } // Unzip auto-detects the header — kept buffer-until-end (run_codec). Codec::Unzip => return None, }) @@ -523,6 +623,8 @@ factory!(js_zlib_create_inflate_raw, Codec::InflateRaw, 8); factory!(js_zlib_create_unzip, Codec::Unzip, 8); factory!(js_zlib_create_brotli_compress, Codec::BrotliCompress, 0); factory!(js_zlib_create_brotli_decompress, Codec::BrotliDecompress, 0); +factory!(js_zlib_create_zstd_compress, Codec::ZstdCompress, 0); +factory!(js_zlib_create_zstd_decompress, Codec::ZstdDecompress, 0); // ── chunk / buffer helpers ───────────────────────────────────────────────────── @@ -1346,6 +1448,15 @@ mod stream_tests { ); } + #[test] + fn zstd_decoder_finish_flushes_pending_output() { + let expected = b"zstd decoder output buffered until the stream finishes"; + let compressed = zstd::stream::encode_all(expected.as_slice(), ZSTD_DEFAULT_LEVEL).unwrap(); + let mut decoder = make_codec_state(Codec::ZstdDecompress).expect("zstd decoder"); + decoder.write_chunk(&compressed).unwrap(); + assert_eq!(decoder.finish().unwrap(), expected); + } + #[test] fn gunzip_run_codec_reads_all_members() { let a = stream_compress(Codec::Gzip, &[b"first "]); diff --git a/crates/perry-runtime/src/event_target.rs b/crates/perry-runtime/src/event_target.rs index edec9ad215..947a1d5a18 100644 --- a/crates/perry-runtime/src/event_target.rs +++ b/crates/perry-runtime/src/event_target.rs @@ -147,9 +147,13 @@ unsafe fn listener_signal(options: f64) -> Option<*mut ObjectHeader> { } fn set_event_field(event: *mut ObjectHeader, name: &[u8], value: f64) { - js_object_set_field_by_name(event, key(name), value); + let scope = crate::gc::RuntimeHandleScope::new(); + let event = scope.root_raw_mut_ptr(event); + let value = scope.root_nanbox_f64(value); + let field_key = key(name); + js_object_set_field_by_name(event.get_raw_mut_ptr(), field_key, value.get_nanbox_f64()); crate::object::set_builtin_property_attrs( - event as usize, + event.get_raw_mut_ptr::() as usize, String::from_utf8_lossy(name).into_owned(), crate::object::PropertyAttrs::new(true, false, true), ); @@ -326,6 +330,56 @@ pub extern "C" fn js_event_subclass_init( static KEEP_JS_EVENT_SUBCLASS_INIT: extern "C" fn(f64, f64, f64, u32, u32) -> f64 = js_event_subclass_init; +/// `class X extends DOMException` — `super(message, name)` initializer +/// (undici's `WebSocketError`, and its module-init `class Test extends +/// DOMException` capability probe). The subclass instance is a registry-class +/// object, not the ErrorHeader `new DOMException(...)` allocates, so stamp the +/// DOMException surface onto `this`: `message`, `name` (default `"Error"`, +/// matching `js_dom_exception_new`), and the legacy numeric `code` for that +/// name. +#[no_mangle] +pub extern "C" fn js_dom_exception_subclass_init(this_value: f64, message: f64, name: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let exception = scope.root_nanbox_f64(this_value); + let message = scope.root_nanbox_f64(message); + let name = scope.root_nanbox_f64(name); + if value_as_ptr::(exception.get_nanbox_f64()).is_none() { + return undefined_value(); + } + let message_ptr = optional_string_from_value(message.get_nanbox_f64(), b""); + let message_ptr = scope.root_string_ptr(message_ptr); + let name_ptr = optional_string_from_value(name.get_nanbox_f64(), b"Error"); + let name_ptr = scope.root_string_ptr(name_ptr); + let name_string = unsafe { + let name_ptr = name_ptr.get_raw_const_ptr::(); + let len = (*name_ptr).byte_len as usize; + let data = (name_ptr as *const u8).add(std::mem::size_of::()); + String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned() + }; + set_event_field( + value_as_ptr::(exception.get_nanbox_f64()).unwrap(), + b"message", + crate::value::js_nanbox_string(message_ptr.get_raw_const_ptr::() as i64), + ); + set_event_field( + value_as_ptr::(exception.get_nanbox_f64()).unwrap(), + b"name", + crate::value::js_nanbox_string(name_ptr.get_raw_const_ptr::() as i64), + ); + set_event_field( + value_as_ptr::(exception.get_nanbox_f64()).unwrap(), + b"code", + dom_exception_code(&name_string), + ); + undefined_value() +} + +/// Keepalive anchor for the auto-optimize whole-program build — +/// `js_dom_exception_subclass_init` is a generated-code-only callee. +#[used] +static KEEP_JS_DOM_EXCEPTION_SUBCLASS_INIT: extern "C" fn(f64, f64, f64) -> f64 = + js_dom_exception_subclass_init; + fn is_event_instance(event: *const ObjectHeader) -> bool { if event.is_null() { return false; diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 2a63bf7a75..2d4512caa4 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -74,12 +74,13 @@ pub(crate) use ctor_thunks::{ global_this_url_pattern_call_thunk, is_function_prototype_object_value, map_constructor_call_thunk, normalize_eval_this_body, promise_constructor_call_thunk, range_error_constructor_call_thunk, reference_error_constructor_call_thunk, - set_constructor_call_thunk, subtle_crypto_method_value, syntax_error_constructor_call_thunk, - type_error_constructor_call_thunk, typed_array_constructor_call_thunk, - uri_error_constructor_call_thunk, weak_map_constructor_call_thunk, - weak_ref_constructor_call_thunk, weak_set_constructor_call_thunk, - webcrypto_get_random_values_thunk, webcrypto_illegal_constructor_thunk, webcrypto_method_value, - webcrypto_random_uuid_thunk, webcrypto_subtle_getter_thunk, + regexp_constructor_call_thunk, set_constructor_call_thunk, subtle_crypto_method_value, + syntax_error_constructor_call_thunk, type_error_constructor_call_thunk, + typed_array_constructor_call_thunk, uri_error_constructor_call_thunk, + weak_map_constructor_call_thunk, weak_ref_constructor_call_thunk, + weak_set_constructor_call_thunk, webcrypto_get_random_values_thunk, + webcrypto_illegal_constructor_thunk, webcrypto_method_value, webcrypto_random_uuid_thunk, + webcrypto_subtle_getter_thunk, }; #[cfg(feature = "temporal")] pub(crate) use fetch_globals::temporal_subclass_super; diff --git a/crates/perry-runtime/src/object/global_this/ctor_thunks.rs b/crates/perry-runtime/src/object/global_this/ctor_thunks.rs index 92347c1713..dda8d7d90a 100644 --- a/crates/perry-runtime/src/object/global_this/ctor_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/ctor_thunks.rs @@ -25,6 +25,65 @@ pub(crate) extern "C" fn typed_array_constructor_call_thunk( super::super::object_ops::throw_object_type_error(b"Constructor %TypedArray% requires 'new'") } +/// `RegExp(pattern, flags)` called WITHOUT `new` — unlike Map/Set below, +/// RegExp IS callable: ECMA-262 22.2.4 makes the call form construct exactly +/// like `new RegExp(pattern, flags)`, with one identity shortcut — `RegExp(re)` +/// with an existing RegExp and undefined flags returns `re` unchanged. +/// +/// The noop-thunk fallback returned `undefined` here, which is how lodash's +/// module init died: `runInContext` rebinds the global (`var RegExp = +/// context.RegExp`) and builds its native-function probe through the call form +/// (`var reIsNative = RegExp('^' + …)`) — the very next `reIsNative.test(...)` +/// threw "Cannot read properties of undefined". Construction mirrors the +/// dynamic-`new` RegExp arm in class_registry/construct.rs. +#[cfg(feature = "regex-engine")] +pub(crate) extern "C" fn regexp_constructor_call_thunk( + _closure: *const crate::closure::ClosureHeader, + pattern: f64, + flags: f64, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let pattern = scope.root_nanbox_f64(pattern); + let flags = scope.root_nanbox_f64(flags); + let flags_undefined = flags.get_nanbox_f64().to_bits() == crate::value::TAG_UNDEFINED; + let pattern_value = crate::value::JSValue::from_bits(pattern.get_nanbox_f64().to_bits()); + if flags_undefined && pattern_value.is_pointer() { + let addr = (pattern.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::regex::is_regex_pointer(addr as *const u8) { + return pattern.get_nanbox_f64(); + } + } + let pattern_string = if pattern_value.is_undefined() { + None + } else { + Some(scope.root_string_ptr(crate::builtins::js_string_coerce(pattern.get_nanbox_f64()))) + }; + let flags_string = if flags_undefined { + None + } else { + Some(scope.root_string_ptr(crate::builtins::js_string_coerce(flags.get_nanbox_f64()))) + }; + let pattern_ptr = pattern_string + .as_ref() + .map_or(std::ptr::null(), |value| value.get_raw_const_ptr()); + let flags_ptr = flags_string + .as_ref() + .map_or(std::ptr::null(), |value| value.get_raw_const_ptr()); + let re = crate::regex::js_regexp_new(pattern_ptr, flags_ptr); + crate::value::js_nanbox_pointer(re as i64) +} + +/// Without the regex engine there is no RegExp to construct — keep the +/// pre-existing noop behavior rather than referencing a compiled-out ctor. +#[cfg(not(feature = "regex-engine"))] +pub(crate) extern "C" fn regexp_constructor_call_thunk( + _closure: *const crate::closure::ClosureHeader, + _pattern: f64, + _flags: f64, +) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + // #4569: Map/Set/WeakMap/WeakSet/WeakRef are constructors — calling them // without `new` is a TypeError (ECMA-262: an undefined newTarget throws). The // bare-call form previously fell through to `global_this_builtin_noop_thunk` diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index cf7f942505..b2a2e97621 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -133,6 +133,7 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade | "Int32Array" | "Uint32Array" | "Float16Array" | "Float32Array" | "Float64Array" | "BigInt64Array" | "BigUint64Array" => typed_array_constructor_call_thunk as *const u8, // #4569: collection constructors throw when called without `new`. + "RegExp" => regexp_constructor_call_thunk as *const u8, "Map" => map_constructor_call_thunk as *const u8, "Set" => set_constructor_call_thunk as *const u8, "WeakMap" => weak_map_constructor_call_thunk as *const u8, @@ -174,6 +175,10 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade "URLPattern" => { crate::closure::js_register_closure_arity(func_ptr, 2); } + // RegExp(pattern, flags) — the call form constructs (22.2.4). + "RegExp" => { + crate::closure::js_register_closure_arity(func_ptr, 2); + } "Int8Array" | "Uint8Array" | "Uint8ClampedArray" | "Int16Array" | "Uint16Array" | "Int32Array" | "Uint32Array" | "Float16Array" | "Float32Array" | "Float64Array" | "BigInt64Array" | "BigUint64Array" => { diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index e4385e25b6..ea197d7556 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -32,8 +32,8 @@ pub(crate) use driver::build_optimized_libs; pub(crate) use freshness::{ auto_optimized_archives_are_fresh, auto_optimized_build_stamp, auto_optimized_cache_key, auto_optimized_cross_features, auto_optimized_source_fingerprint, binding_needs_shared_tokio, - effective_size_panic_immediate_abort, resolve_auto_well_known_libs, size_lto_fat, - size_opt_level, + effective_size_panic_immediate_abort, resolve_auto_well_known_libs, + retain_workspace_declared_features, size_lto_fat, size_opt_level, }; pub(crate) use no_auto::{ build_missing_prebuilt_ext_lib, resolve_no_auto_optimized_libs, resolve_prebuilt_ext_libs, diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 213a02259b..ac686314a9 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -528,7 +528,24 @@ pub(crate) fn build_optimized_libs( hash = hash.wrapping_mul(33).wrapping_add(*b as u64); } let (target_dir, cargo_env_dir) = auto_target_dir_paths(&workspace_root, hash); - let cross_features = auto_optimized_cross_features(ctx, &features, cli_features); + let mut cross_features = auto_optimized_cross_features(ctx, &features, cli_features); + // Binary/workspace skew guard: the baked-in list above tracks the branch + // this `perry` was built from, but cargo resolves it against the checkout + // on disk. One unknown `perry-runtime/` fails the whole resolve, and + // the prebuilt fallback below then links without the ext-pump entrypoints + // — undefined-`js_*` errors two stages away from the cause. Filter before + // the build stamp so the stamp keys on what actually gets built. + let dropped_features = retain_workspace_declared_features(&workspace_root, &mut cross_features); + if !dropped_features.is_empty() && matches!(format, OutputFormat::Text) { + eprintln!( + " auto-optimize: dropping feature(s) this workspace does not declare: {} \ + (this perry binary was likely built from a different branch than the \ + checkout at {})", + dropped_features.join(", "), + workspace_root.display() + ); + } + let cross_features = cross_features; // `-Zbuild-std` (the `PERRY_SIZE_PANIC=abort-immediate` path) requires an // explicit `--target`, and passing one relocates cargo's output into // `target//release`. Resolve the effective triple ONCE so the @@ -858,8 +875,12 @@ pub(crate) fn build_optimized_libs( if !status.success() { if matches!(format, OutputFormat::Text) { eprintln!( - " auto-optimize: cargo build failed (exit {}), \ - using prebuilt libraries", + " auto-optimize: cargo build failed ({}), \ + using prebuilt libraries. The prebuilt archives may lack the \ + feature-gated `js_*` entrypoints this compile routed to ext \ + crates; if the link fails with undefined symbols, fix the \ + cargo error above (or rebuild the workspace so it matches \ + this perry binary) and re-run.", status ); } diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index fea059bab4..95a1d4ffdc 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -300,6 +300,72 @@ pub(crate) fn auto_optimized_cross_features( cross_features } +/// Feature names a workspace crate's `Cargo.toml` can satisfy in a +/// `--features /` request: the `[features]` table keys plus every +/// optional dependency (an optional dep implicitly defines a same-named +/// feature unless all its `dep:` references say otherwise — over-including +/// those keeps this fail-open). `None` when the manifest is missing or +/// unparseable, so callers skip filtering rather than dropping features a +/// manifest they couldn't read might well declare. +fn declared_feature_names(workspace_root: &Path, krate: &str) -> Option> { + let manifest_path = workspace_root.join("crates").join(krate).join("Cargo.toml"); + let manifest: toml::Value = toml::from_str(&fs::read_to_string(manifest_path).ok()?).ok()?; + let mut names: BTreeSet = manifest + .get("features")? + .as_table()? + .keys() + .cloned() + .collect(); + let mut collect_optional = |deps: Option<&toml::Value>| { + let Some(table) = deps.and_then(|d| d.as_table()) else { + return; + }; + for (name, spec) in table { + if spec.get("optional").and_then(|o| o.as_bool()) == Some(true) { + names.insert(name.clone()); + } + } + }; + collect_optional(manifest.get("dependencies")); + if let Some(targets) = manifest.get("target").and_then(|t| t.as_table()) { + for target_spec in targets.values() { + collect_optional(target_spec.get("dependencies")); + } + } + Some(names) +} + +/// The `perry` binary's baked-in cross-feature list tracks the branch the +/// binary was BUILT from, while the auto-optimize cargo build resolves against +/// the workspace found on disk — and the two can skew (binary from branch A, +/// checkout on branch B). One `perry-runtime/` the checkout doesn't +/// declare fails the entire cargo resolve, and the silent prebuilt fallback +/// then links without the ext-pump entrypoints the well-known routing loop +/// already stripped stdlib features for — surfacing as undefined-`js_*` link +/// errors far from the cause. Drop the unknown names instead (a feature the +/// checkout never heard of gates nothing in its sources) and return them so +/// the caller can say what was dropped. +pub(crate) fn retain_workspace_declared_features( + workspace_root: &Path, + cross_features: &mut Vec, +) -> Vec { + let mut dropped = Vec::new(); + for krate in ["perry-runtime", "perry-stdlib"] { + let Some(declared) = declared_feature_names(workspace_root, krate) else { + continue; + }; + let prefix = format!("{krate}/"); + cross_features.retain(|entry| match entry.strip_prefix(&prefix) { + Some(feat) if !declared.contains(feat) => { + dropped.push(entry.clone()); + false + } + _ => true, + }); + } + dropped +} + /// Content fingerprint of every workspace source tree that lands in the /// auto-optimized archives: the crates this build compiles (the runtime/stdlib /// static wrappers and the tokio-using ext crates) plus their transitive diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 003528c0ce..fb743e431b 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -662,3 +662,64 @@ printf '!\n' > "$CARGO_TARGET_DIR/release/libperry_ext_http.a" target_dir.join("release/libperry_ext_http.a") ); } + +/// Binary/workspace skew: a cross-feature the on-disk checkout's +/// perry-runtime doesn't declare must be dropped (and reported), not passed +/// through to fail the entire cargo resolve — that failure's prebuilt +/// fallback links without the routed ext entrypoints and dies with +/// undefined `js_*` symbols far from the cause. +#[test] +fn retain_workspace_declared_features_drops_unknown_names() { + let dir = tempfile::tempdir().expect("tempdir"); + write_file( + &dir.path().join("crates/perry-runtime/Cargo.toml"), + b"[package]\nname = \"perry-runtime\"\n\n[features]\nfull = []\nregex-engine = []\n\n[dependencies]\nmimalloc = { version = \"0.1\", optional = true }\n", + ); + write_file( + &dir.path().join("crates/perry-stdlib/Cargo.toml"), + b"[package]\nname = \"perry-stdlib\"\n\n[features]\ncrypto = []\n", + ); + + let mut cross_features = vec![ + "perry-runtime/full".to_string(), + "perry-runtime/alloc-mimalloc".to_string(), + "perry-runtime/mimalloc".to_string(), + "perry-stdlib/crypto".to_string(), + "perry-stdlib/web-fetch".to_string(), + ]; + let dropped = retain_workspace_declared_features(dir.path(), &mut cross_features); + + // `full` and `crypto` are declared features; `mimalloc` is an optional + // dep (implicit feature). Only the names the checkout has never heard of + // go. + assert_eq!( + cross_features, + vec![ + "perry-runtime/full".to_string(), + "perry-runtime/mimalloc".to_string(), + "perry-stdlib/crypto".to_string(), + ] + ); + assert_eq!( + dropped, + vec![ + "perry-runtime/alloc-mimalloc".to_string(), + "perry-stdlib/web-fetch".to_string(), + ] + ); +} + +/// Fail-open: with no readable manifest (release tarball, partial checkout) +/// there is nothing trustworthy to filter against — every requested feature +/// must survive. +#[test] +fn retain_workspace_declared_features_keeps_all_without_manifests() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut cross_features = vec![ + "perry-runtime/full".to_string(), + "perry-runtime/alloc-mimalloc".to_string(), + ]; + let dropped = retain_workspace_declared_features(dir.path(), &mut cross_features); + assert!(dropped.is_empty()); + assert_eq!(cross_features.len(), 2); +} diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index b1da605608..f49a07ce7e 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -747,13 +747,34 @@ pub(super) fn strip_duplicate_objects_from_well_known_lib(lib_path: &PathBuf) -> let abs_staticlib = std::fs::canonicalize(lib_path)?; let symbols_by_member = collect_archive_symbols_by_member(&nm, &abs_staticlib) .ok_or_else(|| anyhow::anyhow!("failed to inspect archive symbols"))?; + // Undefined (U) symbols per member. Localizing a PANIC-runtime definition + // that a SIBLING member of the same archive still references severs an + // intra-archive edge: the wrapper's kept `std` cgu defines + // `__rust_drop_panic`, its kept `panic_unwind` cgu references it, and a + // panic=abort stdlib provides no replacement — the final link dies on + // exactly that symbol. Skip localizing those. ALLOCATOR shims are + // deliberately NOT guarded this way: every member references + // `__rust_alloc`, so the guard would always skip them — and leaving the + // wrapper's system-malloc shim global lets it beat the runtime's mimalloc + // shim at link, which breaks the runtime's pointer classification + // (console output silently vanishes). Allocator references always have + // the runtime's global copy to bind to; unwind-flavor panic internals may + // not. + let undefined_by_member = collect_archive_undefined_by_member(&nm, &abs_staticlib) + .ok_or_else(|| anyhow::anyhow!("failed to inspect archive undefined symbols"))?; let forced_symbols_by_member: std::collections::BTreeMap> = symbols_by_member .iter() .filter_map(|(member, symbols)| { let mut forced_symbols: Vec = symbols .iter() - .filter(|symbol| force_localize_symbol(symbol)) + .filter(|symbol| { + force_localize_symbol(symbol) + && !(is_panic_unwind_symbol(symbol) + && undefined_by_member + .iter() + .any(|(m, undef)| m != member && undef.contains(*symbol))) + }) .cloned() .collect(); if forced_symbols.is_empty() { @@ -1251,7 +1272,21 @@ pub(super) fn strip_bundled_shared_deps_from_well_known_lib( let stdlib_members = list_members(&abs_stdlib)?; let candidates: std::collections::BTreeSet = members .iter() - .filter(|m| stdlib_members.iter().any(|s| s.contains(m.as_str()))) + .filter(|m| { + stdlib_members.iter().any(|s| s.contains(m.as_str())) + // std's bundled panic runtime. The wrapper (built + // panic=unwind) bundles `panic_unwind-*`; a panic=abort + // stdlib bundles `panic_abort-*` under a DIFFERENT member + // name, so the name-containment rule above never nominates + // it — the stale unwind copy survives, and its reference to + // std's `__rustc` shim (`__rust_drop_panic`), whose object + // WAS dropped as stdlib-provided, fails the link. Nominate + // it here; the fixed-point loop below protects it (keeps it) + // whenever a kept sibling needs a symbol only it defines and + // the stdlib doesn't provide — i.e. removal happens exactly + // when the stdlib's own panic runtime covers the link. + || m.contains("panic_unwind") + }) .cloned() .collect(); if candidates.is_empty() {