diff --git a/changelog.d/6834-http-ffi-boundary.md b/changelog.d/6834-http-ffi-boundary.md new file mode 100644 index 0000000000..e199d43940 --- /dev/null +++ b/changelog.d/6834-http-ffi-boundary.md @@ -0,0 +1 @@ +refactor(http): route production HTTP bindings through `perry-ffi` and remove the normal `perry-ext-http` dependency on `perry-runtime` while preserving the selected HTTP archive ABI. diff --git a/crates/perry-ext-http/Cargo.toml b/crates/perry-ext-http/Cargo.toml index 8d8442ec68..288dbecf44 100644 --- a/crates/perry-ext-http/Cargo.toml +++ b/crates/perry-ext-http/Cargo.toml @@ -24,33 +24,6 @@ tokio-rustls.workspace = true rustls = { workspace = true, features = ["std", "ring", "tls12"] } rustls-pemfile.workspace = true tokio-tungstenite = { workspace = true } -# #2154: Agent argument validation throws `RangeError [ERR_OUT_OF_RANGE]` -# via `js_throw` + `register_error_code_pub`, which are perry-runtime's -# Rust-ABI helpers (not perry-ffi's). Stays consistent with perry-stdlib's -# parallel `http.rs` AgentHandle, which already calls into perry-runtime -# directly. Cargo feature unification keeps the stdlib feature on when -# both stdlib and this crate link the same perry-runtime — no duplicate -# symbols, no behaviour change for the default `full` build. -# #6303: perry-runtime MUST be built here with the same feature set the shipped -# `libperry_runtime.a` / `libperry_stdlib.a` carry (i.e. its `default`). This crate -# is a `staticlib`, so it BUNDLES the perry-runtime rlib objects into -# `libperry_ext_*.a` — and perry links the ext archives BEFORE stdlib/runtime -# (`prefer_well_known_before_stdlib`), so those bundled objects WIN the link for -# every symbol they define. The workspace dep is `default-features = false`, so -# without `"default"` here a per-crate `cargo build -p perry-ext-` (exactly what -# release-packages.yml does in its per-crate loop) bundles a runtime with -# `regex-engine`/`temporal`/... compiled OUT. The dispatchers those features gate -# are exported UNCONDITIONALLY (`js_string_replace_search_dyn`, -# `js_native_call_method`, ...) with the feature-gated logic `#[cfg]`-ed out of the -# BODY — so the degraded copy silently ToString-coerces a RegExp argument and -# searches for it literally instead of matching it (str.replace(re, fn) never fires -# its callback). Keep `"default"` in lock-step with perry-runtime's default feature -# list; the `ext_crates_bundle_a_full_featured_perry_runtime` test (well_known.rs) guards it. -# #6314: `stdlib` drops the bundled no-op `stdlib_stubs` (js_stdlib_init_dispatch, -# ...) from this staticlib's perry-runtime copy. Linked before stdlib, the no-op -# `js_stdlib_init_dispatch` otherwise wins first-definition and never registers -# the tokio reactor — every node:http server dies on its first accept. -perry-runtime = { workspace = true, features = ["default", "external-ws-symbols", "stdlib"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "http2"], default-features = false } tokio = { workspace = true } # Zero-copy body chunks: reqwest::Response::chunk() yields a refcounted @@ -66,4 +39,6 @@ lazy_static.workspace = true socket2.workspace = true [dev-dependencies] +# GC and async test shims call runtime internals; production code uses only perry-ffi. +perry-runtime.workspace = true perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index 7dedb717e7..78d1ba4f40 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -43,8 +43,8 @@ use crate::ensure_gc_scanner_registered; use lazy_static::lazy_static; use perry_ffi::{ - alloc_string, get_handle, get_handle_mut, iter_handles_of_mut, register_handle, GcRootVisitor, - Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, + alloc_string, get_handle, get_handle_mut, iter_handles_of_mut, register_handle, ErrorKind, + GcRootVisitor, Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::Mutex; @@ -253,10 +253,7 @@ fn throw_out_of_range(name: &str, bound: &str, received: f64) -> ! { "The value of \"{}\" is out of range. It must be {}. Received {}", name, bound, received_str ); - let msg_ptr = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - perry_runtime::node_submodules::register_error_code_pub(msg_ptr, "ERR_OUT_OF_RANGE"); - let err = perry_runtime::error::js_rangeerror_new(msg_ptr); - perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer(err as i64)) + perry_ffi::throw_with_code(&message, "ERR_OUT_OF_RANGE", ErrorKind::RangeError) } fn format_received_number(n: f64) -> String { @@ -301,44 +298,25 @@ fn validate_positive(name: &str, value: f64) { /// universe so we can't mix them on the `js_object_get_field_by_name` /// boundary. unsafe fn read_field_bits(obj_f64: f64, field: &str) -> Option { - let bits = obj_f64.to_bits(); - let upper = bits >> 48; - let obj_ptr: *const perry_runtime::ObjectHeader = if upper >= 0x7FF8 { - (bits & PTR_MASK) as *const perry_runtime::ObjectHeader - } else if upper == 0 && bits >= 0x10000 { - bits as *const perry_runtime::ObjectHeader - } else { - return None; - }; - if obj_ptr.is_null() { - return None; - } - let key = perry_runtime::js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { + let value = perry_ffi::object_field_by_name(JsValue::from_bits(obj_f64.to_bits()), field); + if value.is_undefined() || value.is_null() { None } else { - Some(val.bits()) + Some(value.bits()) } } -unsafe fn raw_object_ptr_is_null(val_f64: f64) -> bool { - let bits = val_f64.to_bits(); - let upper = bits >> 48; - if upper >= 0x7FF8 { - (bits & PTR_MASK) == 0 - } else { - !(upper == 0 && bits >= 0x10000) - } +unsafe fn raw_object_ptr_is_null(value: f64) -> bool { + !JsValue::from_bits(value.to_bits()).is_pointer_or_raw() } unsafe fn read_number_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if val.is_number() { Some(val.to_number()) } else if val.is_int32() { - Some(val.as_int32() as f64) + Some(val.to_int32() as f64) } else { None } @@ -346,9 +324,9 @@ unsafe fn read_number_field(obj_f64: f64, field: &str) -> Option { unsafe fn read_bool_field(obj_f64: f64, field: &str) -> Option { let bits = read_field_bits(obj_f64, field)?; - let val = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if val.is_bool() { - Some(val.as_bool()) + Some(val.to_bool()) } else { None } @@ -356,11 +334,11 @@ 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 = perry_runtime::JSValue::from_bits(bits); + let val = JsValue::from_bits(bits); if !val.is_string() { return None; } - let ptr = val.as_string_ptr() as *mut perry_ffi::StringHeader; + let ptr = val.as_string_ptr(); if ptr.is_null() { return None; } @@ -617,16 +595,7 @@ unsafe fn agent_new_with_protocol(options_f64: f64, default_protocol: &str) -> H "The argument 'scheduling' must be one of: 'fifo', 'lifo'. Received {:?}", s ); - let msg_ptr = - perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - perry_runtime::node_submodules::register_error_code_pub( - msg_ptr, - "ERR_INVALID_ARG_VALUE", - ); - let err = perry_runtime::error::js_typeerror_new(msg_ptr); - perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer( - err as i64, - )) + perry_ffi::throw_with_code(&message, "ERR_INVALID_ARG_VALUE", ErrorKind::TypeError) } agent.scheduling = s; } @@ -857,11 +826,13 @@ fn json_value_to_string(v: &serde_json::Value) -> String { #[no_mangle] pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { - perry_runtime::stub_diag::perry_stub_warn( - "http.Agent keepSocketAlive/reuseSocket", - "reqwest owns the keep-alive pool; per-socket hooks are no-ops", - Some("#4917"), - ); + unsafe { + perry_ffi::warn_stub( + c"http.Agent keepSocketAlive/reuseSocket", + c"reqwest owns the keep-alive pool; per-socket hooks are no-ops", + Some(c"#4917"), + ) + }; handle } diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index 2ce0e6bb64..9c548ae138 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -29,12 +29,11 @@ fn null_value() -> f64 { } fn bool_value(value: bool) -> f64 { - f64::from_bits(perry_runtime::JSValue::bool(value).bits()) + f64::from_bits(JsValue::from_bool(value).bits()) } fn string_value(value: &str) -> f64 { - let ptr = perry_runtime::js_string_from_bytes(value.as_ptr(), value.len() as u32); - f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) + f64::from_bits(JsValue::from_string_ptr(alloc_string(value).as_raw()).bits()) } fn handle_value(handle: Handle) -> f64 { @@ -117,12 +116,16 @@ fn remove_header_by_name(handle: Handle, name: &str) { fn headers_array(handle: Handle, raw: bool) -> f64 { let names = header_names(handle, raw); - let mut arr = perry_runtime::js_array_alloc(names.len() as u32); + let mut array = unsafe { perry_ffi::js_array_alloc(names.len() as u32) }; for name in names { - let ptr = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); - arr = perry_runtime::js_array_push(arr, perry_runtime::JSValue::string_ptr(ptr)); + array = unsafe { + perry_ffi::js_array_push( + array, + JsValue::from_string_ptr(alloc_string(&name).as_raw()), + ) + }; } - f64::from_bits(perry_runtime::JSValue::array_ptr(arr).bits()) + f64::from_bits(JsValue::from_object_ptr(array).bits()) } fn headers_object(handle: Handle) -> f64 { @@ -136,35 +139,29 @@ fn headers_object(handle: Handle) -> f64 { .unwrap_or_default(); entries.sort_by(|a, b| a.0.cmp(&b.0)); entries.dedup_by(|a, b| a.0 == b.0); - - let obj = perry_runtime::js_object_alloc_null_proto(0, entries.len() as u32); - let mut keys = perry_runtime::js_array_alloc(entries.len() as u32); - for (index, (key, value)) in entries.iter().enumerate() { - let key_ptr = perry_runtime::js_string_from_bytes(key.as_ptr(), key.len() as u32); - let value_ptr = perry_runtime::js_string_from_bytes(value.as_ptr(), value.len() as u32); - perry_runtime::js_object_set_field( - obj, - index as u32, - perry_runtime::JSValue::string_ptr(value_ptr), - ); - keys = perry_runtime::js_array_push(keys, perry_runtime::JSValue::string_ptr(key_ptr)); - } - perry_runtime::js_object_set_keys(obj, keys); - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()) + let fields: Vec<(&str, JsValue)> = entries + .iter() + .map(|(key, value)| { + ( + key.as_str(), + JsValue::from_string_ptr(alloc_string(value).as_raw()), + ) + }) + .collect(); + f64::from_bits(perry_ffi::alloc_null_proto_object(&fields).bits()) } /// `{ name: }` — stands in for `.constructor` so /// `out.constructor.name` discriminates ClientRequest/ServerResponse the /// way the corpus outgoing-message tests expect (#4909). pub(crate) fn constructor_object(name: &str) -> f64 { - let obj = perry_runtime::js_object_alloc_null_proto(0, 1); - let key_ptr = perry_runtime::js_string_from_bytes("name".as_ptr(), 4); - let value_ptr = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); - perry_runtime::js_object_set_field(obj, 0, perry_runtime::JSValue::string_ptr(value_ptr)); - let mut keys = perry_runtime::js_array_alloc(1); - keys = perry_runtime::js_array_push(keys, perry_runtime::JSValue::string_ptr(key_ptr)); - perry_runtime::js_object_set_keys(obj, keys); - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()) + f64::from_bits( + perry_ffi::alloc_null_proto_object(&[( + "name", + JsValue::from_string_ptr(alloc_string(name).as_raw()), + )]) + .bits(), + ) } fn socket_value(handle: Handle) -> f64 { @@ -173,9 +170,7 @@ fn socket_value(handle: Handle) -> f64 { } with_state_mut(handle, |state| { if state.socket == 0.0 { - let obj = perry_runtime::js_object_alloc(0, 0); - state.socket = - f64::from_bits(perry_runtime::JSValue::object_ptr(obj as *mut u8).bits()); + state.socket = f64::from_bits(perry_ffi::alloc_object().bits()); } state.socket }) diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index 62c535924d..56de802405 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -851,10 +851,10 @@ unsafe fn invoke_create_socket( // of reading an uninitialized register for the second parameter. static REGISTER_ARITY: Once = Once::new(); REGISTER_ARITY.call_once(|| { - perry_runtime::closure::js_register_closure_arity(http_create_socket_cb as *const u8, 2); + perry_ffi::register_closure_arity(http_create_socket_cb as *const u8, 2); }); - let cb = perry_runtime::closure::js_closure_alloc(http_create_socket_cb as *const u8, 1); + let cb = perry_ffi::alloc_closure(http_create_socket_cb as *const u8, 1); if cb.is_null() { return; } @@ -862,7 +862,7 @@ unsafe fn invoke_create_socket( // (still-stored) method/url/headers/body and resume dispatch. Stored as an // f64 (a small registry id, not a heap pointer) — pointer-free, so it // needs no GC layout fixup, matching `sqlite_tx_wrapper`'s db-handle slot. - perry_runtime::closure::js_closure_set_capture_f64(cb, 0, request_handle as f64); + perry_ffi::set_closure_capture_f64(cb, 0, request_handle as f64); let cb_val = f64::from_bits(POINTER_TAG | (cb as usize as u64 & PTR_MASK)); let req_val = f64::from_bits(POINTER_TAG | (request_handle as u64 & PTR_MASK)); @@ -879,12 +879,11 @@ unsafe fn invoke_create_socket( /// the override hands back a `net.Socket` (POINTER_TAG-boxed handle, or a bare /// small handle on some codegen paths). unsafe extern "C" fn http_create_socket_cb( - closure: *const perry_runtime::ClosureHeader, + closure: *const RawClosureHeader, err: f64, socket: f64, ) -> f64 { - let request_handle = - perry_runtime::closure::js_closure_get_capture_f64(closure, 0) as i64 as Handle; + let request_handle = perry_ffi::closure_capture_f64(closure, 0) as i64 as Handle; // Node calls `cb(err)` on failure, `cb(null, socket)` on success. let err_bits = err.to_bits(); @@ -1468,16 +1467,7 @@ unsafe fn emit_socket_timeout_overflow_warning(ms: f64) { "{value_text} does not fit into a 32-bit signed integer.\n\ Timer duration was truncated to 2147483647." ); - let msg_ptr = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let label = "TimeoutOverflowWarning"; - let label_ptr = perry_runtime::js_string_from_bytes(label.as_ptr(), label.len() as u32); - let msg_value = f64::from_bits(perry_runtime::JSValue::string_ptr(msg_ptr).bits()); - let label_value = f64::from_bits(perry_runtime::JSValue::string_ptr(label_ptr).bits()); - perry_runtime::process::js_process_emit_warning( - msg_value, - label_value, - f64::from_bits(TAG_UNDEFINED), - ); + perry_ffi::emit_warning(&message, "TimeoutOverflowWarning"); } /// `IncomingMessage.setEncoding(encoding)` for client responses. The same diff --git a/crates/perry-ext-http/src/response_headers.rs b/crates/perry-ext-http/src/response_headers.rs index 0468936456..f6ca382f34 100644 --- a/crates/perry-ext-http/src/response_headers.rs +++ b/crates/perry-ext-http/src/response_headers.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; -use perry_ffi::{alloc_string, JsValue, ObjectHeader}; +use perry_ffi::{alloc_string, js_array_alloc, js_array_push, JsValue, ObjectHeader}; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; @@ -95,14 +95,13 @@ pub(crate) fn build_response_headers_object(raw: &[(String, String)]) -> f64 { if !obj.is_null() { for (i, key) in order.iter().enumerate() { let v = if key == "set-cookie" { - let mut arr = perry_runtime::js_array_alloc(set_cookie.len() as u32); + let mut arr = unsafe { js_array_alloc(set_cookie.len() as u32) }; for cookie in &set_cookie { - let ptr = - perry_runtime::js_string_from_bytes(cookie.as_ptr(), cookie.len() as u32); - arr = - perry_runtime::js_array_push(arr, perry_runtime::JSValue::string_ptr(ptr)); + arr = unsafe { + js_array_push(arr, JsValue::from_string_ptr(alloc_string(cookie).as_raw())) + }; } - JsValue::from_bits(perry_runtime::JSValue::array_ptr(arr).bits()) + JsValue::from_object_ptr(arr) } else if let Some(val) = combined.get(key) { let s = alloc_string(val); JsValue::from_string_ptr(s.as_raw()) diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index 402616dba6..4e193713e0 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -93,21 +93,12 @@ static GC_REGISTERED: Once = Once::new(); pub(crate) fn ensure_gc_scanner_registered() { GC_REGISTERED.call_once(|| { gc_register_mutable_root_scanner_named("perry-ext-http", scan_http_server_roots); - // #2532 — register the server pump + has-active with perry-runtime - // directly. In a workspace build perry-stdlib drains these via its - // `external-http-server-pump` arm, but an out-of-tree install links - // the prebuilt full stdlib with that arm compiled OUT — so without - // this the accepted requests would never be dispatched and the - // program would hang. Registration is idempotent on the runtime - // side, so the in-tree double-drain is a harmless no-op. - extern "C" { - fn js_register_aux_pump(f: extern "C" fn() -> i32); - fn js_register_aux_has_active(f: extern "C" fn() -> i32); - } - unsafe { - js_register_aux_pump(crate::server::server::js_node_http_server_process_pending); - js_register_aux_has_active(crate::server::server::js_node_http_server_has_active); - } + // Register the extension pump for out-of-tree links where stdlib does + // not compile its HTTP pump arm. Runtime registration is idempotent. + perry_ffi::register_aux_event_pump( + crate::server::server::js_node_http_server_process_pending, + crate::server::server::js_node_http_server_has_active, + ); // Wall 10 — register the handle property/method/property-set dispatch // extensions so erased-receiver `req.url` / `res.end(...)` etc. route to // our handles even when the linked perry-stdlib was built WITHOUT diff --git a/crates/perry-ext-http/src/tls_client.rs b/crates/perry-ext-http/src/tls_client.rs index f857d9294c..7d687d836b 100644 --- a/crates/perry-ext-http/src/tls_client.rs +++ b/crates/perry-ext-http/src/tls_client.rs @@ -36,8 +36,6 @@ //! tests need `rejectUnauthorized:false`. The `ca` trust anchors are //! still wired up so properly-SAN'd certs verify. -use super::PTR_MASK; - /// Parsed client-side TLS options. `Default` is "no TLS customization", /// in which case the caller keeps using the pooled default client. #[derive(Clone, Default, Debug)] @@ -285,26 +283,6 @@ mod tests { /// detect `checkServerIdentity` without a JSON round-trip (which drops /// functions). Mirrors the raw NaN-boxed field read in `agent.rs`. unsafe fn has_function_field(obj_f64: f64, field: &str) -> bool { - let bits = obj_f64.to_bits(); - let upper = bits >> 48; - let obj_ptr: *const perry_runtime::ObjectHeader = if upper >= 0x7FF8 { - (bits & PTR_MASK) as *const perry_runtime::ObjectHeader - } else if upper == 0 && bits >= 0x10000 { - bits as *const perry_runtime::ObjectHeader - } else { - return false; - }; - if obj_ptr.is_null() { - return false; - } - let key = perry_runtime::js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return false; - } - // Closures are NaN-boxed with POINTER_TAG (0x7FFD); a bare raw pointer - // (codegen sometimes hands these back) is also accepted. - let vbits = val.bits(); - let vupper = vbits >> 48; - vupper == 0x7FFD || (vupper == 0 && vbits >= 0x10000) + perry_ffi::object_field_by_name(perry_ffi::JsValue::from_bits(obj_f64.to_bits()), field) + .is_pointer_or_raw() } diff --git a/crates/perry-ext-http/src/validation.rs b/crates/perry-ext-http/src/validation.rs index 8f7af103c3..84dfa2e2bd 100644 --- a/crates/perry-ext-http/src/validation.rs +++ b/crates/perry-ext-http/src/validation.rs @@ -10,7 +10,7 @@ //! `ERR_OUT_OF_RANGE`). Throwing unwinds through the codegen call site back //! to the JS `try` / `assert.throws` frame. -use perry_runtime::fs::validate::throw_type_error_with_code; +use perry_ffi::{throw_with_code, ErrorKind}; /// Node HTTP token bytes (RFC 7230 `tchar`, mirrored from /// `lib/_http_common.js` `tokenRegExp`). Used for both method names and @@ -38,7 +38,7 @@ pub(crate) fn validate_client_url_string(raw: &str) { Err(_) => true, }; if invalid { - throw_type_error_with_code("Invalid URL", "ERR_INVALID_URL"); + throw_with_code("Invalid URL", "ERR_INVALID_URL", ErrorKind::TypeError); } } @@ -55,9 +55,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // `validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser')`). if let Some(v) = obj.get("insecureHTTPParser") { if !v.is_boolean() && !v.is_null() { - throw_type_error_with_code( + throw_with_code( "The \"options.insecureHTTPParser\" property must be of type boolean.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } } @@ -66,9 +67,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // `validateNumber(timeout, 'timeout')`). `timeout: null` throws. if let Some(v) = obj.get("timeout") { if !v.is_number() { - throw_type_error_with_code( + throw_with_code( "The \"timeout\" argument must be of type number.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } } @@ -81,9 +83,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol let normalized = format!("{}:", proto.trim_end_matches(':')); let expected = format!("{default_protocol}:"); if normalized != expected { - throw_type_error_with_code( + throw_with_code( &format!("Protocol \"{normalized}\" not supported. Expected \"{expected}\""), "ERR_INVALID_PROTOCOL", + ErrorKind::TypeError, ); } } @@ -96,9 +99,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol // default instead of throwing (#4970). if let Some(method) = obj.get("method").and_then(|v| v.as_str()) { if !method.is_empty() && !is_valid_token(method) { - throw_type_error_with_code( + throw_with_code( &format!("Method must be a valid HTTP token [\"{method}\"]"), "ERR_INVALID_HTTP_TOKEN", + ErrorKind::TypeError, ); } } @@ -110,9 +114,10 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol let cp = c as u32; !(0x21..=0xff).contains(&cp) }) { - throw_type_error_with_code( + throw_with_code( "Request path contains unescaped characters", "ERR_UNESCAPED_CHARACTERS", + ErrorKind::TypeError, ); } } @@ -122,15 +127,17 @@ pub(crate) fn validate_client_options(opts: &serde_json::Value, default_protocol if let Some(headers) = obj.get("headers").and_then(|v| v.as_object()) { for (name, value) in headers { if name.eq_ignore_ascii_case("host") && value.is_array() { - throw_type_error_with_code( + throw_with_code( "The \"options.headers.host\" property must be of type string.", "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, ); } if !is_valid_token(name) { - throw_type_error_with_code( + throw_with_code( &format!("Header name must be a valid HTTP token [\"{name}\"]"), "ERR_INVALID_HTTP_TOKEN", + ErrorKind::TypeError, ); } } diff --git a/crates/perry-ffi/src/closure.rs b/crates/perry-ffi/src/closure.rs index 6fd0127e0d..06e4b450f8 100644 --- a/crates/perry-ffi/src/closure.rs +++ b/crates/perry-ffi/src/closure.rs @@ -53,6 +53,36 @@ extern "C" { arg2: f64, arg3: f64, ) -> f64; + fn js_closure_alloc(func_ptr: *const u8, capture_count: u32) -> *mut ClosureHeader; + fn js_register_closure_arity(func_ptr: *const u8, arity: u32); + fn js_closure_get_capture_f64(closure: *const ClosureHeader, index: u32) -> f64; + fn js_closure_set_capture_f64(closure: *mut ClosureHeader, index: u32, value: f64); +} + +/// Register the arity the runtime uses when dispatching a native closure. +pub fn register_closure_arity(func: *const u8, arity: u32) { + unsafe { js_register_closure_arity(func, arity) } +} + +/// Allocate a native closure with `capture_count` f64 capture slots. +pub fn alloc_closure(func: *const u8, capture_count: u32) -> *mut ClosureHeader { + unsafe { js_closure_alloc(func, capture_count) } +} + +/// Read an f64 capture slot from a native closure. +/// +/// # Safety +/// `closure` must point to a live closure with an allocated `index` slot. +pub unsafe fn closure_capture_f64(closure: *const ClosureHeader, index: u32) -> f64 { + js_closure_get_capture_f64(closure, index) +} + +/// Write an f64 capture slot in a native closure. +/// +/// # Safety +/// `closure` must point to a live closure with an allocated `index` slot. +pub unsafe fn set_closure_capture_f64(closure: *mut ClosureHeader, index: u32, value: f64) { + js_closure_set_capture_f64(closure, index, value) } /// Opaque handle to a JS closure (a `*const ClosureHeader`). @@ -142,4 +172,16 @@ mod tests { assert!(null.is_null()); assert!(null.as_raw().is_null()); } + + #[cfg(feature = "runtime-link")] + #[test] + fn native_closure_retains_capture() { + unsafe extern "C" fn callback(_: *const ClosureHeader) -> f64 { + 0.0 + } + register_closure_arity(callback as *const u8, 0); + let closure = alloc_closure(callback as *const u8, 1); + unsafe { set_closure_capture_f64(closure, 0, 42.0) }; + assert_eq!(unsafe { closure_capture_f64(closure, 0) }, 42.0); + } } diff --git a/crates/perry-ffi/src/error.rs b/crates/perry-ffi/src/error.rs index 3e0222a0bb..910fa5de2e 100644 --- a/crates/perry-ffi/src/error.rs +++ b/crates/perry-ffi/src/error.rs @@ -13,7 +13,8 @@ //! through these single extern symbols keeps the registry/throw logic in //! the one runtime copy the dispatch path resolves to. -use crate::JsValue; +use crate::{alloc_string, JsValue}; +use std::ffi::{c_char, CStr}; extern "C" { /// Runtime entry: build an Error subclass with a `.code`. @@ -51,6 +52,8 @@ extern "C" { syscall_len: usize, errno: f64, ) -> f64; + fn js_process_emit_warning(warning: f64, type_name: f64, code: f64); + fn perry_stub_warn_ffi(name: *const c_char, reason: *const c_char, issue: *const c_char); } /// Which JS Error subclass [`throw_with_code`] raises. @@ -125,6 +128,31 @@ pub fn system_error_value(msg: &str, code: &str, syscall: &str, errno: i64) -> J JsValue::from_bits(value.to_bits()) } +/// Queue a Node process warning with the given message and type name. +pub fn emit_warning(message: &str, type_name: &str) { + let message = JsValue::from_string_ptr(alloc_string(message).as_raw()); + let type_name = JsValue::from_string_ptr(alloc_string(type_name).as_raw()); + unsafe { + js_process_emit_warning( + f64::from_bits(message.bits()), + f64::from_bits(type_name.bits()), + f64::from_bits(JsValue::UNDEFINED.bits()), + ) + } +} + +/// Emit the runtime's once-per-symbol no-op warning. +/// +/// # Safety +/// All strings must live for the process lifetime. Use C string literals. +pub unsafe fn warn_stub(name: &'static CStr, reason: &'static CStr, issue: Option<&'static CStr>) { + perry_stub_warn_ffi( + name.as_ptr(), + reason.as_ptr(), + issue.map_or(std::ptr::null(), CStr::as_ptr), + ) +} + /// Borrow the raw bytes of a `Buffer` or `TypedArray` value. Returns /// `None` for any value that is neither (the caller should raise a /// `TypeError` in that case). The borrow is valid for the duration of the diff --git a/crates/perry-ffi/src/event_pump.rs b/crates/perry-ffi/src/event_pump.rs index 6ac75953d2..25bbf246cb 100644 --- a/crates/perry-ffi/src/event_pump.rs +++ b/crates/perry-ffi/src/event_pump.rs @@ -45,6 +45,17 @@ extern "C" { /// one wake — the main-loop tick drains every queue each pass /// regardless. fn js_notify_main_thread(); + fn js_register_aux_pump(f: extern "C" fn() -> i32); + fn js_register_aux_has_active(f: extern "C" fn() -> i32); +} + +/// Register an extension event pump and activity probe with the runtime. +/// Registration is idempotent for each function pointer. +pub fn register_aux_event_pump(pump: extern "C" fn() -> i32, has_active: extern "C" fn() -> i32) { + unsafe { + js_register_aux_pump(pump); + js_register_aux_has_active(has_active); + } } /// Wake the main thread so it picks up a pending event the calling diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index 50668fe0b3..b36e2cce20 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -32,7 +32,7 @@ //! These tag values are part of perry-ffi's stable API — a //! perry-runtime renumbering bumps perry-ffi major. -use crate::{ArrayHeader, ObjectHeader, StringHeader}; +use crate::{alloc_string, ArrayHeader, ObjectHeader, StringHeader}; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; @@ -259,6 +259,12 @@ impl JsValue { std::ptr::null_mut() } } + + /// True when this is a tagged heap pointer or a legacy bare pointer. + #[inline] + pub const fn is_pointer_or_raw(self) -> bool { + self.is_pointer() || (self.0 >> 48 == 0 && self.0 >= 0x10000) + } } impl std::fmt::Debug for JsValue { @@ -322,6 +328,11 @@ extern "C" { /// Write the field at `field_index`. pub fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, value: JsValue); + + fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader; + fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader; + fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader); + fn js_object_get_field_by_name(obj: *const ObjectHeader, key: *const StringHeader) -> JsValue; } /// Compute `(packed_keys_bytes, shape_id)` for use with @@ -336,6 +347,56 @@ extern "C" { /// the same key list, which improves shape sharing across /// crates. `0x4646_0000` ("FF" prefix) namespaces perry-ffi-built /// shapes from perry-stdlib's hand-rolled ones. +/// Allocate an empty ordinary object. +pub fn alloc_object() -> JsValue { + let object = unsafe { js_object_alloc(0, 0) }; + if object.is_null() { + JsValue::UNDEFINED + } else { + JsValue::from_object_ptr(object) + } +} + +/// Allocate a null-prototype object with the given fields. +/// +/// Use this for Node objects such as request headers where inherited keys must +/// not be visible. Field order is preserved in the runtime keys array. +pub fn alloc_null_proto_object(fields: &[(&str, JsValue)]) -> JsValue { + let obj = unsafe { js_object_alloc_null_proto(0, fields.len() as u32) }; + if obj.is_null() { + return JsValue::UNDEFINED; + } + let mut keys = unsafe { js_array_alloc(fields.len() as u32) }; + for (index, (key, value)) in fields.iter().enumerate() { + unsafe { js_object_set_field(obj, index as u32, *value) }; + keys = unsafe { js_array_push(keys, JsValue::from_string_ptr(alloc_string(key).as_raw())) }; + } + unsafe { js_object_set_keys(obj, keys) }; + JsValue::from_object_ptr(obj) +} + +/// Read an own or inherited named field from an object value. +/// +/// Untagged legacy pointers are accepted because older generated call paths +/// can still pass them. Non-object values return `undefined`. +pub fn object_field_by_name(value: JsValue, key: &str) -> JsValue { + let bits = value.bits(); + let obj = if value.is_pointer() { + value.as_pointer::() + } else if bits >> 48 == 0 && bits >= 0x10000 { + bits as *mut ObjectHeader + } else { + std::ptr::null_mut() + }; + if obj.is_null() { + return JsValue::UNDEFINED; + } + let key = alloc_string(key); + unsafe { js_object_get_field_by_name(obj, key.as_raw()) } +} + +/// Compute `(packed_keys_bytes, shape_id)` for use with +/// [`js_object_alloc_with_shape`]. pub fn build_object_shape(keys: &[&str]) -> (Vec, u32) { let mut packed: Vec = Vec::new(); let mut shape_id: u32 = 0x4646_0000; diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index ae69827f26..959d6d5d97 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -72,12 +72,16 @@ pub use handle::{ mod jsvalue; pub use jsvalue::{ - 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_set_field, JsValue, + alloc_null_proto_object, alloc_object, 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_set_field, object_field_by_name, JsValue, }; mod closure; -pub use closure::{JsClosure, RawClosureHeader}; +pub use closure::{ + alloc_closure, closure_capture_f64, register_closure_arity, set_closure_capture_f64, JsClosure, + RawClosureHeader, +}; mod bigint; pub use bigint::{alloc_bigint_from_str, read_bigint_limbs}; @@ -90,11 +94,12 @@ pub use json::json_stringify; mod error; pub use error::{ - error_value_with_code, system_error_value, throw_with_code, value_byte_slice, ErrorKind, + emit_warning, error_value_with_code, system_error_value, throw_with_code, value_byte_slice, + warn_stub, ErrorKind, }; mod event_pump; -pub use event_pump::notify_main_thread; +pub use event_pump::{notify_main_thread, register_aux_event_pump}; mod raw_net; pub use raw_net::{raw_net, register_raw_net, RawNetVtable}; diff --git a/docs/src/contributing/crate-policy.md b/docs/src/contributing/crate-policy.md index 89c4f7612f..dbfdffdce5 100644 --- a/docs/src/contributing/crate-policy.md +++ b/docs/src/contributing/crate-policy.md @@ -63,8 +63,6 @@ reason to split or merge it. - Native bindings use `perry-ffi` as their production interface to Perry. - A production dependency from `perry-ext-*` to `perry-runtime` is forbidden. - `perry-ext-http` is the sole recorded migration debt while its missing FFI - capabilities are introduced. - Test binaries may enable `perry-ffi/runtime-link`; that edge provides runtime symbols for tests and is not part of the binding's distributed contract. - Runtime and stdlib functionality must have one production implementation. diff --git a/workspace-architecture.json b/workspace-architecture.json index f85ce7d398..4fa21ef747 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -3,9 +3,7 @@ "expected_default_members": [ "perry" ], - "allowed_binding_runtime_dependencies": [ - "perry-ext-http" - ], + "allowed_binding_runtime_dependencies": [], "ci": { "linux_host_excluded_members": [ "perry-ui-android",