From e659dfa56f20405208e660692aba844324e67d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 04:23:52 +0200 Subject: [PATCH 1/2] fix(worker): never report an empty handler rejection reason A rejected handler promise renders as deployment dispatch failed error=Runtime(invoking deployment HTTP handler: handler promise rejected: ) with nothing after the colon. That names neither the failure nor where to look, and it is what an operator actually sees on a 500. read_perry_string returns None only when the value is not a string, so a rejection carrying an Error object -- which reads back here as a zero-length string -- took the Some("") path and printed nothing. Empty string and non-string are now distinguished, and the raw NaN-boxed bits are always included so a bug report has something to correlate. Found while investigating perryts/perry#8546, where two Next.js applications in one in_process daemon leave the second serving 500 with exactly this empty reason. --- crates/coop-worker/src/plugin_host.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/coop-worker/src/plugin_host.rs b/crates/coop-worker/src/plugin_host.rs index 1a20fc9..3ce36f6 100644 --- a/crates/coop-worker/src/plugin_host.rs +++ b/crates/coop-worker/src/plugin_host.rs @@ -346,8 +346,23 @@ impl LoadedPlugin { // the error message; if it's not a string, we just // include the raw bits. let reason = unsafe { (api.js_promise_reason)(promise_ptr) }; - let reason_str = read_perry_string(reason) - .unwrap_or_else(|| format!("0x{:016x}", reason.to_bits())); + // Never render an empty reason. `handler promise rejected: ` + // with nothing after it is what an operator actually sees, + // and it names neither the failure nor where to look. An + // Error object reads back as a zero-length string here, so + // "" and "not a string" must stay distinguishable, and the + // raw bits are always worth keeping for a bug report. + let reason_str = match read_perry_string(reason) { + Some(text) if !text.is_empty() => text, + Some(_) => format!( + " (raw 0x{:016x})", + reason.to_bits() + ), + None => format!( + " (raw 0x{:016x})", + reason.to_bits() + ), + }; return Err(anyhow!("handler promise rejected: {}", reason_str)); } _ => { From f88709903fe59c780bbd0dbcf576f1a753dcac43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 11:19:21 +0200 Subject: [PATCH 2/2] fix(worker): stop reading a non-string rejection as a string; report the real error read_perry_string trusted a contract that does not exist. Its comment says js_get_string_pointer_unified 'Returns 0 if not a string', but that function deliberately returns the payload for a POINTER_TAG (0x7ffd) value too -- 'used for cross-module returns' (perry value/nanbox.rs). Handing it an Error object yields a non-null pointer that is NOT a StringHeader, and reading through it is a wild read: it fabricated a one-character reason ("\t") out of unrelated heap bytes, which then took the success path and printed as if it were the rejection reason. Check the NaN-box tag before dereferencing, and stringify a non-string rejection with js_jsvalue_to_string so an Error reports its message. On the case that motivated this (perryts/perry#8546, two Next.js apps in one in_process daemon), the reason goes from an empty string to handler promise rejected: TypeError: value is not a function which is the documented signature of a GC rooting failure -- turning an unattributable 500 into a named defect class. --- crates/coop-worker/src/plugin_host.rs | 57 +++++++++++++++++---- crates/coop-worker/src/runtime_libraries.rs | 5 ++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/coop-worker/src/plugin_host.rs b/crates/coop-worker/src/plugin_host.rs index 3ce36f6..38e2e3a 100644 --- a/crates/coop-worker/src/plugin_host.rs +++ b/crates/coop-worker/src/plugin_host.rs @@ -352,16 +352,24 @@ impl LoadedPlugin { // Error object reads back as a zero-length string here, so // "" and "not a string" must stay distinguishable, and the // raw bits are always worth keeping for a bug report. + let bits = reason.to_bits(); let reason_str = match read_perry_string(reason) { Some(text) if !text.is_empty() => text, - Some(_) => format!( - " (raw 0x{:016x})", - reason.to_bits() - ), - None => format!( - " (raw 0x{:016x})", - reason.to_bits() - ), + Some(_) => format!(" (raw 0x{bits:016x})"), + None => { + // Not a string: ask Perry to stringify it, so an + // `Error` object reports as "Error: " + // instead of an opaque tag. This is what turned an + // unattributable 500 into + // "TypeError: value is not a function". + let header = unsafe { (api.js_jsvalue_to_string)(reason) }; + read_string_header(header as *const StringHeader).unwrap_or_else(|| { + format!( + " (raw 0x{bits:016x})", + bits >> 48 + ) + }) + } }; return Err(anyhow!("handler promise rejected: {}", reason_str)); } @@ -648,10 +656,41 @@ fn make_perry_buffer(bytes: &[u8]) -> Result { Ok(value) } +/// NaN-box tags for the two string representations. See Perry's +/// `crates/perry-runtime/src/value/nanbox.rs`. +const STRING_TAG: u64 = 0x7fff; +const SHORT_STRING_TAG: u64 = 0x7ff9; + +/// Materialize a `StringHeader` the runtime handed us. Split out so the +/// rejection path can reuse it for `js_jsvalue_to_string`'s result. +fn read_string_header(header_ptr: *const StringHeader) -> Option { + if header_ptr.is_null() { + return None; + } + unsafe { + let len = (*header_ptr).byte_len as usize; + let data = (header_ptr as *const u8).add(std::mem::size_of::()); + let slice = std::slice::from_raw_parts(data, len); + Some(String::from_utf8_lossy(slice).into_owned()) + } +} + fn read_perry_string(value: f64) -> Option { + // Check the tag OURSELVES first. `js_get_string_pointer_unified` does NOT + // return 0 for every non-string: it deliberately returns the payload for a + // POINTER_TAG (0x7ffd) value too, "used for cross-module returns" + // (nanbox.rs). Handing it an object therefore yields a non-null pointer + // that is NOT a StringHeader, and reading through it is a wild read — it + // produced a bogus one-character reason ("\t") for a rejected handler + // promise carrying an Error object. + let tag = value.to_bits() >> 48; + if tag != STRING_TAG && tag != SHORT_STRING_TAG { + return None; + } + // js_get_string_pointer_unified handles both heap-allocated strings // (STRING_TAG) and inline SSO strings (SHORT_STRING_TAG) by - // materializing the latter to the heap. Returns 0 if not a string. + // materializing the latter to the heap. let header_ptr = unsafe { (crate::runtime_libraries::runtime_api().js_get_string_pointer_unified)(value) } as *const StringHeader; diff --git a/crates/coop-worker/src/runtime_libraries.rs b/crates/coop-worker/src/runtime_libraries.rs index cc31729..4e2d1f5 100644 --- a/crates/coop-worker/src/runtime_libraries.rs +++ b/crates/coop-worker/src/runtime_libraries.rs @@ -15,6 +15,9 @@ use std::time::Instant; pub(crate) type JsGcInit = unsafe extern "C" fn(); pub(crate) type JsPromiseState = unsafe extern "C" fn(*mut u8) -> i32; pub(crate) type JsPromiseValue = unsafe extern "C" fn(*mut u8) -> f64; +/// `js_jsvalue_to_string(value) -> *mut StringHeader`: stringify ANY JS value, +/// so a non-string rejection (an `Error` object) can still be reported. +pub(crate) type JsValueToString = unsafe extern "C" fn(f64) -> *const u8; pub(crate) type PerryPoll = unsafe extern "C" fn() -> i32; pub(crate) type JsWaitForEvent = unsafe extern "C" fn(); pub(crate) type JsValueIsPromise = unsafe extern "C" fn(f64) -> i32; @@ -37,6 +40,7 @@ pub(crate) struct RuntimeApi { pub js_promise_state: JsPromiseState, pub js_promise_value: JsPromiseValue, pub js_promise_reason: JsPromiseValue, + pub js_jsvalue_to_string: JsValueToString, pub perry_poll: PerryPoll, pub js_wait_for_event: JsWaitForEvent, pub js_value_is_promise: JsValueIsPromise, @@ -181,6 +185,7 @@ pub fn initialize_runtime_libraries_with_verification( js_promise_state: load_symbol(runtime_handle, "js_promise_state")?, js_promise_value: load_symbol(runtime_handle, "js_promise_value")?, js_promise_reason: load_symbol(runtime_handle, "js_promise_reason")?, + js_jsvalue_to_string: load_symbol(runtime_handle, "js_jsvalue_to_string")?, perry_poll: load_symbol(runtime_handle, "perry_poll")?, js_wait_for_event: load_symbol(runtime_handle, "js_wait_for_event")?, js_value_is_promise: load_symbol(runtime_handle, "js_value_is_promise")?,