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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,23 @@ pub(crate) fn is_global_this_builtin_name(name: &str) -> bool {
| "TextDecoderStream"
| "CompressionStream"
| "DecompressionStream"
// The three core Web Streams constructors. Perry already implements
// them (`new ReadableStream(…)` and `x instanceof ReadableStream` are
// both lowered, and `ReadableStream` has a class id), but the NAMES
// were never registered as globalThis builtins — so a bare
// `ReadableStream` identifier did not resolve: `typeof
// ReadableStream === "undefined"` and `"ReadableStream" in globalThis`
// was false, while Node exposes all three as functions.
//
// Libraries feature-detect exactly this (`typeof ReadableStream !==
// "undefined" ? … : …`) to decide how to consume a `fetch()` body.
// Getting "undefined" sent a large esbuild-bundled CLI app down the
// wrong branch, which then threw "The first argument must be a
// Readable, a ReadableStream, or an async iterable" and aborted its
// background tar-stream downloads entirely.
| "ReadableStream"
| "WritableStream"
| "TransformStream"
| "Navigator"
| "URL"
| "URLSearchParams"
Expand Down
11 changes: 11 additions & 0 deletions crates/perry-runtime/src/object/global_this_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ pub(crate) const GLOBAL_THIS_BUILTIN_CONSTRUCTORS: &[&str] = &[
"TextDecoderStream",
"CompressionStream",
"DecompressionStream",
// The three core Web Streams constructors. Perry implements them (codegen
// lowers `new ReadableStream(…)` and `x instanceof ReadableStream`, and the
// class has an id), but the NAMES were never registered here or in codegen's
// globalThis-builtin list — so a bare `ReadableStream` identifier resolved to
// nothing: `typeof ReadableStream === "undefined"` and `"ReadableStream" in
// globalThis` was false, whereas Node exposes all three as functions.
// Libraries feature-detect precisely that (`typeof ReadableStream !==
// "undefined" ? … : …`) when deciding how to consume a `fetch()` body.
"ReadableStream",
"WritableStream",
"TransformStream",
"Navigator",
"URL",
"URLSearchParams",
Expand Down
69 changes: 69 additions & 0 deletions crates/perry-runtime/src/object/instanceof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,52 @@ fn is_native_module_namespace_value(value: f64, expected: &str) -> bool {
/// `js_instanceof`. Returns FALSE for non-class-ref type values (matches
/// JS spec: `1 instanceof 2` throws, but Perry returns false defensively).
/// Refs #420 / #618 followup.
#[no_mangle]
/// Map a builtin constructor VALUE (the `ClosureHeader`-backed function installed
/// on `globalThis`) back to the class id that codegen passes to `js_instanceof`
/// for the static `x instanceof <Identifier>` form.
///
/// Only the natively-backed builtins need this: their instances are handles
/// (stream / fetch registries), not heap objects with a real prototype chain, so
/// `js_instanceof` brand-checks them via the kind probes rather than a chain walk.
/// Heap-backed builtins already resolve through the class-id / prototype paths.
fn builtin_ctor_class_id_from_value(type_ref: f64) -> Option<u32> {
let closure =
crate::value::js_nanbox_get_pointer(type_ref) as *const crate::closure::ClosureHeader;
if closure.is_null() {
return None;
}
if unsafe { (*closure).type_tag } != crate::closure::CLOSURE_MAGIC {
return None;
}
let name_value = crate::closure::closure_get_dynamic_prop(closure as usize, "name");
let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN];
let (ptr, len) = crate::string::str_bytes_from_jsvalue(name_value, &mut scratch)?;
if ptr.is_null() {
return None;
}
let name =
std::str::from_utf8(unsafe { std::slice::from_raw_parts(ptr, len as usize) }).ok()?;
let class_id = match name {
"ReadableStream" => 0xFFFF_0060,
"WritableStream" => 0xFFFF_0061,
"TransformStream" => 0xFFFF_0062,
"Response" => 0xFFFF_0028,
"Request" => 0xFFFF_0029,
"Headers" => 0xFFFF_002A,
"Blob" => 0xFFFF_0026,
_ => return None,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
// The name alone is forgeable (`function Response() {}` in user code).
// Require IDENTITY with the builtin constructor installed on
// `globalThis`: only the genuine builtin value brand-checks instances.
let global_ctor = crate::object::js_get_global_this_builtin_value(name.as_ptr(), name.len());
if crate::value::js_nanbox_get_pointer(global_ctor) as usize != closure as usize {
return None;
}
Some(class_id)
}

#[no_mangle]
pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 {
const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003;
Expand Down Expand Up @@ -206,6 +252,29 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 {
return js_instanceof(value, class_id);
}
}
// A builtin constructor held in a VARIABLE — `const RS = ReadableStream; body
// instanceof RS` — arrives here as the ClosureHeader-backed function installed
// on `globalThis`, so none of the class-id paths above match and the prototype
// walk below returns false. Codegen only special-cases the *static identifier*
// form (`body instanceof ReadableStream`), where it hands the builtin class id
// straight to `js_instanceof`, which brand-checks these natively-backed values
// via the stream / fetch kind probes (their instances are handles, not heap
// objects with a real prototype chain).
//
// Minified bundles almost always alias constructors into locals, so the
// variable form is the common one in the wild: `x instanceof <alias>` for
// ReadableStream / Response / Headers silently returned `false` while Node
// returns `true`. That made a large esbuild-bundled CLI app mis-detect its
// `fetch()` body, throw "The first argument must be a Readable, a
// ReadableStream, or an async iterable", and abort its background
// tar-stream downloads entirely.
//
// Recover the builtin's name from the constructor closure (recorded by
// `set_bound_native_closure_name` when globalThis is populated) and reuse the
// static path's class id, so both spellings agree.
if let Some(class_id) = builtin_ctor_class_id_from_value(type_ref) {
return js_instanceof(value, class_id);
}
if let Some((module, method)) = unsafe { bound_native_callable_module_and_method(type_ref) } {
if module == "stream"
&& matches!(
Expand Down
Loading