diff --git a/Cargo.lock b/Cargo.lock index 2a9e494b60..6389c5f135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6180,6 +6180,7 @@ dependencies = [ "image", "kamadak-exif", "perry-ffi", + "perry-runtime", ] [[package]] diff --git a/changelog.d/8750-ext-error-objects.md b/changelog.d/8750-ext-error-objects.md new file mode 100644 index 0000000000..01683f96f3 --- /dev/null +++ b/changelog.d/8750-ext-error-objects.md @@ -0,0 +1 @@ +Reject native-extension failures with real JavaScript `Error` objects, including `.message`/`.stack`, and preserve mysql2-compatible `.code`/`.errno` metadata for common MySQL server errors. diff --git a/crates/perry-ext-events/src/test_async_shims.rs b/crates/perry-ext-events/src/test_async_shims.rs index cb3a466893..108f205115 100644 --- a/crates/perry-ext-events/src/test_async_shims.rs +++ b/crates/perry-ext-events/src/test_async_shims.rs @@ -1,4 +1,5 @@ use perry_ffi::Promise; +use std::ffi::c_void; // Unit-test binaries do not link the host stdlib/runtime archive that normally // provides the perry_ffi async bridge. Keep these test-only shims synchronous. @@ -23,3 +24,12 @@ pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64 f64::from_bits(bits), ); } + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} diff --git a/crates/perry-ext-fetch/src/test_async_shims.rs b/crates/perry-ext-fetch/src/test_async_shims.rs index ed22ecba04..9927c98571 100644 --- a/crates/perry-ext-fetch/src/test_async_shims.rs +++ b/crates/perry-ext-fetch/src/test_async_shims.rs @@ -22,6 +22,15 @@ pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64 ); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-http/src/test_async_shims.rs b/crates/perry-ext-http/src/test_async_shims.rs index e4b5407bbe..6f642e5f85 100644 --- a/crates/perry-ext-http/src/test_async_shims.rs +++ b/crates/perry-ext-http/src/test_async_shims.rs @@ -38,6 +38,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-mysql2/src/lib.rs b/crates/perry-ext-mysql2/src/lib.rs index 7ee3fe054b..7d4ae1fcd5 100644 --- a/crates/perry-ext-mysql2/src/lib.rs +++ b/crates/perry-ext-mysql2/src/lib.rs @@ -20,7 +20,7 @@ use perry_ffi::{ spawn_blocking, take_handle, with_handle, ArrayHeader, Handle, JsPromise, JsValue, ObjectHeader, Promise, StringHeader, }; -use sqlx::mysql::{MySqlConnection, MySqlPool, MySqlPoolOptions, MySqlRow}; +use sqlx::mysql::{MySqlConnection, MySqlDatabaseError, MySqlPool, MySqlPoolOptions, MySqlRow}; use sqlx::pool::PoolConnection; use sqlx::{Column, Connection, MySql, Row, TypeInfo}; use std::sync::Arc; @@ -587,6 +587,86 @@ enum MysqlConnectionTarget { Pool(Arc>>>), } +#[derive(Debug)] +struct MysqlPromiseError { + message: String, + code: Option<&'static str>, + errno: Option, +} + +impl MysqlPromiseError { + fn message(message: impl Into) -> Self { + Self { + message: message.into(), + code: None, + errno: None, + } + } + + fn from_sqlx(context: &str, error: sqlx::Error) -> Self { + let errno = error + .as_database_error() + .and_then(|database| database.try_downcast_ref::()) + .map(MySqlDatabaseError::number); + Self { + message: format!("{context}: {error}"), + code: errno.and_then(mysql2_error_code), + errno, + } + } + + fn reject(self, promise: JsPromise) { + if let Some(errno) = self.errno { + let code = self.code.unwrap_or(""); + let message = self.message; + promise.reject_with(move || { + // MySQL server errors use positive protocol error numbers, as + // mysql2 does, rather than libuv's negative errno convention. + perry_ffi::system_error_value(&message, code, "", i64::from(errno)) + }); + } else { + promise.reject_string(&self.message); + } + } +} + +/// mysql2 exposes symbolic server error names through `.code` and the numeric +/// protocol value through `.errno`. Keep the common SQL/application failures +/// stable here; unknown server numbers still retain `.errno`. +fn mysql2_error_code(errno: u16) -> Option<&'static str> { + Some(match errno { + 1022 => "ER_DUP_KEY", + 1045 => "ER_ACCESS_DENIED_ERROR", + 1048 => "ER_BAD_NULL_ERROR", + 1049 => "ER_BAD_DB_ERROR", + 1050 => "ER_TABLE_EXISTS_ERROR", + 1051 => "ER_BAD_TABLE_ERROR", + 1052 => "ER_NON_UNIQ_ERROR", + 1054 => "ER_BAD_FIELD_ERROR", + 1062 => "ER_DUP_ENTRY", + 1064 => "ER_PARSE_ERROR", + 1146 => "ER_NO_SUCH_TABLE", + 1169 => "ER_DUP_UNIQUE", + 1205 => "ER_LOCK_WAIT_TIMEOUT", + 1213 => "ER_LOCK_DEADLOCK", + 1216 => "ER_NO_REFERENCED_ROW", + 1217 => "ER_ROW_IS_REFERENCED", + 1264 => "ER_WARN_DATA_OUT_OF_RANGE", + 1292 => "ER_TRUNCATED_WRONG_VALUE", + 1364 => "ER_NO_DEFAULT_FOR_FIELD", + 1406 => "ER_DATA_TOO_LONG", + 1451 => "ER_ROW_IS_REFERENCED_2", + 1452 => "ER_NO_REFERENCED_ROW_2", + 1586 => "ER_DUP_ENTRY_WITH_KEY_NAME", + 1830 => "ER_FK_COLUMN_NOT_NULL", + 1834 => "ER_FK_CANNOT_DELETE_PARENT", + 1859 => "ER_DUP_UNKNOWN_IN_INDEX", + 3819 => "ER_CHECK_CONSTRAINT_VIOLATED", + 4025 => "ER_CONSTRAINT_FAILED", + _ => return None, + }) +} + /// Resolve either mysql2 connection handle family without returning a /// registry-backed `'static` reference. The old `get_handle_mut` calls dropped /// DashMap's guard before async work began, so overlapping workers could hold @@ -605,7 +685,7 @@ fn connection_target(handle: Handle) -> Option { async fn execute_query_on_connection( conn: &mut MySqlConnection, request: &QueryRequest, -) -> Result { +) -> Result { let is_select = request.is_row_returning(); if !request.uses_prepared_statement() { @@ -621,8 +701,8 @@ async fn execute_query_on_connection( raw.fetch_all(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; return Ok(QueryOutcome::Rows(raws_from_mysql_rows(rows))); } @@ -631,8 +711,8 @@ async fn execute_query_on_connection( raw.execute(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; return Ok(QueryOutcome::Executed { affected_rows: res.rows_affected(), last_insert_id: res.last_insert_id(), @@ -663,8 +743,8 @@ async fn execute_query_on_connection( query.fetch_all(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; Ok(QueryOutcome::Rows(raws_from_mysql_rows(rows))) } else { let res = tokio::time::timeout( @@ -672,8 +752,8 @@ async fn execute_query_on_connection( query.execute(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; Ok(QueryOutcome::Executed { affected_rows: res.rows_affected(), last_insert_id: res.last_insert_id(), @@ -684,20 +764,20 @@ async fn execute_query_on_connection( async fn execute_query_on_target( target: MysqlConnectionTarget, request: &QueryRequest, -) -> Result { +) -> Result { match target { MysqlConnectionTarget::Direct(connection) => { let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Connection already closed".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Connection already closed"))?; execute_query_on_connection(conn, request).await } MysqlConnectionTarget::Pool(connection) => { let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Pool connection released"))?; execute_query_on_connection(conn, request).await } } @@ -721,15 +801,15 @@ pub unsafe extern "C" fn js_mysql2_create_connection(config_f: f64) -> *mut Prom MySqlConnection::connect(&url), ) .await - .map_err(|_| "MySQL connection timed out".to_string())? - .map_err(|e| format!("Failed to connect: {}", e)) + .map_err(|_| MysqlPromiseError::message("MySQL connection timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Failed to connect", e)) }); match result { Ok(conn) => { let handle = register_handle(MysqlConnectionHandle::new(conn)); promise.resolve(JsValue::from_number(handle as f64)); } - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -748,7 +828,9 @@ pub extern "C" fn js_mysql2_connection_end(conn_handle: Handle) -> *mut Promise let result = tokio::runtime::Handle::current().block_on(conn.close()); match result { Ok(()) => promise.resolve_undefined(), - Err(e) => promise.reject_string(&format!("Failed to close: {}", e)), + Err(error) => { + MysqlPromiseError::from_sqlx("Failed to close", error).reject(promise) + } } } else { promise.reject_string("Connection already closed"); @@ -778,9 +860,10 @@ unsafe fn run_connection_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let target = target.ok_or_else(|| "Invalid connection handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let target = target + .ok_or_else(|| MysqlPromiseError::message("Invalid connection handle"))?; execute_query_on_target(target, &request).await }); match outcome { @@ -789,7 +872,7 @@ unsafe fn run_connection_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -827,36 +910,38 @@ fn run_simple_command(conn_handle: Handle, sql: &'static str) -> *mut Promise { let promise = JsPromise::new(); let raw = promise.as_raw(); spawn_blocking(move || { - let result = tokio::runtime::Handle::current().block_on(async move { - let target = target.ok_or_else(|| "Invalid connection handle".to_string())?; - match target { - MysqlConnectionTarget::Direct(connection) => { - let mut slot = connection.lock().await; - let conn = slot - .as_mut() - .ok_or_else(|| "Connection already closed".to_string())?; - sqlx::raw_sql(sql) - .execute(conn) - .await - .map(|_| ()) - .map_err(|e| format!("{}: {}", sql, e)) - } - MysqlConnectionTarget::Pool(connection) => { - let mut slot = connection.lock().await; - let conn = slot - .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; - sqlx::raw_sql(sql) - .execute(&mut **conn) - .await - .map(|_| ()) - .map_err(|e| format!("{}: {}", sql, e)) + let result: Result<(), MysqlPromiseError> = + tokio::runtime::Handle::current().block_on(async move { + let target = target + .ok_or_else(|| MysqlPromiseError::message("Invalid connection handle"))?; + match target { + MysqlConnectionTarget::Direct(connection) => { + let mut slot = connection.lock().await; + let conn = slot.as_mut().ok_or_else(|| { + MysqlPromiseError::message("Connection already closed") + })?; + sqlx::raw_sql(sql) + .execute(conn) + .await + .map(|_| ()) + .map_err(|e| MysqlPromiseError::from_sqlx(sql, e)) + } + MysqlConnectionTarget::Pool(connection) => { + let mut slot = connection.lock().await; + let conn = slot.as_mut().ok_or_else(|| { + MysqlPromiseError::message("Pool connection released") + })?; + sqlx::raw_sql(sql) + .execute(&mut **conn) + .await + .map(|_| ()) + .map_err(|e| MysqlPromiseError::from_sqlx(sql, e)) + } } - } - }); + }); match result { Ok(()) => promise.resolve_undefined(), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1141,9 +1226,9 @@ unsafe fn run_pool_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let pool = pool.ok_or_else(|| "Invalid pool handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let pool = pool.ok_or_else(|| MysqlPromiseError::message("Invalid pool handle"))?; // Explicitly check out one connection for the whole request so // statement preparation, bind encoding, execution, and result // draining cannot be split across independent pool operations. @@ -1152,8 +1237,8 @@ unsafe fn run_pool_query( pool.acquire(), ) .await - .map_err(|_| "Pool acquire timed out".to_string())? - .map_err(|e| format!("Pool acquire failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Pool acquire timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Pool acquire failed", e))?; execute_query_on_connection(&mut conn, &request).await }); match outcome { @@ -1162,7 +1247,7 @@ unsafe fn run_pool_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1197,21 +1282,21 @@ pub extern "C" fn js_mysql2_pool_get_connection(pool_handle: Handle) -> *mut Pro let raw = promise.as_raw(); spawn_blocking(move || { let result = tokio::runtime::Handle::current().block_on(async move { - let pool = pool.ok_or_else(|| "Invalid pool handle".to_string())?; + let pool = pool.ok_or_else(|| MysqlPromiseError::message("Invalid pool handle"))?; tokio::time::timeout( Duration::from_secs(DEFAULT_ACQUIRE_TIMEOUT_SECS), pool.acquire(), ) .await - .map_err(|_| "Pool acquire timed out".to_string())? - .map_err(|e| format!("Pool acquire failed: {}", e)) + .map_err(|_| MysqlPromiseError::message("Pool acquire timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Pool acquire failed", e)) }); match result { Ok(conn) => { let h = register_handle(MysqlPoolConnectionHandle::new(conn)); promise.resolve(JsValue::from_number(h as f64)); } - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1251,14 +1336,14 @@ unsafe fn run_pool_conn_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let connection = - connection.ok_or_else(|| "Invalid pool-connection handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let connection = connection + .ok_or_else(|| MysqlPromiseError::message("Invalid pool-connection handle"))?; let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Pool connection released"))?; execute_query_on_connection(conn, &request).await }); match outcome { @@ -1267,7 +1352,7 @@ unsafe fn run_pool_conn_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1299,6 +1384,13 @@ pub unsafe extern "C" fn js_mysql2_pool_connection_execute( mod tests { use super::*; + unsafe fn runtime_string(ptr: *const perry_runtime::StringHeader) -> String { + assert!(!ptr.is_null()); + // SAFETY: callers pass a live runtime string pointer obtained from the + // Error object under test. + unsafe { perry_ffi::copy_string_from_raw(ptr) } + } + #[test] fn config_defaults() { let cfg = MySqlConfig::default(); @@ -1446,4 +1538,68 @@ mod tests { assert_eq!(transaction_sql_for_method("rollback"), Some("ROLLBACK")); assert_eq!(transaction_sql_for_method("release"), None); } + + #[test] + fn mysql_server_error_metadata_matches_mysql2_shape() { + assert_eq!(mysql2_error_code(1062), Some("ER_DUP_ENTRY")); + assert_eq!(mysql2_error_code(1213), Some("ER_LOCK_DEADLOCK")); + assert_eq!(mysql2_error_code(u16::MAX), None); + + let promise = JsPromise::new(); + let raw = promise.as_raw(); + MysqlPromiseError { + message: "Query failed: 1062 duplicate entry".into(), + code: mysql2_error_code(1062), + errno: Some(1062), + } + .reject(promise); + + let reason = perry_runtime::promise::js_promise_reason(raw.cast()); + assert!( + JsValue::from_bits(perry_runtime::error::js_error_is_error(reason).to_bits()).to_bool() + ); + let reason = JsValue::from_bits(reason.to_bits()); + unsafe { + assert_eq!( + jsvalue_to_string(object_field_by_name(reason, "code")).as_deref(), + Some("ER_DUP_ENTRY") + ); + assert_eq!(object_field_by_name(reason, "errno").to_number(), 1062.0); + } + } + + #[test] + fn invalid_connection_rejects_with_error_object() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test tokio runtime"); + let _runtime_guard = runtime.enter(); + let sql = alloc_string("SELECT 1"); + + let promise = unsafe { + js_mysql2_connection_execute( + perry_ffi::INVALID_HANDLE, + sql.as_raw() as *const u8, + f64::from_bits(JsValue::UNDEFINED.bits()), + ) + }; + + assert_eq!(perry_runtime::promise::js_promise_state(promise.cast()), 2); + let reason = perry_runtime::promise::js_promise_reason(promise.cast()); + assert_eq!( + perry_runtime::error::js_error_is_error(reason).to_bits(), + JsValue::from_bool(true).bits() + ); + let error = + JsValue::from_bits(reason.to_bits()).as_pointer::(); + unsafe { + assert_eq!( + runtime_string((*error).message), + "Invalid connection handle" + ); + let stack = runtime_string((*error).stack); + assert!(stack.contains("Error: Invalid connection handle")); + } + } } diff --git a/crates/perry-ext-mysql2/src/test_async_shims.rs b/crates/perry-ext-mysql2/src/test_async_shims.rs index 47945057b6..23ff2d4486 100644 --- a/crates/perry-ext-mysql2/src/test_async_shims.rs +++ b/crates/perry-ext-mysql2/src/test_async_shims.rs @@ -35,6 +35,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-net/src/test_async_shims.rs b/crates/perry-ext-net/src/test_async_shims.rs index fb51459b10..525a79e6d0 100644 --- a/crates/perry-ext-net/src/test_async_shims.rs +++ b/crates/perry-ext-net/src/test_async_shims.rs @@ -34,6 +34,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-sharp/Cargo.toml b/crates/perry-ext-sharp/Cargo.toml index 67e1cabad9..789a7113d4 100644 --- a/crates/perry-ext-sharp/Cargo.toml +++ b/crates/perry-ext-sharp/Cargo.toml @@ -21,3 +21,6 @@ kamadak-exif = "0.6" [dev-dependencies] perry-ffi = { workspace = true, features = ["runtime-link"] } +# Standalone extension tests provide the runtime half of perry-ffi's async +# bridge; the production dependency remains perry-ffi-only. +perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-sharp/src/lib.rs b/crates/perry-ext-sharp/src/lib.rs index 686fc8dc88..6192ce1736 100644 --- a/crates/perry-ext-sharp/src/lib.rs +++ b/crates/perry-ext-sharp/src/lib.rs @@ -13,6 +13,9 @@ use perry_ffi::{ }; use std::io::Cursor; +#[cfg(test)] +mod test_async_shims; + // perry-runtime `#[no_mangle]` symbols (always linked) used to inspect raw // NaN-boxed JS values at the ext-crate boundary: the unified pointer mask // (works for strings AND buffers/objects), the Buffer-registry probe, and @@ -893,6 +896,28 @@ mod tests { assert_eq!(js_sharp_height(-1), 0.0); } + #[test] + fn invalid_handle_async_failure_is_an_error_object() { + let promise = js_sharp_metadata(perry_ffi::INVALID_HANDLE); + assert_eq!(perry_runtime::promise::js_promise_state(promise.cast()), 2); + + let reason = perry_runtime::promise::js_promise_reason(promise.cast()); + assert_eq!( + perry_runtime::error::js_error_is_error(reason).to_bits(), + JsValue::from_bool(true).bits() + ); + let error = + JsValue::from_bits(reason.to_bits()).as_pointer::(); + unsafe { + let message = (*error).message; + assert_eq!( + perry_ffi::copy_string_from_raw(message), + "Invalid sharp handle" + ); + assert!(!(*error).stack.is_null()); + } + } + #[test] fn orientation_6_swaps_dimensions() { // EXIF orientation 6 = rotate 90° CW → W×H becomes H×W. diff --git a/crates/perry-ext-sharp/src/test_async_shims.rs b/crates/perry-ext-sharp/src/test_async_shims.rs new file mode 100644 index 0000000000..a5b2afdab1 --- /dev/null +++ b/crates/perry-ext-sharp/src/test_async_shims.rs @@ -0,0 +1,38 @@ +//! Test-only host shims for the standalone sharp extension test binary. + +use perry_ffi::Promise; +use std::ffi::c_void; + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { + perry_runtime::promise::js_promise_new() as *mut Promise +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_runtime::promise::js_promise_resolve( + promise as *mut perry_runtime::Promise, + f64::from_bits(invoke(ctx)), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_runtime::promise::js_promise_reject( + promise as *mut perry_runtime::Promise, + f64::from_bits(invoke(ctx)), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { + invoke(ctx); +} diff --git a/crates/perry-ffi/src/async_runtime.rs b/crates/perry-ffi/src/async_runtime.rs index e5d35e19aa..cbeeb10f98 100644 --- a/crates/perry-ffi/src/async_runtime.rs +++ b/crates/perry-ffi/src/async_runtime.rs @@ -23,11 +23,10 @@ //! - A `JsPromise` is owned by Perry's runtime arena from //! construction onwards. Once resolved or rejected, the //! underlying `Promise` is consumed by the awaiter. -//! - The "bits" passed to [`JsPromise::resolve_string`] / -//! [`JsPromise::reject_string`] are NaN-boxed `JSValue` -//! representations. The safe wrappers in this module produce -//! the right bit pattern so wrapper authors don't need to know -//! the tag values. +//! - The "bits" passed to [`JsPromise::resolve_string`] are NaN-boxed +//! `JSValue` representations. [`JsPromise::reject_string`] copies its +//! message and constructs the corresponding JavaScript `Error` on the main +//! thread, so wrapper authors don't need to know the runtime layout. use std::ffi::c_void; @@ -46,6 +45,11 @@ extern "C" { ctx: *mut c_void, invoke: extern "C" fn(*mut c_void) -> u64, ); + fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, + ); fn perry_ffi_native_async_new(flags: u32) -> *mut NativeAsyncCompletion; fn perry_ffi_native_async_promise(token: *mut NativeAsyncCompletion) -> *mut Promise; fn perry_ffi_native_async_resolve_bits(token: *mut NativeAsyncCompletion, bits: u64) -> i32; @@ -221,18 +225,46 @@ impl JsPromise { unsafe { perry_ffi_promise_resolve_deferred(self.0, ctx, invoke) }; } + /// Reject by building the reason on the **main thread**. + /// + /// This is the rejection-side twin of [`Self::resolve_with`]. It is useful + /// for native failures that need to allocate an Error object or attach + /// structured fields after worker-thread work has completed. + pub fn reject_with(self, f: F) + where + F: FnOnce() -> crate::JsValue + Send + 'static, + { + let boxed: Box u64 + Send> = Box::new(move || f().bits()); + let thin: Box u64 + Send>> = Box::new(boxed); + let ctx = Box::into_raw(thin) as *mut c_void; + + extern "C" fn invoke(ctx: *mut c_void) -> u64 { + let thin: Box u64 + Send>> = + unsafe { Box::from_raw(ctx as *mut Box u64 + Send>) }; + let f: Box u64 + Send> = *thin; + f() + } + + unsafe { perry_ffi_promise_reject_deferred(self.0, ctx, invoke) }; + } + /// Reject with an arbitrary [`crate::JsValue`]. Mirror of /// [`Self::resolve`]. pub fn reject(self, value: crate::JsValue) { unsafe { perry_ffi_promise_reject_bits(self.0, value.bits()) }; } - /// Reject with an error message string. The wrapper layer - /// produces an Error-shaped JSValue downstream; here we just - /// pass the raw message bits. + /// Reject with a JavaScript [`Error`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error) + /// whose `.message` is `message`. + /// + /// The message is copied before returning and the Error is allocated on + /// the main thread. Use [`Self::reject`] when intentionally rejecting with + /// a non-Error JavaScript value. pub fn reject_string(self, message: &str) { - let str_handle = alloc_string(message); - unsafe { perry_ffi_promise_reject_bits(self.0, nanbox_string_bits(str_handle.as_raw())) }; + let message = message.to_owned(); + self.reject_with(move || { + crate::error_value_with_code(&message, "", crate::ErrorKind::Error) + }); } } @@ -299,8 +331,9 @@ impl JsNativeAsyncCompletion { self.resolve_bits(TAG_UNDEFINED) } - /// Reject with a string reason. The runtime copies the bytes immediately and - /// allocates the Perry JS string later on the main thread. + /// Reject with a JavaScript `Error` whose `.message` is `message`. The + /// runtime copies the bytes immediately and allocates the Error later on + /// the main thread. pub fn reject_string(self, message: &str) -> i32 { unsafe { perry_ffi_native_async_reject_string(self.0, message.as_ptr(), message.len()) } } diff --git a/crates/perry-runtime/src/promise/native_async.rs b/crates/perry-runtime/src/promise/native_async.rs index b0e577dafb..17c64a4b1d 100644 --- a/crates/perry-runtime/src/promise/native_async.rs +++ b/crates/perry-runtime/src/promise/native_async.rs @@ -191,17 +191,18 @@ fn complete_bits(token: *mut NativeAsyncCompletion, bits: u64, fulfilled: bool) enqueue_with_thread_policy(token, payload, PERRY_NATIVE_ASYNC_OK) } -fn bytes_value_bits(bytes: &[u8]) -> u64 { - let ptr = if bytes.is_empty() { +fn error_value_bits(message: &[u8]) -> u64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let message = if message.is_empty() { crate::string::js_string_from_bytes(std::ptr::null(), 0) } else { - crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32) }; - crate::value::JSValue::string_ptr(ptr).bits() -} - -fn string_value_bits(message: &str) -> u64 { - bytes_value_bits(message.as_bytes()) + let message = scope.root_string_ptr(message); + let error = crate::error::js_error_new_with_message( + message.get_raw_mut_ptr::(), + ); + crate::value::js_nanbox_pointer(error as i64).to_bits() } fn payload_to_settlement(payload: PendingPayload) -> (bool, u64, u32) { @@ -210,17 +211,17 @@ fn payload_to_settlement(payload: PendingPayload) -> (bool, u64, u32) { PendingPayload::RejectBits(bits) => (false, bits, PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT), PendingPayload::RejectString(bytes) => ( false, - bytes_value_bits(&bytes), + error_value_bits(&bytes), PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT, ), PendingPayload::Cancel => ( false, - string_value_bits(DEFAULT_CANCEL_REASON), + error_value_bits(DEFAULT_CANCEL_REASON.as_bytes()), PERRY_NATIVE_ASYNC_CLEANUP_ON_CANCEL, ), PendingPayload::WrongThread => ( false, - string_value_bits(WRONG_THREAD_REASON), + error_value_bits(WRONG_THREAD_REASON.as_bytes()), PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT, ), } @@ -333,10 +334,11 @@ pub extern "C" fn js_native_async_completion_reject_bits( complete_bits(token, bits, false) } -/// Reject a native async token with caller-owned UTF-8 bytes. +/// Reject a native async token with an Error carrying caller-owned UTF-8 bytes +/// as its message. /// /// The bytes are copied before enqueueing so worker threads do not allocate -/// Perry runtime strings; string allocation happens while draining on the main +/// Perry runtime values; Error allocation happens while draining on the main /// thread. #[no_mangle] pub extern "C" fn js_native_async_completion_reject_string( @@ -681,15 +683,26 @@ mod tests { ) } - unsafe fn assert_heap_string_value(value: f64, expected: &[u8]) { - let value = crate::value::JSValue::from_bits(value.to_bits()); - assert!(value.is_string(), "expected heap string JSValue"); - let ptr = value.as_string_ptr(); + unsafe fn string_bytes(ptr: *const crate::StringHeader) -> Vec { assert!(!ptr.is_null(), "expected non-null string pointer"); - assert_eq!((*ptr).byte_len as usize, expected.len()); + 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, expected.len()); - assert_eq!(bytes, expected); + std::slice::from_raw_parts(data, len).to_vec() + } + + unsafe fn assert_error_value(value: f64, expected: &[u8]) { + let value = crate::value::JSValue::from_bits(value.to_bits()); + assert!(value.is_pointer(), "expected Error pointer JSValue"); + let error = value.as_pointer::(); + assert!(crate::error::ptr_is_native_error(error as usize)); + assert_eq!(string_bytes((*error).message), expected); + + let stack = string_bytes((*error).stack); + assert!( + stack.starts_with(b"Error: ") && stack.windows(expected.len()).any(|w| w == expected), + "Error.stack must include the rejection message: {}", + String::from_utf8_lossy(&stack) + ); } #[test] @@ -757,7 +770,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value(super::super::js_promise_reason(promise), &expected); + assert_error_value(super::super::js_promise_reason(promise), &expected); } } @@ -786,7 +799,7 @@ mod tests { assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), DEFAULT_CANCEL_REASON.as_bytes(), ); @@ -881,7 +894,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), WRONG_THREAD_REASON.as_bytes(), ); @@ -917,7 +930,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), WRONG_THREAD_REASON.as_bytes(), ); diff --git a/crates/perry-stdlib/src/perry_ffi_async.rs b/crates/perry-stdlib/src/perry_ffi_async.rs index cd18bc3bf6..938bb73028 100644 --- a/crates/perry-stdlib/src/perry_ffi_async.rs +++ b/crates/perry-stdlib/src/perry_ffi_async.rs @@ -184,6 +184,22 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( }); } +/// `perry_ffi_promise_reject_deferred(promise, ctx, invoke)` — rejection-side +/// twin of [`perry_ffi_promise_resolve_deferred`]. `invoke(ctx)` runs once on +/// the main thread so external bindings can safely allocate Error objects and +/// other structured rejection values after worker-thread work completes. +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut perry_runtime::Promise, + ctx: *mut std::ffi::c_void, + invoke: extern "C" fn(*mut std::ffi::c_void) -> u64, +) { + let ctx_addr = ctx as usize; + async_bridge::queue_deferred_resolution(promise as usize, false, move || { + invoke(ctx_addr as *mut std::ffi::c_void) + }); +} + /// `perry_ffi_spawn_blocking(ctx, invoke)` — run `invoke(ctx)` on /// the global tokio runtime's blocking pool. The caller is expected /// to box a closure into `ctx` before calling, and write a thin diff --git a/docs/src/native-libraries/abi.md b/docs/src/native-libraries/abi.md index a3a12ed3be..a9db81b191 100644 --- a/docs/src/native-libraries/abi.md +++ b/docs/src/native-libraries/abi.md @@ -102,6 +102,15 @@ return / parameter types — wrappers should write `pub extern "C" fn js_my_module_thing() -> *mut perry_ffi::StringHeader`, not import `StringHeader` from `perry-runtime` directly. +### Async promise rejection + +`JsPromise::reject_string(message)` copies the message and rejects with a real +JavaScript `Error` allocated on the runtime's main thread. Its `.message` and +`.stack` are available to ordinary handlers, and `instanceof Error` succeeds. +`JsPromise::reject(value)` remains the escape hatch for APIs that deliberately +reject with an arbitrary JavaScript value. Use `JsPromise::reject_with` to +construct a structured rejection value safely on the main thread. + ### What's NOT in v0.5 These will land as real wrappers force them, tracked under diff --git a/docs/src/native-libraries/authoring-guide.md b/docs/src/native-libraries/authoring-guide.md index d72f394dae..18bd0abe38 100644 --- a/docs/src/native-libraries/authoring-guide.md +++ b/docs/src/native-libraries/authoring-guide.md @@ -366,6 +366,12 @@ pub extern "C" fn js_my_fetch(url_ptr: *const StringHeader) -> *mut Promise { } ``` +`reject_string(message)` rejects with a real JavaScript `Error`: consumers can +use `error instanceof Error`, `error.message`, and `error.stack`. Use +`reject(value)` only when the API intentionally rejects with a non-Error value, +or `reject_with(...)` when the Error needs structured fields built on the main +thread. + ### Sync handle-based class Use a `handle` descriptor for synchronous resource-style APIs. The diff --git a/docs/src/native-libraries/overview.md b/docs/src/native-libraries/overview.md index cc56aad737..ad00c0a30e 100644 --- a/docs/src/native-libraries/overview.md +++ b/docs/src/native-libraries/overview.md @@ -184,7 +184,7 @@ The 9 surface dimensions perry-ffi exposes today are: | Surface | What it does | Documented at | |---|---|---| | Strings | `JsString` / `alloc_string` / `read_string` / `read_bytes` / `alloc_bytes` | [`abi.md`](abi.md) | -| Async / Promise | `JsPromise` (`new` / `resolve` / `reject_string`), `spawn_blocking` | [`abi.md`](abi.md) | +| Async / Promise | `JsPromise` (`new` / `resolve` / `reject_string` as `Error` / `reject_with`), `spawn_blocking` | [`abi.md`](abi.md) | | Handles | `register_handle` / `get_handle` / `with_handle` / `take_handle` / `iter_handles_of` | [`abi.md`](abi.md) | | JsValue + objects/arrays | `JsValue`, `js_array_alloc/push/get/set`, `js_object_alloc_with_shape`, `js_object_get_field`, `js_object_set_field`, `build_object_shape` | [`abi.md`](abi.md) | | Closures | `JsClosure::call0..4` | [`abi.md`](abi.md) |