From c76da1c9c8b1d7a5185c2e0bd745db81694c4e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:22:09 +0200 Subject: [PATCH 1/9] fix(slugify): parse the npm replacement-or-options second argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `slugify("Hello World", { lower: true })` returned "hello{world": the dispatch rows coerced the options object through NA_STR, so it was JSON-stringified and its first char '{' became the separator. The runtime also diverged from simov/slugify in several ways (always lowercased, dropped '-'/'_' as separators, first-char-only replacement). - Change `js_slugify_with_options` (perry-stdlib + perry-ext-slugify) to take the second JS argument as raw NaN-box bits (i64) and distinguish string (replacement) / object ({ replacement, lower, strict, trim }) / undefined, matching npm's overloads. `remove` and `locale` remain unsupported. - Reimplement the core following the npm algorithm: case-preserving charMap (incl. multi-char maps like 'ß'→"ss", '&'→"and"), the default keep-set [\w\s$*_+~.()'"!\-:@], strict ([A-Za-z0-9\s]), trim-default- true, whitespace-run collapse to the full replacement string, and lower as a final pass. - Dispatch rows (utils_crypto.rs) become [NA_STR, NA_JSV]; manifest entries updated to the 2-arg shape (p_str, p_any). js_slugify / js_slugify_strict keep their exported signatures. --- .../perry-api-manifest/src/entries/part_1.rs | 7 +- .../lower_call/native_table/utils_crypto.rs | 13 +- crates/perry-ext-slugify/src/lib.rs | 440 +++++++++++++----- crates/perry-stdlib/src/slugify.rs | 411 ++++++++++++---- test-files/test_gap_slugify_options.ts | 32 ++ 5 files changed, 713 insertions(+), 190 deletions(-) create mode 100644 test-files/test_gap_slugify_options.ts diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index a54e5e058e..2ef4d1f74c 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1192,12 +1192,15 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::String, ), + // Second arg is npm slugify's replacement-or-options overload + // (string | { replacement, lower, strict, trim }) — Any, matching + // the NA_JSV dispatch slot. method_sig( "slugify", "default", false, None, - &[p_str("p0"), p_str("p1"), p_str("p2")], + &[p_str("p0"), p_any("p1")], TypeSpec::String, ), method_sig( @@ -1205,7 +1208,7 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ "slugify", false, None, - &[p_str("p0"), p_str("p1"), p_str("p2")], + &[p_str("p0"), p_any("p1")], TypeSpec::String, ), method_sig( diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 5a2670d16e..ceed7a76c7 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -144,8 +144,13 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ ret: NR_STR, }, // ========== slugify ========== - // Three-arg form handles both slugify(s) and slugify(s, replacement_char). - // Missing args pad to null ptr → runtime uses "-" default separator. + // Second arg is npm slugify's replacement-or-options overload: a + // plain string ('_') OR an options object ({ replacement, lower, + // strict, trim }). It must cross as raw NaN-box bits (NA_JSV) so + // the runtime can distinguish the two — the old NA_STR coercion + // JSON-stringified the object and its first char '{' became the + // separator ("hello{world"). Missing arg pads to TAG_UNDEFINED → + // runtime defaults ("-" separator, no lower/strict, trim). // "default" for `import slugify from 'slugify'; slugify(s)` (HIR emits method:"default"). // "slugify" for `import { slugify } from 'slugify'; slugify(s)` (named import). NativeModSig { @@ -154,7 +159,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ method: "default", class_filter: None, runtime: "js_slugify_with_options", - args: &[NA_STR, NA_STR, NA_STR], + args: &[NA_STR, NA_JSV], ret: NR_STR, }, NativeModSig { @@ -163,7 +168,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ method: "slugify", class_filter: None, runtime: "js_slugify_with_options", - args: &[NA_STR, NA_STR, NA_STR], + args: &[NA_STR, NA_JSV], ret: NR_STR, }, // ========== validator ========== diff --git a/crates/perry-ext-slugify/src/lib.rs b/crates/perry-ext-slugify/src/lib.rs index 9d4f705995..2200ad3e28 100644 --- a/crates/perry-ext-slugify/src/lib.rs +++ b/crates/perry-ext-slugify/src/lib.rs @@ -1,76 +1,309 @@ //! Native bindings for the npm `slugify` package. //! -//! Functionally identical to `crates/perry-stdlib/src/slugify.rs`. -//! Depends only on [`perry_ffi`] — fourth wrapper port under -//! #466 Phase 5. - -use perry_ffi::{alloc_string, read_string, JsString, StringHeader}; - -/// Mirror of the perry-stdlib accent-folding table. Kept in-line -/// rather than pulling a transliteration crate so the resulting -/// `.a` is the same ~30 KB size as the stdlib copy — predictable -/// for users measuring binary growth across the well-known flip. -fn replace_accents(c: char) -> Option { - match c { - 'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' => { - Some('a') +//! Functionally identical to `crates/perry-stdlib/src/slugify.rs` — +//! both follow simov/slugify's actual algorithm: +//! +//! 1. per-char charMap substitution (case-preserving: 'É' → 'E'); +//! 2. a mapped char equal to `options.replacement` becomes a space; +//! 3. chars outside the default keep-set `[\w\s$*_+~.()'"!\-:@]` are +//! removed (the `remove` regex option is not supported); +//! 4. `strict` strips everything but `[A-Za-z0-9\s]`; +//! 5. `trim` (default true) trims whitespace; +//! 6. whitespace runs collapse to the (full, possibly multi-char) +//! replacement string; +//! 7. `lower` lowercases the final slug. +//! +//! The second argument mirrors npm slugify's overloads: a plain string +//! (the replacement) or an options object `{ replacement, lower, +//! strict, trim }`. It crosses the FFI as raw NaN-box bits (i64) so +//! this wrapper can distinguish string / object / undefined — the old +//! coerce-to-string ABI is what garbled `slugify(s, { lower: true })` +//! into `hello{world` (the JSON-stringified object's first char `{` +//! became the separator). +//! +//! Depends only on [`perry_ffi`] plus three C-ABI runtime symbols +//! (declared below, resolved at final link — the perry-ext-events +//! pattern for by-name object field reads). + +use perry_ffi::{alloc_string, read_string, JsString, JsValue, ObjectHeader, StringHeader}; + +extern "C" { + /// perry-runtime: read an object field by string key, returning the + /// raw NaN-boxed JSValue bits as f64 (undefined tag when absent). + fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; + /// perry-runtime: JS truthiness probe for a NaN-boxed value. + fn js_is_truthy(value: f64) -> i32; + /// perry-runtime: extract the StringHeader pointer from any + /// string-tagged NaN-boxed value. + fn js_get_string_pointer_unified(value: f64) -> i64; +} + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + +/// Subset of npm slugify's charMap. Case-preserving, may expand to +/// multiple chars ('ß' → "ss", '&' → "and") — exactly like the npm map. +fn char_map(c: char) -> Option<&'static str> { + Some(match c { + 'À' => "A", + 'Á' => "A", + 'Â' => "A", + 'Ã' => "A", + 'Ä' => "A", + 'Å' => "A", + 'Æ' => "AE", + 'Ç' => "C", + 'È' => "E", + 'É' => "E", + 'Ê' => "E", + 'Ë' => "E", + 'Ì' => "I", + 'Í' => "I", + 'Î' => "I", + 'Ï' => "I", + 'Ð' => "D", + 'Ñ' => "N", + 'Ò' => "O", + 'Ó' => "O", + 'Ô' => "O", + 'Õ' => "O", + 'Ö' => "O", + 'Ø' => "O", + 'Ù' => "U", + 'Ú' => "U", + 'Û' => "U", + 'Ü' => "U", + 'Ý' => "Y", + 'Þ' => "TH", + 'ß' => "ss", + 'à' => "a", + 'á' => "a", + 'â' => "a", + 'ã' => "a", + 'ä' => "a", + 'å' => "a", + 'æ' => "ae", + 'ç' => "c", + 'è' => "e", + 'é' => "e", + 'ê' => "e", + 'ë' => "e", + 'ì' => "i", + 'í' => "i", + 'î' => "i", + 'ï' => "i", + 'ð' => "d", + 'ñ' => "n", + 'ò' => "o", + 'ó' => "o", + 'ô' => "o", + 'õ' => "o", + 'ö' => "o", + 'ø' => "o", + 'ù' => "u", + 'ú' => "u", + 'û' => "u", + 'ü' => "u", + 'ý' => "y", + 'þ' => "th", + 'ÿ' => "y", + 'Ÿ' => "Y", + 'Œ' => "OE", + 'œ' => "oe", + '&' => "and", + '|' => "or", + '<' => "less", + '>' => "greater", + '©' => "(c)", + '®' => "(r)", + '™' => "tm", + _ => return None, + }) +} + +/// JS `\s` (non-unicode regex flag): ASCII whitespace + the Unicode +/// space separators the npm regexes match. +fn js_space_char(c: char) -> bool { + matches!( + c, + ' ' | '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +fn default_keep(c: char) -> bool { + // JS `[\w\s$*_+~.()'"!\-:@]` with the default (ASCII) `\w`. + c.is_ascii_alphanumeric() + || c == '_' + || js_space_char(c) + || matches!( + c, + '$' | '*' | '+' | '~' | '.' | '(' | ')' | '\'' | '"' | '!' | '-' | ':' | '@' + ) +} + +/// Parsed slugify options (npm surface; `remove` / `locale` unsupported). +struct SlugifyOptions { + /// The replacement string for whitespace runs. npm defaults it to + /// "-" BEFORE the per-char `appendChar === replacement` check, so a + /// literal '-' in the input collapses with adjacent whitespace even + /// when no replacement was supplied (slugify('a - b') === 'a-b'). + replacement: String, + lower: bool, + strict: bool, + trim: bool, +} + +impl Default for SlugifyOptions { + fn default() -> Self { + SlugifyOptions { + replacement: "-".to_string(), + lower: false, + strict: false, + trim: true, } - 'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => Some('e'), - 'í' | 'ì' | 'î' | 'ï' | 'Í' | 'Ì' | 'Î' | 'Ï' => Some('i'), - 'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ø' | 'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Õ' | 'Ø' => { - Some('o') + } +} + +/// Core algorithm — mirrors npm slugify's reduce + post-passes ordering. +fn slugify_npm(input: &str, opts: &SlugifyOptions) -> String { + let mut slug = String::with_capacity(input.len()); + let mut buf = [0u8; 4]; + for ch in input.chars() { + let mapped: &str = match char_map(ch) { + Some(m) => m, + None => ch.encode_utf8(&mut buf), + }; + // `if (appendChar === replacement) appendChar = ' '` — with the + // already-defaulted replacement. + let effective: &str = if mapped == opts.replacement { + " " + } else { + mapped + }; + for c in effective.chars() { + if default_keep(c) { + slug.push(c); + } + } + } + + if opts.strict { + slug.retain(|c| c.is_ascii_alphanumeric() || js_space_char(c)); + } + if opts.trim { + slug = slug.trim_matches(js_space_char).to_string(); + } + + // replace(/\s+/g, replacement) + let mut out = String::with_capacity(slug.len()); + let mut in_ws = false; + for c in slug.chars() { + if js_space_char(c) { + if !in_ws { + out.push_str(&opts.replacement); + in_ws = true; + } + } else { + out.push(c); + in_ws = false; } - 'ú' | 'ù' | 'û' | 'ü' | 'Ú' | 'Ù' | 'Û' | 'Ü' => Some('u'), - 'ý' | 'ÿ' | 'Ý' | 'Ÿ' => Some('y'), - 'ñ' | 'Ñ' => Some('n'), - 'ç' | 'Ç' => Some('c'), - 'ß' => Some('s'), - 'æ' | 'Æ' => Some('a'), - 'œ' | 'Œ' => Some('o'), - 'ð' | 'Ð' => Some('d'), - 'þ' | 'Þ' => Some('t'), - _ => None, } + + if opts.lower { + out.to_lowercase() + } else { + out + } +} + +unsafe fn string_from_bits(bits: u64) -> Option { + let ptr = js_get_string_pointer_unified(f64::from_bits(bits)) as *mut StringHeader; + if ptr.is_null() { + return None; + } + read_string(JsString::from_raw(ptr)).map(String::from) } -/// `slugify(string)` — default URL-friendly slug with `-` separator. +/// Decode the second slugify argument from raw NaN-box bits. +unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { + let mut opts = SlugifyOptions::default(); + let bits = options_bits as u64; + let jv = JsValue::from_bits(bits); + + if jv.is_any_string() { + if let Some(s) = string_from_bits(bits) { + opts.replacement = s; + } + return opts; + } + + if !jv.is_pointer() { + return opts; + } + let obj = jv.as_pointer::(); + if obj.is_null() || (obj as usize) < 0x1000 { + return opts; + } + + let field = |name: &str| -> f64 { + let key = alloc_string(name); + js_object_get_field_by_name_f64(obj, key.as_raw()) + }; + + let replacement = field("replacement"); + if JsValue::from_bits(replacement.to_bits()).is_any_string() { + if let Some(s) = string_from_bits(replacement.to_bits()) { + opts.replacement = s; + } + } + opts.lower = js_is_truthy(field("lower")) != 0; + opts.strict = js_is_truthy(field("strict")) != 0; + // npm: `if (options.trim !== false) slug = slug.trim()` — absent + // (undefined) means trim. + let trim = field("trim"); + opts.trim = JsValue::from_bits(trim.to_bits()).is_undefined() || js_is_truthy(trim) != 0; + opts +} + +/// `slugify(string)` — default slug with `-` separator (case-preserved, +/// matching npm). /// /// # Safety /// /// `input_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_slugify(input_ptr: *const StringHeader) -> *mut StringHeader { - js_slugify_with_options(input_ptr, std::ptr::null(), std::ptr::null()) + js_slugify_with_options(input_ptr, TAG_UNDEFINED as i64) } -/// `slugify(string, { replacement, lower })` — slug with a caller- -/// supplied replacement character. `_options_ptr` is reserved for -/// future option passing without a signature change. +/// `slugify(string, replacementOrOptions)` — `options_bits` carries the +/// second JS argument as raw NaN-box bits: string → replacement, object +/// → `{ replacement, lower, strict, trim }`, undefined → defaults. /// /// # Safety /// -/// All three pointers must be null or Perry-runtime `StringHeader`s. +/// `input_ptr` must be null or a Perry-runtime `StringHeader`; +/// `options_bits` must be valid NaN-box bits. #[no_mangle] pub unsafe extern "C" fn js_slugify_with_options( input_ptr: *const StringHeader, - replacement_ptr: *const StringHeader, - _options_ptr: *const StringHeader, + options_bits: i64, ) -> *mut StringHeader { let input_handle = JsString::from_raw(input_ptr as *mut StringHeader); let Some(input) = read_string(input_handle) else { return std::ptr::null_mut(); }; - - let replacement_handle = JsString::from_raw(replacement_ptr as *mut StringHeader); - let replacement_char = read_string(replacement_handle) - .and_then(|s| s.chars().next()) - .unwrap_or('-'); - - alloc_string(&slugify_to_string(input, replacement_char, false)).as_raw() + let opts = options_from_bits(options_bits); + alloc_string(&slugify_npm(input, &opts)).as_raw() } -/// `slugify(string, { strict: true })` — only alphanumeric output; -/// non-alphanumeric clusters collapse to a single `-`. +/// `slugify(string, { strict: true })` — legacy strict entry point. /// /// # Safety /// @@ -81,83 +314,82 @@ pub unsafe extern "C" fn js_slugify_strict(input_ptr: *const StringHeader) -> *m let Some(input) = read_string(handle) else { return std::ptr::null_mut(); }; - alloc_string(&slugify_to_string(input, '-', true)).as_raw() + let opts = SlugifyOptions { + strict: true, + ..SlugifyOptions::default() + }; + alloc_string(&slugify_npm(input, &opts)).as_raw() } -fn slugify_to_string(input: &str, replacement: char, strict: bool) -> String { - let mut result = String::with_capacity(input.len()); - let mut last_was_separator = true; // Start true to trim leading separators - - for c in input.chars() { - let c = replace_accents(c).unwrap_or(c); - - if c.is_ascii_alphanumeric() { - result.push(c.to_ascii_lowercase()); - last_was_separator = false; - } else if strict { - // Non-alphanumeric → single replacement, regardless of - // what character it actually was. - if !last_was_separator { - result.push(replacement); - last_was_separator = true; - } - } else if (c.is_whitespace() || c == '_' || c == '-' || c == '/' || c == '\\') - && !last_was_separator - { - result.push(replacement); - last_was_separator = true; - } - // Otherwise the char is stripped silently. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_preserves_case_like_npm() { + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("Hello World", &opts), "Hello-World"); } - if result.ends_with(replacement) { - result.pop(); + #[test] + fn lower_option_lowercases() { + let opts = SlugifyOptions { + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("Hello World", &opts), "hello-world"); } - result -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn string_replacement_is_full_string() { + let opts = SlugifyOptions { + replacement: "__".into(), + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("foo bar", &opts), "foo__bar"); + } - fn read_handle(handle: *mut StringHeader) -> String { - read_string(unsafe { JsString::from_raw(handle) }) - .map(String::from) - .unwrap_or_default() + #[test] + fn strict_removes_non_alnum() { + let opts = SlugifyOptions { + strict: true, + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!( + slugify_npm("Hello, World! (2024)", &opts), + "hello-world-2024" + ); } #[test] - fn lowercases_and_dashifies() { - let input = alloc_string("Hello World!"); - let s = read_handle(unsafe { js_slugify(input.as_raw() as *const _) }); - assert_eq!(s, "hello-world"); + fn accents_fold_case_preserving() { + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("Crème Brûlée", &opts), "Creme-Brulee"); } #[test] - fn folds_accents() { - let input = alloc_string("Café au lait"); - let s = read_handle(unsafe { js_slugify(input.as_raw() as *const _) }); - assert_eq!(s, "cafe-au-lait"); + fn ampersand_maps_to_and() { + let opts = SlugifyOptions { + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("Foo & Bar", &opts), "foo-and-bar"); } #[test] - fn strict_mode_drops_punctuation_to_single_dash() { - let input = alloc_string("hello!! ___ world"); - let s = read_handle(unsafe { js_slugify_strict(input.as_raw() as *const _) }); - assert_eq!(s, "hello-world"); + fn default_dash_collapses_with_whitespace() { + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("a - b", &opts), "a-b"); + assert_eq!(slugify_npm("a-b", &opts), "a-b"); } #[test] - fn custom_replacement_char_is_first_char_of_string() { - let input = alloc_string("hello world foo"); - let replacement = alloc_string("_"); - let s = read_handle(unsafe { - js_slugify_with_options( - input.as_raw() as *const _, - replacement.as_raw() as *const _, - std::ptr::null(), - ) - }); - assert_eq!(s, "hello_world_foo"); + fn trim_false_keeps_edges_as_separators() { + let opts = SlugifyOptions { + trim: false, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm(" foo bar ", &opts), "-foo-bar-"); } } diff --git a/crates/perry-stdlib/src/slugify.rs b/crates/perry-stdlib/src/slugify.rs index 4000af7f5a..e0629eb0d4 100644 --- a/crates/perry-stdlib/src/slugify.rs +++ b/crates/perry-stdlib/src/slugify.rs @@ -1,9 +1,27 @@ //! Slugify module (slugify compatible) //! -//! Native implementation of the 'slugify' npm package. -//! Converts strings to URL-friendly slugs. +//! Native implementation of the 'slugify' npm package (simov/slugify), +//! following the package's actual algorithm: +//! +//! 1. per-char charMap substitution (case-preserving: 'É' → 'E'); +//! 2. a mapped char equal to `options.replacement` becomes a space; +//! 3. chars outside the default keep-set `[\w\s$*_+~.()'"!\-:@]` are +//! removed (the `remove` regex option is not supported); +//! 4. `strict` strips everything but `[A-Za-z0-9\s]`; +//! 5. `trim` (default true) trims whitespace; +//! 6. whitespace runs collapse to the (full, possibly multi-char) +//! replacement string; +//! 7. `lower` lowercases the final slug. +//! +//! The second argument mirrors npm slugify's overloads: a plain string +//! (the replacement) or an options object `{ replacement, lower, +//! strict, trim }`. It crosses the FFI as raw NaN-box bits (i64) so the +//! runtime can distinguish string / object / undefined — passing it as +//! a coerced string is what garbled `slugify(s, { lower: true })` into +//! `hello{world` (the JSON-stringified object's first char `{` became +//! the separator). -use perry_runtime::{js_string_from_bytes, StringHeader}; +use perry_runtime::{js_string_from_bytes, JSValue, ObjectHeader, StringHeader}; /// Helper to extract string from StringHeader pointer unsafe fn string_from_header(ptr: *const StringHeader) -> Option { @@ -16,86 +34,258 @@ unsafe fn string_from_header(ptr: *const StringHeader) -> Option { std::str::from_utf8(bytes).ok().map(|s| s.to_string()) } -/// Character replacement map for common accented characters -fn replace_accents(c: char) -> Option { - match c { - 'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' => Some('a'), - 'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' => Some('a'), - 'é' | 'è' | 'ê' | 'ë' => Some('e'), - 'É' | 'È' | 'Ê' | 'Ë' => Some('e'), - 'í' | 'ì' | 'î' | 'ï' => Some('i'), - 'Í' | 'Ì' | 'Î' | 'Ï' => Some('i'), - 'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ø' => Some('o'), - 'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Õ' | 'Ø' => Some('o'), - 'ú' | 'ù' | 'û' | 'ü' => Some('u'), - 'Ú' | 'Ù' | 'Û' | 'Ü' => Some('u'), - 'ý' | 'ÿ' => Some('y'), - 'Ý' | 'Ÿ' => Some('y'), - 'ñ' => Some('n'), - 'Ñ' => Some('n'), - 'ç' => Some('c'), - 'Ç' => Some('c'), - 'ß' => Some('s'), - 'æ' => Some('a'), - 'Æ' => Some('a'), - 'œ' => Some('o'), - 'Œ' => Some('o'), - 'ð' => Some('d'), - 'Ð' => Some('d'), - 'þ' => Some('t'), - 'Þ' => Some('t'), - _ => None, +/// Subset of npm slugify's charMap. Case-preserving, may expand to +/// multiple chars ('ß' → "ss", '&' → "and") — exactly like the npm map. +pub(crate) fn char_map(c: char) -> Option<&'static str> { + Some(match c { + 'À' => "A", + 'Á' => "A", + 'Â' => "A", + 'Ã' => "A", + 'Ä' => "A", + 'Å' => "A", + 'Æ' => "AE", + 'Ç' => "C", + 'È' => "E", + 'É' => "E", + 'Ê' => "E", + 'Ë' => "E", + 'Ì' => "I", + 'Í' => "I", + 'Î' => "I", + 'Ï' => "I", + 'Ð' => "D", + 'Ñ' => "N", + 'Ò' => "O", + 'Ó' => "O", + 'Ô' => "O", + 'Õ' => "O", + 'Ö' => "O", + 'Ø' => "O", + 'Ù' => "U", + 'Ú' => "U", + 'Û' => "U", + 'Ü' => "U", + 'Ý' => "Y", + 'Þ' => "TH", + 'ß' => "ss", + 'à' => "a", + 'á' => "a", + 'â' => "a", + 'ã' => "a", + 'ä' => "a", + 'å' => "a", + 'æ' => "ae", + 'ç' => "c", + 'è' => "e", + 'é' => "e", + 'ê' => "e", + 'ë' => "e", + 'ì' => "i", + 'í' => "i", + 'î' => "i", + 'ï' => "i", + 'ð' => "d", + 'ñ' => "n", + 'ò' => "o", + 'ó' => "o", + 'ô' => "o", + 'õ' => "o", + 'ö' => "o", + 'ø' => "o", + 'ù' => "u", + 'ú' => "u", + 'û' => "u", + 'ü' => "u", + 'ý' => "y", + 'þ' => "th", + 'ÿ' => "y", + 'Ÿ' => "Y", + 'Œ' => "OE", + 'œ' => "oe", + '&' => "and", + '|' => "or", + '<' => "less", + '>' => "greater", + '©' => "(c)", + '®' => "(r)", + '™' => "tm", + _ => return None, + }) +} + +/// JS `\s` (non-unicode regex flag): ASCII whitespace + the Unicode +/// space separators the npm regexes match. +pub(crate) fn js_space_char(c: char) -> bool { + matches!( + c, + ' ' | '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +fn default_keep(c: char) -> bool { + // JS `[\w\s$*_+~.()'"!\-:@]` with the default (ASCII) `\w`. + c.is_ascii_alphanumeric() + || c == '_' + || js_space_char(c) + || matches!( + c, + '$' | '*' | '+' | '~' | '.' | '(' | ')' | '\'' | '"' | '!' | '-' | ':' | '@' + ) +} + +/// Parsed slugify options (mirrors npm's option surface; `remove` and +/// `locale` are not supported). +pub(crate) struct SlugifyOptions { + /// The replacement string for whitespace runs. npm defaults it to + /// "-" BEFORE the per-char `appendChar === replacement` check, so a + /// literal '-' in the input collapses with adjacent whitespace even + /// when no replacement was supplied (slugify('a - b') === 'a-b'). + pub replacement: String, + pub lower: bool, + pub strict: bool, + pub trim: bool, +} + +impl Default for SlugifyOptions { + fn default() -> Self { + SlugifyOptions { + replacement: "-".to_string(), + lower: false, + strict: false, + trim: true, + } + } +} + +/// Core algorithm shared by every entry point. Mirrors npm slugify's +/// reduce + post-passes ordering exactly. +pub(crate) fn slugify_npm(input: &str, opts: &SlugifyOptions) -> String { + let mut slug = String::with_capacity(input.len()); + let mut buf = [0u8; 4]; + for ch in input.chars() { + let mapped: &str = match char_map(ch) { + Some(m) => m, + None => ch.encode_utf8(&mut buf), + }; + // `if (appendChar === replacement) appendChar = ' '` — with the + // already-defaulted replacement. + let effective: &str = if mapped == opts.replacement { + " " + } else { + mapped + }; + for c in effective.chars() { + if default_keep(c) { + slug.push(c); + } + } + } + + if opts.strict { + slug.retain(|c| c.is_ascii_alphanumeric() || js_space_char(c)); + } + if opts.trim { + slug = slug.trim_matches(js_space_char).to_string(); + } + + // replace(/\s+/g, replacement) + let mut out = String::with_capacity(slug.len()); + let mut in_ws = false; + for c in slug.chars() { + if js_space_char(c) { + if !in_ws { + out.push_str(&opts.replacement); + in_ws = true; + } + } else { + out.push(c); + in_ws = false; + } + } + + if opts.lower { + out.to_lowercase() + } else { + out } } +/// Decode the second slugify argument from raw NaN-box bits: +/// string → `{ replacement }`, object → `{ replacement, lower, strict, +/// trim }`, anything else → defaults. +unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { + let mut opts = SlugifyOptions::default(); + let value = f64::from_bits(options_bits as u64); + let jv = JSValue::from_bits(options_bits as u64); + + if jv.is_any_string() { + let ptr = perry_runtime::js_get_string_pointer_unified(value) as *const StringHeader; + if let Some(s) = string_from_header(ptr) { + opts.replacement = s; + } + return opts; + } + + if !jv.is_pointer() { + return opts; + } + let obj = jv.as_pointer::(); + if obj.is_null() || (obj as usize) < 0x1000 { + return opts; + } + + let field = |name: &[u8]| -> f64 { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + perry_runtime::object::js_object_get_field_by_name_f64(obj, key) + }; + + let replacement = field(b"replacement"); + if JSValue::from_bits(replacement.to_bits()).is_any_string() { + let ptr = perry_runtime::js_get_string_pointer_unified(replacement) as *const StringHeader; + if let Some(s) = string_from_header(ptr) { + opts.replacement = s; + } + } + opts.lower = perry_runtime::value::js_is_truthy(field(b"lower")) != 0; + opts.strict = perry_runtime::value::js_is_truthy(field(b"strict")) != 0; + // npm: `if (options.trim !== false) slug = slug.trim()` — absent + // (undefined) means trim. + let trim = field(b"trim"); + let trim_jv = JSValue::from_bits(trim.to_bits()); + opts.trim = trim_jv.is_undefined() || perry_runtime::value::js_is_truthy(trim) != 0; + opts +} + /// Convert a string to a URL-friendly slug /// slugify(string) -> string #[no_mangle] pub unsafe extern "C" fn js_slugify(input_ptr: *const StringHeader) -> *mut StringHeader { - js_slugify_with_options(input_ptr, std::ptr::null(), std::ptr::null()) + js_slugify_with_options(input_ptr, perry_runtime::JSValue::undefined().bits() as i64) } -/// Convert a string to a URL-friendly slug with options -/// slugify(string, { replacement, lower }) -> string +/// Convert a string to a URL-friendly slug with options. +/// `options_bits` carries the second JS argument as raw NaN-box bits: +/// `slugify(s, '_')` (string replacement) and +/// `slugify(s, { replacement, lower, strict, trim })` both route here. #[no_mangle] pub unsafe extern "C" fn js_slugify_with_options( input_ptr: *const StringHeader, - replacement_ptr: *const StringHeader, - _options_ptr: *const StringHeader, // Reserved for future options + options_bits: i64, ) -> *mut StringHeader { let input = match string_from_header(input_ptr) { Some(s) => s, None => return std::ptr::null_mut(), }; - - let replacement = string_from_header(replacement_ptr).unwrap_or_else(|| "-".to_string()); - let replacement_char = replacement.chars().next().unwrap_or('-'); - - let mut result = String::with_capacity(input.len()); - let mut last_was_separator = true; // Start true to trim leading separators - - for c in input.chars() { - // Check for accent replacement first - let c = replace_accents(c).unwrap_or(c); - - if c.is_ascii_alphanumeric() { - result.push(c.to_ascii_lowercase()); - last_was_separator = false; - } else if c.is_whitespace() || c == '_' || c == '-' || c == '/' || c == '\\' { - // Replace whitespace and common separators - if !last_was_separator { - result.push(replacement_char); - last_was_separator = true; - } - } - // Other characters are stripped - } - - // Remove trailing separator - if result.ends_with(replacement_char) { - result.pop(); - } - + let opts = options_from_bits(options_bits); + let result = slugify_npm(&input, &opts); js_string_from_bytes(result.as_ptr(), result.len() as u32) } @@ -107,25 +297,86 @@ pub unsafe extern "C" fn js_slugify_strict(input_ptr: *const StringHeader) -> *m Some(s) => s, None => return std::ptr::null_mut(), }; + let opts = SlugifyOptions { + strict: true, + ..SlugifyOptions::default() + }; + let result = slugify_npm(&input, &opts); + js_string_from_bytes(result.as_ptr(), result.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; - let mut result = String::with_capacity(input.len()); - let mut last_was_separator = true; + #[test] + fn default_preserves_case_like_npm() { + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("Hello World", &opts), "Hello-World"); + } - for c in input.chars() { - let c = replace_accents(c).unwrap_or(c); + #[test] + fn lower_option_lowercases() { + let opts = SlugifyOptions { + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("Hello World", &opts), "hello-world"); + } - if c.is_ascii_alphanumeric() { - result.push(c.to_ascii_lowercase()); - last_was_separator = false; - } else if !last_was_separator { - result.push('-'); - last_was_separator = true; - } + #[test] + fn string_replacement_is_full_string() { + let opts = SlugifyOptions { + replacement: "_".into(), + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("foo bar baz", &opts), "foo_bar_baz"); } - if result.ends_with('-') { - result.pop(); + #[test] + fn strict_removes_non_alnum() { + let opts = SlugifyOptions { + strict: true, + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!( + slugify_npm("Hello, World! (2024)", &opts), + "hello-world-2024" + ); } - js_string_from_bytes(result.as_ptr(), result.len() as u32) + #[test] + fn accents_fold_case_preserving() { + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("Crème Brûlée", &opts), "Creme-Brulee"); + } + + #[test] + fn ampersand_maps_to_and() { + let opts = SlugifyOptions { + lower: true, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm("Foo & Bar", &opts), "foo-and-bar"); + } + + #[test] + fn default_dash_collapses_with_whitespace() { + // npm defaults the replacement to '-' BEFORE the per-char + // compare, so a literal '-' merges with adjacent whitespace. + let opts = SlugifyOptions::default(); + assert_eq!(slugify_npm("a - b", &opts), "a-b"); + assert_eq!(slugify_npm("a-b", &opts), "a-b"); + assert_eq!(slugify_npm("foo_bar-baz", &opts), "foo_bar-baz"); + } + + #[test] + fn trim_false_keeps_edges_as_separators() { + let opts = SlugifyOptions { + trim: false, + ..SlugifyOptions::default() + }; + assert_eq!(slugify_npm(" foo bar ", &opts), "-foo-bar-"); + } } diff --git a/test-files/test_gap_slugify_options.ts b/test-files/test_gap_slugify_options.ts new file mode 100644 index 0000000000..9d552cee76 --- /dev/null +++ b/test-files/test_gap_slugify_options.ts @@ -0,0 +1,32 @@ +// Gap test: slugify's second argument is a replacement string OR an +// options object ({ replacement, lower, strict, trim }). The options +// object used to be coerced through the string path, garbling +// slugify("Hello World", { lower: true }) into "hello{world". +// All inputs below stay inside the accent/charMap subset the native +// binding supports, and outputs are fully deterministic. + +import slugify from "slugify"; + +// Positional-string form (kept working). +console.log(slugify("Hello World")); +console.log(slugify("Hello World", "_")); + +// Options-object forms. +console.log(slugify("Hello World", { lower: true })); +console.log(slugify("Hello World", { replacement: "_" })); +console.log(slugify("Hello World", { replacement: "__", lower: true })); + +// npm semantics: default keeps case; '!' is in the keep-set; ',' is not. +console.log(slugify("Crème Brûlée!", { lower: true })); +console.log(slugify("Hello, World! (2024)", { lower: true, strict: true })); + +// charMap expansion: '&' -> 'and'. +console.log(slugify("Foo & Bar", { lower: true })); + +// trim (default true) + custom replacement. +console.log(slugify(" padded input ", { replacement: "_" })); + +// '-' and '_' are kept literally; only whitespace runs collapse. +console.log(slugify("a - b")); +console.log(slugify("foo_bar-baz")); +console.log(slugify("UPPER Case Kept")); From 16edd0d31e6247587e74253d9eafba7c09dfeb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:22:23 +0200 Subject: [PATCH 2/9] fix(exponential-backoff): honor options and retry on rejection in the ext wrapper The perry-ext-exponential-backoff copy (the one the default well-known flip links) parsed nothing: it hardcoded 3 attempts / 100 ms / x2 / 10 s (its own comment admitted the TODO), blocked the calling thread with thread::sleep between retries, and returned a promise-returning task's FIRST promise directly - so async tasks were never retried at all. perry-stdlib's copy was fixed in #4917 but never ported. Port the stdlib implementation onto perry-ffi plus C-ABI runtime symbols (js_promise_new/resolve/reject/then, js_is_promise, js_closure_* and js_set_timeout_value_ref, declared extern - the perry-ext-events pattern): - Options parsed with npm names and defaults: numOfAttempts (10), startingDelay (100 ms), timeMultiple (2), maxDelay (uncapped), delayFirstAttempt (false), jitter ('none' default, 'full' honored), retry(e, attemptNumber) predicate. - Promise-returning tasks retry on rejection via promise reactions; retries wait startingDelay * timeMultiple^n ms (capped at maxDelay) through the runtime timer queue - no main-thread sleeps. - Task / outer-promise / retry-callback NaN-box bits are GC-rooted via a mutable root scanner for the life of each in-flight call. js_backoff_simple keeps its exported signature and sync behavior. --- .../perry-ext-exponential-backoff/src/lib.rs | 501 +++++++++++++++--- test-files/test_gap_backoff_options.ts | 66 +++ 2 files changed, 481 insertions(+), 86 deletions(-) create mode 100644 test-files/test_gap_backoff_options.ts diff --git a/crates/perry-ext-exponential-backoff/src/lib.rs b/crates/perry-ext-exponential-backoff/src/lib.rs index af52bdce44..f96c0bb751 100644 --- a/crates/perry-ext-exponential-backoff/src/lib.rs +++ b/crates/perry-ext-exponential-backoff/src/lib.rs @@ -1,133 +1,423 @@ //! Native bindings for the npm `exponential-backoff` package. //! -//! Acceptance test for perry-ffi's closure invocation surface -//! (`JsClosure::call0`). Functionally identical to -//! `crates/perry-stdlib/src/exponential_backoff.rs`. +//! Port of `crates/perry-stdlib/src/exponential_backoff.rs` (#4917) onto +//! the perry-ffi surface plus a handful of C-ABI runtime symbols +//! (declared below; resolved at final link — same pattern as +//! perry-ext-events' by-name field reads). +//! +//! `backOff(task, options?)` honors the package's real option surface: +//! `numOfAttempts` (default 10), `startingDelay` (100 ms), +//! `timeMultiple` (x2), `maxDelay` (uncapped), `delayFirstAttempt` +//! (false), `jitter: 'full'` (default `'none'`), and the +//! `retry(e, attemptNumber)` predicate. Promise-returning tasks retry +//! on **rejection** via promise reactions chained through the timer +//! queue — no blocking `thread::sleep` on the main thread. +//! +//! The previous version of this wrapper parsed nothing and hardcoded +//! 3 attempts / 100 ms / x2 / 10 s, retried only on raw-NaN results, +//! and returned a promise-returning task's first promise directly +//! (no retry at all) — none of which matches the npm package. -use perry_ffi::{JsClosure, JsPromise, JsValue, ObjectHeader, Promise, RawClosureHeader}; -use std::thread; -use std::time::Duration; +use perry_ffi::{JsValue, ObjectHeader, Promise, RawClosureHeader, StringHeader}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, Once}; -const POINTER_TAG_HIGH: u64 = 0x7FFD; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const NANBOX_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; -/// True when the result the user closure returned is "real" — a -/// regular number, a NaN-boxed pointer / int32 / string / bool / -/// null. Only raw IEEE NaN (no perry tag bits set) signals -/// "treat as failure, retry". +extern "C" { + // perry-runtime promise surface (C ABI). + fn js_promise_new() -> *mut Promise; + fn js_promise_resolve(promise: *mut Promise, value: f64); + fn js_promise_reject(promise: *mut Promise, reason: f64); + fn js_promise_then( + promise: *mut Promise, + on_fulfilled: *const RawClosureHeader, + on_rejected: *const RawClosureHeader, + ) -> *mut Promise; + fn js_is_promise(ptr: *mut Promise) -> i32; + // perry-runtime closure surface. + fn js_closure_call0(closure: *const RawClosureHeader) -> f64; + fn js_closure_call2(closure: *const RawClosureHeader, arg0: f64, arg1: f64) -> f64; + fn js_closure_alloc(func_ptr: *const u8, capture_count: u32) -> *mut RawClosureHeader; + fn js_closure_set_capture_f64(closure: *mut RawClosureHeader, index: u32, value: f64); + fn js_closure_get_capture_f64(closure: *const RawClosureHeader, index: u32) -> f64; + fn js_register_closure_arity(func_ptr: *const u8, arity: u32); + // perry-runtime timer queue (promise resolved after `delay_ms`). + fn js_set_timeout_value_ref(delay_ms: f64, value: f64, has_ref: i32) -> *mut Promise; + // perry-runtime object / value probes. + fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; + fn js_is_truthy(value: f64) -> i32; + fn js_get_string_pointer_unified(value: f64) -> i64; +} + +/// Check if an f64 value represents a "real" success value. NaN-boxed +/// tagged values (pointers, strings, int32, booleans, null, …) are valid +/// results; only raw IEEE NaN signals "treat as failure, retry" for +/// synchronous tasks. +#[inline] fn is_valid_result(result: f64) -> bool { if !result.is_nan() { return true; } - // Tag check: 0x7FFA..0x7FFF are perry's NaN-box tag values. let tag = result.to_bits() >> 48; tag >= 0x7FFA } -/// `backOff(fn, options?)` — call `fn`, retry on failure with -/// exponentially-increasing delays. Returns a Promise that -/// resolves with the success value or rejects after exhausting -/// retries. -/// -/// Options parsing matches perry-stdlib's existing wrapper: -/// `options_ptr` is currently unused (TODO carried over from -/// the original — `numOfAttempts`, `startingDelay`, -/// `timeMultiple`, `maxDelay` could be parsed via -/// `JsValue::is_pointer` + object-field reads in a followup). -#[no_mangle] -pub extern "C" fn backOff( - fn_ptr: *const RawClosureHeader, - _options_ptr: *const ObjectHeader, -) -> *mut Promise { - let closure = unsafe { JsClosure::from_raw(fn_ptr) }; - if closure.is_null() { - let p = JsPromise::new(); - p.reject(JsValue::from_number(f64::NAN)); - return JsPromise::new().as_raw(); // Note: original does the same odd thing — kept for parity. +fn js_undefined() -> f64 { + f64::from_bits(TAG_UNDEFINED) +} + +/// Options mirroring the npm package's `BackoffOptions` (with its +/// defaults: 10 attempts, 100 ms starting delay, x2 multiple, uncapped +/// maxDelay, no jitter, first attempt not delayed). +struct BackoffOptions { + num_of_attempts: u32, + starting_delay: f64, + time_multiple: f64, + max_delay: f64, + delay_first_attempt: bool, + jitter_full: bool, + /// NaN-box bits of the `retry` predicate closure, or 0 when absent. + retry_cb: u64, +} + +impl Default for BackoffOptions { + fn default() -> Self { + BackoffOptions { + num_of_attempts: 10, + starting_delay: 100.0, + time_multiple: 2.0, + max_delay: f64::INFINITY, + delay_first_attempt: false, + jitter_full: false, + retry_cb: 0, + } } +} - // First attempt — no delay. - let result = unsafe { closure.call0() }; +/// One in-flight `backOff()` call. `task`/`outer`/`retry_cb` hold NaN-box +/// bits and are GC-rooted by `scan_backoff_roots` for the life of the +/// entry. +struct BackoffState { + /// NaN-box bits of the task closure. + task: u64, + /// NaN-box bits (POINTER_TAG) of the outer promise returned to JS. + outer: u64, + /// Attempts completed (started and settled/failed). + attempts_done: u32, + opts: BackoffOptions, +} - // If the callback returned a Promise (POINTER_TAG-tagged - // pointer), unwrap and return it directly to avoid - // Promise-in-Promise. Same trick the original uses. - let bits = result.to_bits(); - if (bits >> 48) == POINTER_TAG_HIGH { - let ptr = (bits & POINTER_MASK) as *mut Promise; - if !ptr.is_null() { - return ptr; +static STATES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); +static GC_REGISTERED: Once = Once::new(); + +fn ensure_backoff_gc_scanner() { + GC_REGISTERED.call_once(|| { + perry_ffi::gc_register_mutable_root_scanner_named( + "perry-ext-exponential-backoff", + scan_backoff_roots, + ); + }); +} + +fn scan_backoff_roots(visitor: &mut perry_ffi::GcRootVisitor<'_>) { + if let Ok(mut states) = STATES.lock() { + for st in states.values_mut() { + let mut task = st.task as i64; + visitor.visit_i64_slot(&mut task); + st.task = task as u64; + let mut outer = st.outer as i64; + visitor.visit_i64_slot(&mut outer); + st.outer = outer as u64; + if st.opts.retry_cb != 0 { + let mut cb = st.opts.retry_cb as i64; + visitor.visit_i64_slot(&mut cb); + st.opts.retry_cb = cb as u64; + } } } +} - let promise = JsPromise::new(); - let raw = promise.as_raw(); - if is_valid_result(result) { - promise.resolve(JsValue::from_bits(bits)); - return raw; +unsafe fn option_field(ptr: *const ObjectHeader, name: &str) -> f64 { + let key = perry_ffi::alloc_string(name); + js_object_get_field_by_name_f64(ptr, key.as_raw()) +} + +fn option_number(value: f64) -> Option { + let jv = JsValue::from_bits(value.to_bits()); + if jv.is_int32() { + Some(jv.to_int32() as f64) + } else if jv.is_number() && !value.is_nan() { + Some(value) + } else { + None + } +} + +unsafe fn parse_options(options: f64) -> BackoffOptions { + let mut opts = BackoffOptions::default(); + let jv = JsValue::from_bits(options.to_bits()); + if !jv.is_pointer() { + return opts; + } + let ptr = jv.as_pointer::(); + if ptr.is_null() || (ptr as usize) < 0x1000 { + return opts; } - // Retry with exponential backoff. Defaults match the npm - // `exponential-backoff` package's defaults. - let num_of_attempts: u32 = 3; - let starting_delay: u64 = 100; - let max_delay: u64 = 10_000; - let time_multiple: f64 = 2.0; + if let Some(n) = option_number(option_field(ptr, "numOfAttempts")) { + opts.num_of_attempts = n.max(1.0) as u32; + } + if let Some(n) = option_number(option_field(ptr, "startingDelay")) { + opts.starting_delay = n.max(0.0); + } + if let Some(n) = option_number(option_field(ptr, "timeMultiple")) { + opts.time_multiple = n.max(1.0); + } + if let Some(n) = option_number(option_field(ptr, "maxDelay")) { + opts.max_delay = n.max(0.0); + } + if js_is_truthy(option_field(ptr, "delayFirstAttempt")) != 0 { + opts.delay_first_attempt = true; + } + // `jitter` is the string 'full' (anything else, including the + // default 'none', means no jitter). + let jitter = option_field(ptr, "jitter"); + if JsValue::from_bits(jitter.to_bits()).is_any_string() { + let s = js_get_string_pointer_unified(jitter) as *const StringHeader; + if !s.is_null() { + let handle = perry_ffi::JsString::from_raw(s as *mut StringHeader); + if perry_ffi::read_string(handle) == Some("full") { + opts.jitter_full = true; + } + } + } + let retry = option_field(ptr, "retry"); + let retry_bits = retry.to_bits(); + // npm's `retry` option is a function; a POINTER_TAG value here is a + // closure by contract. + if JsValue::from_bits(retry_bits).is_pointer() { + opts.retry_cb = retry_bits; + } + opts +} - // `promise` is consumed if the first-shot resolved above; in - // the retry path we re-wrap `raw` inside the loop body. - drop(promise); - let mut attempt = 1; - let mut current_delay = starting_delay; +/// Build a 1-arg promise-reaction closure capturing the backoff state id. +unsafe fn bound_reaction(func_ptr: *const u8, state_id: u64) -> *const RawClosureHeader { + js_register_closure_arity(func_ptr, 1); + let closure = js_closure_alloc(func_ptr, 1); + js_closure_set_capture_f64(closure, 0, f64::from_bits(state_id)); + closure as *const RawClosureHeader +} - loop { - attempt += 1; - if attempt > num_of_attempts { - unsafe { JsPromise::from_raw(raw) }.reject(JsValue::from_number(f64::NAN)); - return raw; +unsafe fn state_id_from_closure(closure: *const RawClosureHeader) -> u64 { + js_closure_get_capture_f64(closure, 0).to_bits() +} + +fn settle(id: u64, resolve: bool, value: f64) { + let Some(st) = STATES.lock().unwrap().remove(&id) else { + return; + }; + let outer = (st.outer & NANBOX_MASK) as *mut Promise; + unsafe { + if resolve { + js_promise_resolve(outer, value); + } else { + js_promise_reject(outer, value); } - thread::sleep(Duration::from_millis(current_delay)); - let result = unsafe { closure.call0() }; - let bits = result.to_bits(); - if (bits >> 48) == POINTER_TAG_HIGH { - let ptr = (bits & POINTER_MASK) as *mut Promise; - if !ptr.is_null() { - return ptr; - } + } +} + +/// Delay before attempt `attempts_done + 1`, mirroring the package's +/// `SkipFirstDelay` (power `attempts_done - 1`) vs `AlwaysDelay` +/// (power `attempts_done`) factories, capped at `maxDelay`, with +/// optional full jitter. +fn next_delay_ms(st: &BackoffState) -> f64 { + let power = if st.opts.delay_first_attempt { + st.attempts_done as f64 + } else { + (st.attempts_done as f64 - 1.0).max(0.0) + }; + let mut delay = st.opts.starting_delay * st.opts.time_multiple.powf(power); + if !delay.is_finite() { + delay = st.opts.max_delay; + } + delay = delay.min(st.opts.max_delay); + if st.opts.jitter_full { + // Cheap jitter source — the npm package only needs uniform-ish + // `random() * delay`, not crypto-grade randomness. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + delay *= (nanos % 1_000_000) as f64 / 1_000_000.0; + } + if delay.is_finite() { + delay.max(0.0) + } else { + 0.0 + } +} + +fn schedule_next_attempt(id: u64) { + let delay = { + let states = STATES.lock().unwrap(); + let Some(st) = states.get(&id) else { return }; + next_delay_ms(st) + }; + unsafe { + let timer = js_set_timeout_value_ref(delay, js_undefined(), 1); + js_promise_then( + timer, + bound_reaction(backoff_on_timer as *const u8, id), + std::ptr::null(), + ); + } +} + +/// A completed attempt failed with `error`. Either retry (after +/// consulting the `retry` predicate and scheduling the backoff delay) +/// or reject. +fn handle_failure(id: u64, error: f64) { + let (attempts_done, exhausted, retry_cb) = { + let mut states = STATES.lock().unwrap(); + let Some(st) = states.get_mut(&id) else { + return; + }; + st.attempts_done += 1; + ( + st.attempts_done, + st.attempts_done >= st.opts.num_of_attempts, + st.opts.retry_cb, + ) + }; + if exhausted { + settle(id, false, error); + return; + } + if retry_cb != 0 { + // npm: `const shouldRetry = await retry(e, attemptNumber)`; + // falsy stops and rethrows. (A promise-returning predicate is + // treated as truthy — not awaited.) + let cb = ((retry_cb & NANBOX_MASK) as usize) as *const RawClosureHeader; + let should_retry = unsafe { js_closure_call2(cb, error, attempts_done as f64) }; + if unsafe { js_is_truthy(should_retry) } == 0 { + settle(id, false, error); + return; } - if is_valid_result(result) { - unsafe { JsPromise::from_raw(raw) }.resolve(JsValue::from_bits(bits)); - return raw; + } + schedule_next_attempt(id); +} + +fn run_attempt(id: u64) { + let task = { + let states = STATES.lock().unwrap(); + let Some(st) = states.get(&id) else { return }; + st.task + }; + let task_ptr = ((task & NANBOX_MASK) as usize) as *const RawClosureHeader; + let result = unsafe { js_closure_call0(task_ptr) }; + + let bits = result.to_bits(); + if JsValue::from_bits(bits).is_pointer() { + let raw = (bits & NANBOX_MASK) as *mut Promise; + if !raw.is_null() && unsafe { js_is_promise(raw) } != 0 { + unsafe { + js_promise_then( + raw, + bound_reaction(backoff_on_fulfilled as *const u8, id), + bound_reaction(backoff_on_rejected as *const u8, id), + ); + } + return; } - current_delay = ((current_delay as f64) * time_multiple).min(max_delay as f64) as u64; } + if is_valid_result(result) { + settle(id, true, result); + } else { + handle_failure(id, result); + } +} + +extern "C" fn backoff_on_fulfilled(closure: *const RawClosureHeader, value: f64) -> f64 { + settle(unsafe { state_id_from_closure(closure) }, true, value); + js_undefined() } -/// `backoffSimple(fn, attempts, delayMs)` — synchronous variant -/// that returns the result f64 directly. Used by perry-stdlib's -/// callers for non-Promise-returning closures. +extern "C" fn backoff_on_rejected(closure: *const RawClosureHeader, error: f64) -> f64 { + handle_failure(unsafe { state_id_from_closure(closure) }, error); + js_undefined() +} + +extern "C" fn backoff_on_timer(closure: *const RawClosureHeader, _value: f64) -> f64 { + run_attempt(unsafe { state_id_from_closure(closure) }); + js_undefined() +} + +/// `backOff(task, options?)` — execute a task with exponential-backoff +/// retry. `fn_ptr` is the task closure (codegen `NA_PTR`: raw extracted +/// pointer); `options` is the NaN-boxed options object (codegen +/// `NA_F64`). Returns a Promise that resolves with the first successful +/// result or rejects with the last error once attempts are exhausted or +/// the `retry` predicate says stop. +#[no_mangle] +pub extern "C" fn backOff(fn_ptr: *const RawClosureHeader, options: f64) -> *mut Promise { + let promise = unsafe { js_promise_new() }; + if fn_ptr.is_null() { + unsafe { js_promise_reject(promise, f64::NAN) }; + return promise; + } + ensure_backoff_gc_scanner(); + + let opts = unsafe { parse_options(options) }; + let delay_first = opts.delay_first_attempt; + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + STATES.lock().unwrap().insert( + id, + BackoffState { + task: POINTER_TAG | (fn_ptr as u64 & NANBOX_MASK), + outer: POINTER_TAG | (promise as u64 & NANBOX_MASK), + attempts_done: 0, + opts, + }, + ); + + if delay_first { + schedule_next_attempt(id); + } else { + run_attempt(id); + } + promise +} + +/// `backoffSimple(fn, attempts, delayMs)` — synchronous variant that +/// returns the result f64 directly. Kept for symbol-surface stability. #[no_mangle] pub extern "C" fn js_backoff_simple( fn_ptr: *const RawClosureHeader, num_attempts: i32, delay_ms: i32, ) -> f64 { - let closure = unsafe { JsClosure::from_raw(fn_ptr) }; - if closure.is_null() { + if fn_ptr.is_null() { return f64::NAN; } let mut attempt = 0; let mut current_delay = delay_ms.max(10) as u64; loop { attempt += 1; - let result = unsafe { closure.call0() }; + let result = unsafe { js_closure_call0(fn_ptr) }; if is_valid_result(result) { return result; } if attempt >= num_attempts { return f64::NAN; } - thread::sleep(Duration::from_millis(current_delay)); + std::thread::sleep(std::time::Duration::from_millis(current_delay)); current_delay = (current_delay * 2).min(10_000); } } @@ -157,8 +447,47 @@ mod tests { } #[test] - fn null_closure_returns_nan() { - let r = js_backoff_simple(std::ptr::null(), 5, 10); - assert!(r.is_nan()); + fn defaults_match_npm_package() { + let opts = BackoffOptions::default(); + assert_eq!(opts.num_of_attempts, 10); + assert_eq!(opts.starting_delay, 100.0); + assert_eq!(opts.time_multiple, 2.0); + assert_eq!(opts.max_delay, f64::INFINITY); + assert!(!opts.delay_first_attempt); + assert!(!opts.jitter_full); + } + + #[test] + fn delay_progression_honors_options() { + let mk = |attempts_done: u32, opts: BackoffOptions| BackoffState { + task: 0, + outer: 0, + attempts_done, + opts, + }; + // startingDelay 50, x3, cap 200: delays 50, 150, 200, 200… + let opts = || BackoffOptions { + starting_delay: 50.0, + time_multiple: 3.0, + max_delay: 200.0, + ..BackoffOptions::default() + }; + assert_eq!(next_delay_ms(&mk(1, opts())), 50.0); + assert_eq!(next_delay_ms(&mk(2, opts())), 150.0); + assert_eq!(next_delay_ms(&mk(3, opts())), 200.0); + // delayFirstAttempt shifts the power by one. + let dfa = BackoffOptions { + delay_first_attempt: true, + ..opts() + }; + assert_eq!( + next_delay_ms(&BackoffState { + task: 0, + outer: 0, + attempts_done: 0, + opts: dfa + }), + 50.0 + ); } } diff --git a/test-files/test_gap_backoff_options.ts b/test-files/test_gap_backoff_options.ts new file mode 100644 index 0000000000..47d567205c --- /dev/null +++ b/test-files/test_gap_backoff_options.ts @@ -0,0 +1,66 @@ +// Gap test: exponential-backoff's backOff(task, options) must honor +// numOfAttempts / startingDelay / timeMultiple / maxDelay and retry a +// promise-returning task on rejection. The native binding used to +// hardcode 3 attempts / 100ms / x2 / 10s and never retried async +// tasks at all. Elapsed-time checks are expressed as booleans (no raw +// timestamps in the output) so the test is deterministic. + +import { backOff } from "exponential-backoff"; + +async function main() { + // Succeeds on the 4th attempt — requires numOfAttempts > 3 to pass. + let attempts = 0; + const t0 = Date.now(); + const result = await backOff( + async () => { + attempts++; + if (attempts < 4) { + throw new Error("flaky-" + attempts); + } + return "ok:" + attempts; + }, + { numOfAttempts: 6, startingDelay: 40, timeMultiple: 2, maxDelay: 500 } + ); + const elapsed = Date.now() - t0; + console.log(result); + console.log("attempts:", attempts); + // Delays should be ~40 + 80 + 160 = 280ms; allow generous slack both ways. + console.log("waited at least 250ms:", elapsed >= 250); + console.log("finished under 5s:", elapsed < 5000); + + // Exhausts numOfAttempts and rejects with the last error. + let attempts2 = 0; + try { + await backOff( + async () => { + attempts2++; + throw new Error("always-fails"); + }, + { numOfAttempts: 3, startingDelay: 10 } + ); + console.log("unexpected success"); + } catch (e) { + console.log("failed after attempts:", attempts2, "-", (e as Error).message); + } + + // retry predicate stops the loop early. + let attempts3 = 0; + try { + await backOff( + async () => { + attempts3++; + throw new Error("nope"); + }, + { + numOfAttempts: 10, + startingDelay: 5, + retry: (_e: unknown, attemptNumber: number) => attemptNumber < 2, + } + ); + console.log("unexpected success 2"); + } catch (_e) { + console.log("predicate stopped after:", attempts3); + } +} + +main(); From 117b2c3d03e33248956cbfd5463fec293d648b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:23:51 +0200 Subject: [PATCH 3/9] fix(dayjs): factory parses its argument (ISO string / epoch ms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dayjs('2024-01-15')` returned "now" for any argument: the dispatch rows for the factory (method "default" / "dayjs") declared args:&[] and routed to js_dayjs_now, so the user's argument was passed as a trailing f64 the runtime never read. - New runtime entry point `js_dayjs_factory(value_bits)` (perry-stdlib + perry-ext-dayjs): takes the raw NaN-boxed first argument (TAG_UNDEFINED when absent) and routes undefined -> now, string -> js_dayjs_parse, finite number -> js_dayjs_from_timestamp (epoch ms). - js_dayjs_parse additionally accepts offset-less ISO forms ("2024-01-15T10:30:00", optional fraction), which dayjs treats as local time — matches Node under TZ=UTC since this runtime is UTC-based. - Dispatch rows switch to runtime js_dayjs_factory, args &[NA_JSV]; manifest factory entries declare the optional `input: any` param. - HIR chain maps (var-decl, module-decl, expression-position, and the js_transform copy) register dayjs add/subtract/startOf/endOf results as dayjs instances so `const d2 = d.add(7, 'day'); d2.format(...)` and `d.add(7, 'day').format(...)` dispatch natively instead of falling through to undefined. --- .../perry-api-manifest/src/entries/part_2.rs | 28 +++++++++++- .../src/lower_call/native_table/dates.rs | 14 +++--- .../src/runtime_decls/stdlib_ffi/utilities.rs | 1 + crates/perry-ext-dayjs/src/lib.rs | 44 +++++++++++++++++++ .../src/destructuring/var_decl/native_new.rs | 9 ++++ .../src/js_transform/local_natives.rs | 2 + .../lower/expr_call/static_and_instance.rs | 5 +++ crates/perry-hir/src/lower/module_decl.rs | 9 ++++ crates/perry-stdlib/src/dayjs.rs | 40 +++++++++++++++++ test-files/test_gap_dayjs_factory_arg.ts | 32 ++++++++++++++ 10 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 test-files/test_gap_dayjs_factory_arg.ts diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index 5c0e992d68..6324798edc 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -175,8 +175,32 @@ pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ &[p_any("p0"), p_any("p1")], TypeSpec::Number, ), - method_sig("dayjs", "default", false, None, &[], TypeSpec::Any), - method_sig("dayjs", "dayjs", false, None, &[], TypeSpec::Any), + // Factory takes an optional input (string | number | undefined) — + // matches the 1-slot NA_JSV dispatch row (js_dayjs_factory). + method_sig( + "dayjs", + "default", + false, + None, + &[ParamSpec::Named { + name: "input", + ty: TypeSpec::Any, + optional: true, + }], + TypeSpec::Any, + ), + method_sig( + "dayjs", + "dayjs", + false, + None, + &[ParamSpec::Named { + name: "input", + ty: TypeSpec::Any, + optional: true, + }], + TypeSpec::Any, + ), method("dayjs", "format", true, None), method("dayjs", "year", true, None), method("dayjs", "month", true, None), diff --git a/crates/perry-codegen/src/lower_call/native_table/dates.rs b/crates/perry-codegen/src/lower_call/native_table/dates.rs index 3942c316cf..7ea281b464 100644 --- a/crates/perry-codegen/src/lower_call/native_table/dates.rs +++ b/crates/perry-codegen/src/lower_call/native_table/dates.rs @@ -4,17 +4,21 @@ pub(super) const DATES_ROWS: &[NativeModSig] = &[ // ========== dayjs ========== // Factory: `import dayjs from 'dayjs'; dayjs()` → method:"default". // Named import: `import { dayjs } from 'dayjs'; dayjs()` → method:"dayjs". + // The factory takes the raw NaN-boxed first arg (NA_JSV; pads to + // TAG_UNDEFINED when absent): undefined → now, string → ISO parse, + // number → epoch ms. The old rows called js_dayjs_now with args:&[] + // so `dayjs('2024-01-15')` silently ignored its argument (the extra + // arg was passed as a trailing f64 the runtime never read). // Instance methods: handle is a small i64 stored in f64 bits; unbox_to_i64 // does bitcast+mask which is identity for small values, so has_receiver:true works. // dayjs handle args (isBefore/isAfter/diff) use NA_JSV (bitcast, no mask). - // Note: moment instance methods use f64 handle ABI so cannot use this path. NativeModSig { module: "dayjs", has_receiver: false, method: "default", class_filter: None, - runtime: "js_dayjs_now", - args: &[], + runtime: "js_dayjs_factory", + args: &[NA_JSV], ret: NR_F64, }, NativeModSig { @@ -22,8 +26,8 @@ pub(super) const DATES_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "dayjs", class_filter: None, - runtime: "js_dayjs_now", - args: &[], + runtime: "js_dayjs_factory", + args: &[NA_JSV], ret: NR_F64, }, NativeModSig { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs index 0c6419eed2..6a6319408b 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs @@ -63,6 +63,7 @@ pub(crate) fn declare_utilities(module: &mut LlModule) { module.declare_function("js_dayjs_day", DOUBLE, &[I64]); module.declare_function("js_dayjs_diff", DOUBLE, &[I64, I64, I64]); module.declare_function("js_dayjs_end_of", DOUBLE, &[I64, I64]); + module.declare_function("js_dayjs_factory", DOUBLE, &[I64]); module.declare_function("js_dayjs_format", I64, &[I64, I64]); module.declare_function("js_dayjs_from_timestamp", DOUBLE, &[DOUBLE]); module.declare_function("js_dayjs_hour", DOUBLE, &[I64]); diff --git a/crates/perry-ext-dayjs/src/lib.rs b/crates/perry-ext-dayjs/src/lib.rs index b0a550802a..e5d0836863 100644 --- a/crates/perry-ext-dayjs/src/lib.rs +++ b/crates/perry-ext-dayjs/src/lib.rs @@ -69,9 +69,53 @@ pub unsafe extern "C" fn js_dayjs_parse(date_str_ptr: *const StringHeader) -> f6 let dt = Utc.from_utc_datetime(&naive); return handle_to_f64(register_handle(DayjsHandle::new(dt))); } + // Offset-less ISO forms ("2024-01-15T10:30:00", optional fraction) — + // dayjs treats these as local time; this runtime is UTC-based so they + // match Node under TZ=UTC. + for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { + if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, fmt) { + let dt = Utc.from_utc_datetime(&naive); + return handle_to_f64(register_handle(DayjsHandle::new(dt))); + } + } 0.0 } +extern "C" { + /// perry-runtime: extract the StringHeader pointer from any + /// string-tagged NaN-boxed value (materializes short strings). + fn js_get_string_pointer_unified(value: f64) -> i64; +} + +/// dayjs(input?) — the factory with its real argument surface. +/// `value_bits` is the raw NaN-boxed first argument (TAG_UNDEFINED when +/// absent). undefined → now; string → ISO parse; number → epoch ms. The +/// dispatch row previously called `js_dayjs_now` with no args, silently +/// ignoring `dayjs('2024-01-15')`'s argument. +/// +/// # Safety +/// `value_bits` must be valid NaN-box bits. +#[no_mangle] +pub unsafe extern "C" fn js_dayjs_factory(value_bits: i64) -> f64 { + use perry_ffi::JsValue; + let bits = value_bits as u64; + let jv = JsValue::from_bits(bits); + if jv.is_any_string() { + let ptr = js_get_string_pointer_unified(f64::from_bits(bits)) as *const StringHeader; + return js_dayjs_parse(ptr); + } + if jv.is_int32() { + return js_dayjs_from_timestamp(jv.to_int32() as f64); + } + if jv.is_number() { + let n = f64::from_bits(bits); + if n.is_finite() { + return js_dayjs_from_timestamp(n); + } + } + js_dayjs_now() +} + /// # Safety /// `pattern_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 115514a10b..24a24bc52f 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -532,6 +532,15 @@ pub(crate) fn register_native_from_new_and_calls( ("better-sqlite3", "prepare") => Some("Statement"), ("sqlite", "prepare") => Some("StatementSync"), ("sqlite", "createSession") => Some("Session"), + // dayjs manipulation methods return a NEW + // date handle — without re-registering the + // binding, `const d2 = d.add(7, 'day'); + // d2.format(...)` fell to generic dispatch + // (undefined). "App" matches the factory- + // result registration class. + ("dayjs", "add" | "subtract" | "startOf" | "endOf") => { + Some("App") + } _ => None, }; if let Some(class_name) = returns_handle { diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index df4bf51fa5..1e2b74977d 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -661,6 +661,8 @@ pub fn chained_native_class(module: &str, prior_method: &str) -> Option<&'static ("mysql2", "getConnection") | ("mysql2/promise", "getConnection") => Some("PoolConnection"), ("pg", "connect") => Some("PoolClient"), ("ioredis", "duplicate") => Some("Redis"), + // dayjs manipulation methods return a NEW date handle. + ("dayjs", "add" | "subtract" | "startOf" | "endOf") => Some("App"), _ => None, } } diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index a2e7b5e686..a02c4330ae 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -543,6 +543,11 @@ pub(super) fn try_static_method_and_instance( } ("pg", "connect") => Some("PoolClient"), ("ioredis", "duplicate") => Some("Redis"), + // dayjs manipulation methods return a NEW date + // handle — lets `d.add(7, 'day').format(...)` + // dispatch against the result. "App" matches the + // factory-result registration class. + ("dayjs", "add" | "subtract" | "startOf" | "endOf") => Some("App"), _ => None, }; if let Some(result_class) = chained_class { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index c0f19f0739..e5c0d963e0 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1070,6 +1070,15 @@ pub(crate) fn lower_module_decl( ("sqlite", "createSession") => { Some("Session") } + // dayjs manipulation + // methods return a NEW + // date handle (see + // native_new.rs). + ( + "dayjs", + "add" | "subtract" | "startOf" + | "endOf", + ) => Some("App"), _ => None, }; if let Some(class_name) = returns_handle { diff --git a/crates/perry-stdlib/src/dayjs.rs b/crates/perry-stdlib/src/dayjs.rs index a400f6af96..701e63e229 100644 --- a/crates/perry-stdlib/src/dayjs.rs +++ b/crates/perry-stdlib/src/dayjs.rs @@ -98,9 +98,49 @@ pub unsafe extern "C" fn js_dayjs_parse(date_str_ptr: *const StringHeader) -> f6 return handle_to_f64(handle); } + // Offset-less ISO forms ("2024-01-15T10:30:00", optional fraction) — + // dayjs treats these as local time; Perry's date runtime is UTC-based + // so they match Node under TZ=UTC. + for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { + if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, fmt) { + let dt = Utc.from_utc_datetime(&naive); + let handle = register_handle(DayjsHandle::new(dt)); + return handle_to_f64(handle); + } + } + 0.0 // Invalid date string } +/// dayjs(input?) -> Dayjs +/// +/// The dayjs factory with its real argument surface: `value_bits` is the +/// raw NaN-boxed first argument (TAG_UNDEFINED when absent). undefined → +/// now; string → ISO parse; number → epoch milliseconds. The dispatch +/// row previously called `js_dayjs_now` with no args, silently ignoring +/// `dayjs('2024-01-15')`'s argument. +#[no_mangle] +pub unsafe extern "C" fn js_dayjs_factory(value_bits: i64) -> f64 { + use perry_runtime::JSValue; + let bits = value_bits as u64; + let jv = JSValue::from_bits(bits); + if jv.is_any_string() { + let ptr = perry_runtime::js_get_string_pointer_unified(f64::from_bits(bits)) + as *const StringHeader; + return js_dayjs_parse(ptr); + } + if jv.is_int32() { + return js_dayjs_from_timestamp(jv.as_int32() as f64); + } + if jv.is_number() { + let n = f64::from_bits(bits); + if n.is_finite() { + return js_dayjs_from_timestamp(n); + } + } + js_dayjs_now() +} + /// dayjs.format(pattern) -> string /// /// Format a date according to the given pattern. diff --git a/test-files/test_gap_dayjs_factory_arg.ts b/test-files/test_gap_dayjs_factory_arg.ts new file mode 100644 index 0000000000..c7c92084f7 --- /dev/null +++ b/test-files/test_gap_dayjs_factory_arg.ts @@ -0,0 +1,32 @@ +// Gap test: the dayjs factory must parse its argument (ISO strings and +// epoch milliseconds) instead of always returning "now". Run with +// TZ=UTC on both sides — dayjs treats offset-less inputs as local time +// and Perry's date runtime is UTC-based. + +import dayjs from "dayjs"; + +// Bare date string. +const d = dayjs("2024-01-15"); +console.log(d.format("YYYY-MM-DD")); +console.log(d.year(), d.month(), d.date(), d.day()); +console.log(d.valueOf()); + +// Offset-less ISO datetime. +const dt = dayjs("2024-03-05T06:07:08"); +console.log(dt.format("YYYY-MM-DD HH:mm:ss")); +console.log(dt.hour(), dt.minute(), dt.second()); + +// Epoch milliseconds. +const epoch = dayjs(1700000000000); +console.log(epoch.valueOf()); +console.log(epoch.format("YYYY-MM-DD HH:mm:ss")); + +// Arithmetic on parsed dates (dayjs is immutable — no clone needed). +const plus = d.add(7, "day"); +console.log(plus.format("YYYY-MM-DD")); +const minus = dt.subtract(2, "hour"); +console.log(minus.format("YYYY-MM-DD HH:mm:ss")); + +// Comparisons anchored on parsed values. +console.log(d.isBefore(plus) ? "before" : "not-before"); +console.log(plus.diff(d, "day")); From 28b6ffe6448be661e6468fae99aab9a399973901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:24:50 +0200 Subject: [PATCH 4/9] fix(moment): wire instance methods to the date runtime (was factory-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `moment()` was dispatched; `.format()` (and every other instance method) returned undefined. The blocker was an ABI mismatch: the moment runtime methods took the handle as `handle: f64` (in a float register), while NATIVE_MODULE_TABLE's has_receiver dispatch passes the receiver as an i64 first argument — so no rows could be added (the old dates.rs comment documented exactly this). - Convert the moment runtime (perry-stdlib/src/moment.rs + perry-ext-moment) to the dayjs handle scheme: the JS value is f64::from_bits(handle); instance methods take `handle: i64` (identical to the receiver unbox), other-moment args arrive as raw NA_JSV bits which equal the handle. - New `js_moment_factory(value_bits)` mirrors js_dayjs_factory: undefined -> now, string -> parse, finite number -> epoch ms. The bare-date parse path went through NaiveDateTime with a date-only format (always errors), silently making moment('2024-01-15') invalid — parse via NaiveDate + midnight, plus offset-less ISO and fractional-seconds forms. - Add dispatch rows for format/toISOString/valueOf/unix/year/month/ date/day/hour/minute/second/millisecond/add/subtract/startOf/endOf/ diff/isBefore/isAfter/isSame/isBetween/isValid/clone/fromNow/toDate, mirroring the dayjs rows. Predicates return NaN-boxed TAG_TRUE/ TAG_FALSE from the runtime, so they pass through as NR_F64 (NR_BOOL's fcmp would misread the NaN-boxed true). - runtime_decls declarations updated to the new ABI (the old list mixed I64/DOUBLE returns inconsistently with the actual symbols); manifest gains the moment factory param + instance-method entries; HIR chain maps register moment add/subtract/startOf/endOf/clone results as moment instances. --- .../perry-api-manifest/src/entries/part_2.rs | 53 +++- .../src/lower_call/native_table/dates.rs | 241 +++++++++++++++++- .../src/runtime_decls/stdlib_ffi/utilities.rs | 29 ++- crates/perry-ext-moment/src/lib.rs | 230 ++++++++++------- .../src/destructuring/var_decl/native_new.rs | 12 +- .../src/js_transform/local_natives.rs | 3 +- .../lower/expr_call/static_and_instance.rs | 7 +- crates/perry-hir/src/lower/module_decl.rs | 12 +- crates/perry-stdlib/src/moment.rs | 146 +++++------ test-files/test_gap_moment_methods.ts | 41 +++ 10 files changed, 587 insertions(+), 187 deletions(-) create mode 100644 test-files/test_gap_moment_methods.ts diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index 6324798edc..e5e2568b99 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -223,8 +223,57 @@ pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ method("dayjs", "isValid", true, None), method("dayjs", "diff", true, None), method("dayjs", "clone", true, None), - method_sig("moment", "default", false, None, &[], TypeSpec::Any), - method_sig("moment", "moment", false, None, &[], TypeSpec::Any), + // moment factory + instance methods (wired to the same handle-based + // date runtime as dayjs; see native_table/dates.rs moment rows). + method_sig( + "moment", + "default", + false, + None, + &[ParamSpec::Named { + name: "input", + ty: TypeSpec::Any, + optional: true, + }], + TypeSpec::Any, + ), + method_sig( + "moment", + "moment", + false, + None, + &[ParamSpec::Named { + name: "input", + ty: TypeSpec::Any, + optional: true, + }], + TypeSpec::Any, + ), + method("moment", "format", true, None), + method("moment", "toISOString", true, None), + method("moment", "valueOf", true, None), + method("moment", "unix", true, None), + method("moment", "year", true, None), + method("moment", "month", true, None), + method("moment", "date", true, None), + method("moment", "day", true, None), + method("moment", "hour", true, None), + method("moment", "minute", true, None), + method("moment", "second", true, None), + method("moment", "millisecond", true, None), + method("moment", "add", true, None), + method("moment", "subtract", true, None), + method("moment", "startOf", true, None), + method("moment", "endOf", true, None), + method("moment", "diff", true, None), + method("moment", "isBefore", true, None), + method("moment", "isAfter", true, None), + method("moment", "isSame", true, None), + method("moment", "isBetween", true, None), + method("moment", "isValid", true, None), + method("moment", "clone", true, None), + method("moment", "fromNow", true, None), + method("moment", "toDate", true, None), method_sig( "sharp", "default", diff --git a/crates/perry-codegen/src/lower_call/native_table/dates.rs b/crates/perry-codegen/src/lower_call/native_table/dates.rs index 7ea281b464..e8d27aa7fd 100644 --- a/crates/perry-codegen/src/lower_call/native_table/dates.rs +++ b/crates/perry-codegen/src/lower_call/native_table/dates.rs @@ -344,15 +344,21 @@ pub(super) const DATES_ROWS: &[NativeModSig] = &[ ret: NR_F64, }, // ========== moment ========== - // Only factory wired: moment instance methods take f64 handle (not i64), - // incompatible with the has_receiver:true i64-first-arg dispatch ABI. + // Same handle scheme as dayjs: the factory returns + // f64::from_bits(handle) and instance methods take the handle as an + // i64 first arg (the runtime signatures were converted from the old + // `handle: f64` ABI, which was incompatible with the + // has_receiver:true i64-first-arg dispatch — that's why only the + // factory used to be wired and every instance method returned + // undefined). Other-moment args (diff/isBefore/isAfter/isSame/ + // isBetween) pass NA_JSV — the raw f64 bits ARE the handle. NativeModSig { module: "moment", has_receiver: false, method: "default", class_filter: None, - runtime: "js_moment_now", - args: &[], + runtime: "js_moment_factory", + args: &[NA_JSV], ret: NR_F64, }, NativeModSig { @@ -360,7 +366,232 @@ pub(super) const DATES_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "moment", class_filter: None, - runtime: "js_moment_now", + runtime: "js_moment_factory", + args: &[NA_JSV], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "format", + class_filter: None, + runtime: "js_moment_format", + args: &[NA_STR], + ret: NR_STR, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "toISOString", + class_filter: None, + runtime: "js_moment_to_iso_string", + args: &[], + ret: NR_STR, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "valueOf", + class_filter: None, + runtime: "js_moment_value_of", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "unix", + class_filter: None, + runtime: "js_moment_unix", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "year", + class_filter: None, + runtime: "js_moment_year", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "month", + class_filter: None, + runtime: "js_moment_month", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "date", + class_filter: None, + runtime: "js_moment_date", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "day", + class_filter: None, + runtime: "js_moment_day", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "hour", + class_filter: None, + runtime: "js_moment_hour", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "minute", + class_filter: None, + runtime: "js_moment_minute", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "second", + class_filter: None, + runtime: "js_moment_second", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "millisecond", + class_filter: None, + runtime: "js_moment_millisecond", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "add", + class_filter: None, + runtime: "js_moment_add", + args: &[NA_F64, NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "subtract", + class_filter: None, + runtime: "js_moment_subtract", + args: &[NA_F64, NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "startOf", + class_filter: None, + runtime: "js_moment_start_of", + args: &[NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "endOf", + class_filter: None, + runtime: "js_moment_end_of", + args: &[NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "diff", + class_filter: None, + runtime: "js_moment_diff", + args: &[NA_JSV, NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "isBefore", + class_filter: None, + runtime: "js_moment_is_before", + args: &[NA_JSV], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "isAfter", + class_filter: None, + runtime: "js_moment_is_after", + args: &[NA_JSV], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "isSame", + class_filter: None, + runtime: "js_moment_is_same", + args: &[NA_JSV, NA_STR], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "isBetween", + class_filter: None, + runtime: "js_moment_is_between", + args: &[NA_JSV, NA_JSV], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "isValid", + class_filter: None, + runtime: "js_moment_is_valid", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "clone", + class_filter: None, + runtime: "js_moment_clone", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "fromNow", + class_filter: None, + runtime: "js_moment_from_now", + args: &[], + ret: NR_STR, + }, + NativeModSig { + module: "moment", + has_receiver: true, + method: "toDate", + class_filter: None, + runtime: "js_moment_to_date", args: &[], ret: NR_F64, }, diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs index 6a6319408b..1b4f6c9e79 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs @@ -83,23 +83,38 @@ pub(crate) fn declare_utilities(module: &mut LlModule) { module.declare_function("js_dayjs_unix", DOUBLE, &[I64]); module.declare_function("js_dayjs_value_of", DOUBLE, &[I64]); module.declare_function("js_dayjs_year", DOUBLE, &[I64]); - module.declare_function("js_moment_add", I64, &[I64, DOUBLE, I64]); + // moment: same handle scheme as dayjs — the factory returns the + // handle as f64 bits (DOUBLE), instance methods take the handle as + // an I64 first arg. Methods returning a new moment return DOUBLE + // (f64::from_bits(handle)). Keep in lock-step with the moment rows + // in lower_call/native_table/dates.rs and the runtime signatures in + // perry-stdlib/src/moment.rs + perry-ext-moment. + module.declare_function("js_moment_add", DOUBLE, &[I64, DOUBLE, I64]); + module.declare_function("js_moment_clone", DOUBLE, &[I64]); module.declare_function("js_moment_date", DOUBLE, &[I64]); module.declare_function("js_moment_day", DOUBLE, &[I64]); module.declare_function("js_moment_diff", DOUBLE, &[I64, I64, I64]); - module.declare_function("js_moment_end_of", I64, &[I64, I64]); + module.declare_function("js_moment_end_of", DOUBLE, &[I64, I64]); + module.declare_function("js_moment_factory", DOUBLE, &[I64]); module.declare_function("js_moment_format", I64, &[I64, I64]); - module.declare_function("js_moment_from_timestamp", I64, &[DOUBLE]); + module.declare_function("js_moment_from_now", I64, &[I64]); + module.declare_function("js_moment_from_timestamp", DOUBLE, &[DOUBLE]); module.declare_function("js_moment_hour", DOUBLE, &[I64]); + module.declare_function("js_moment_is_after", DOUBLE, &[I64, I64]); + module.declare_function("js_moment_is_before", DOUBLE, &[I64, I64]); + module.declare_function("js_moment_is_between", DOUBLE, &[I64, I64, I64]); + module.declare_function("js_moment_is_same", DOUBLE, &[I64, I64, I64]); module.declare_function("js_moment_is_valid", DOUBLE, &[I64]); module.declare_function("js_moment_millisecond", DOUBLE, &[I64]); module.declare_function("js_moment_minute", DOUBLE, &[I64]); module.declare_function("js_moment_month", DOUBLE, &[I64]); - module.declare_function("js_moment_now", I64, &[]); - module.declare_function("js_moment_parse", I64, &[I64]); + module.declare_function("js_moment_now", DOUBLE, &[]); + module.declare_function("js_moment_parse", DOUBLE, &[I64]); module.declare_function("js_moment_second", DOUBLE, &[I64]); - module.declare_function("js_moment_start_of", I64, &[I64, I64]); - module.declare_function("js_moment_subtract", I64, &[I64, DOUBLE, I64]); + module.declare_function("js_moment_start_of", DOUBLE, &[I64, I64]); + module.declare_function("js_moment_subtract", DOUBLE, &[I64, DOUBLE, I64]); + module.declare_function("js_moment_to_date", DOUBLE, &[I64]); + module.declare_function("js_moment_to_iso_string", I64, &[I64]); module.declare_function("js_moment_unix", DOUBLE, &[I64]); module.declare_function("js_moment_value_of", DOUBLE, &[I64]); module.declare_function("js_moment_year", DOUBLE, &[I64]); diff --git a/crates/perry-ext-moment/src/lib.rs b/crates/perry-ext-moment/src/lib.rs index 83d619ec60..15fe1122de 100644 --- a/crates/perry-ext-moment/src/lib.rs +++ b/crates/perry-ext-moment/src/lib.rs @@ -25,16 +25,17 @@ unsafe fn read_str(ptr: *const StringHeader) -> Option { read_string(handle).map(String::from) } +/// Same handle scheme as perry-ext-dayjs: the JS-visible moment value +/// is `f64::from_bits(handle)` — a tiny denormal whose raw bits ARE the +/// handle. Instance methods receive the handle back as an i64 first arg +/// (the dispatch table's has_receiver unbox is bitcast+mask, identity +/// for small handles); other-moment args arrive as raw NA_JSV bits, +/// which equal the handle for the same reason. #[inline] fn handle_to_f64(handle: Handle) -> f64 { f64::from_bits(handle as u64) } -#[inline] -fn f64_to_handle(value: f64) -> Handle { - value.to_bits() as Handle -} - #[inline] fn js_bool(b: bool) -> f64 { if b { @@ -82,14 +83,21 @@ pub unsafe extern "C" fn js_moment_parse(date_str_ptr: *const StringHeader) -> f } }; + // Note: the bare-date form must go through NaiveDate + // (NaiveDateTime::parse_from_str("%Y-%m-%d") always errors on the + // missing time part, which silently made moment('2024-01-15') + // invalid). let datetime = date_str .parse::>() .or_else(|_| { - NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc()) + NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S%.f").map(|dt| dt.and_utc()) }) - .or_else(|_| NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d").map(|dt| dt.and_utc())) .or_else(|_| { - NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S").map(|dt| dt.and_utc()) + NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S%.f").map(|dt| dt.and_utc()) + }) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") + .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc()) }); match datetime { @@ -104,14 +112,46 @@ pub unsafe extern "C" fn js_moment_parse(date_str_ptr: *const StringHeader) -> f } } +extern "C" { + /// perry-runtime: extract the StringHeader pointer from any + /// string-tagged NaN-boxed value (materializes short strings). + fn js_get_string_pointer_unified(value: f64) -> i64; +} + +/// moment(input?) — the factory with its real argument surface. +/// `value_bits` is the raw NaN-boxed first argument (TAG_UNDEFINED when +/// absent). undefined → now; string → parse; number → epoch ms. +/// +/// # Safety +/// `value_bits` must be valid NaN-box bits. +#[no_mangle] +pub unsafe extern "C" fn js_moment_factory(value_bits: i64) -> f64 { + use perry_ffi::JsValue; + let bits = value_bits as u64; + let jv = JsValue::from_bits(bits); + if jv.is_any_string() { + let ptr = js_get_string_pointer_unified(f64::from_bits(bits)) as *const StringHeader; + return js_moment_parse(ptr); + } + if jv.is_int32() { + return js_moment_from_timestamp(jv.to_int32() as f64); + } + if jv.is_number() { + let n = f64::from_bits(bits); + if n.is_finite() { + return js_moment_from_timestamp(n); + } + } + js_moment_now() +} + /// # Safety /// `format_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_moment_format( - handle: f64, + handle: i64, format_ptr: *const StringHeader, ) -> *mut StringHeader { - let handle = f64_to_handle(handle); let format_str = read_str(format_ptr).unwrap_or_else(|| "YYYY-MM-DDTHH:mm:ssZ".to_string()); if let Some(moment) = get_handle::(handle) { @@ -140,8 +180,7 @@ pub unsafe extern "C" fn js_moment_format( } #[no_mangle] -pub extern "C" fn js_moment_to_iso_string(handle: f64) -> *mut StringHeader { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_to_iso_string(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { return alloc_string(&moment.datetime.to_rfc3339()).as_raw(); } @@ -149,80 +188,70 @@ pub extern "C" fn js_moment_to_iso_string(handle: f64) -> *mut StringHeader { } #[no_mangle] -pub extern "C" fn js_moment_value_of(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_value_of(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.timestamp_millis() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_unix(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_unix(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.timestamp() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_year(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_year(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.year() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_month(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_month(handle: i64) -> f64 { get_handle::(handle) .map(|m| (m.datetime.month() - 1) as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_date(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_date(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.day() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_day(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_day(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.weekday().num_days_from_sunday() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_hour(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_hour(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.hour() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_minute(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_minute(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.minute() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_second(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_second(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.second() as f64) .unwrap_or(0.0) } #[no_mangle] -pub extern "C" fn js_moment_millisecond(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_millisecond(handle: i64) -> f64 { get_handle::(handle) .map(|m| m.datetime.timestamp_subsec_millis() as f64) .unwrap_or(0.0) @@ -232,11 +261,11 @@ pub extern "C" fn js_moment_millisecond(handle: f64) -> f64 { /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_moment_add( - handle: f64, + handle: i64, amount: f64, unit_ptr: *const StringHeader, ) -> f64 { - let handle_v = f64_to_handle(handle); + let handle_v = handle; let unit = read_str(unit_ptr).unwrap_or_else(|| "days".to_string()); if let Some(moment) = get_handle::(handle_v) { @@ -259,14 +288,14 @@ pub unsafe extern "C" fn js_moment_add( }); return handle_to_f64(new_handle); } - handle + handle_to_f64(handle) } /// # Safety /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_moment_subtract( - handle: f64, + handle: i64, amount: f64, unit_ptr: *const StringHeader, ) -> f64 { @@ -276,8 +305,8 @@ pub unsafe extern "C" fn js_moment_subtract( /// # Safety /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] -pub unsafe extern "C" fn js_moment_start_of(handle: f64, unit_ptr: *const StringHeader) -> f64 { - let handle_v = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_start_of(handle: i64, unit_ptr: *const StringHeader) -> f64 { + let handle_v = handle; let unit = read_str(unit_ptr).unwrap_or_else(|| "day".to_string()); if let Some(moment) = get_handle::(handle_v) { @@ -304,14 +333,14 @@ pub unsafe extern "C" fn js_moment_start_of(handle: f64, unit_ptr: *const String }); return handle_to_f64(new_handle); } - handle + handle_to_f64(handle) } /// # Safety /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] -pub unsafe extern "C" fn js_moment_end_of(handle: f64, unit_ptr: *const StringHeader) -> f64 { - let handle_v = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_end_of(handle: i64, unit_ptr: *const StringHeader) -> f64 { + let handle_v = handle; let unit = read_str(unit_ptr).unwrap_or_else(|| "day".to_string()); if let Some(moment) = get_handle::(handle_v) { @@ -347,19 +376,19 @@ pub unsafe extern "C" fn js_moment_end_of(handle: f64, unit_ptr: *const StringHe }); return handle_to_f64(new_handle); } - handle + handle_to_f64(handle) } /// # Safety /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_moment_diff( - handle: f64, - other_handle: f64, + handle: i64, + other_handle: i64, unit_ptr: *const StringHeader, ) -> f64 { - let handle_v = f64_to_handle(handle); - let other_v = f64_to_handle(other_handle); + let handle_v = handle; + let other_v = other_handle; let unit = read_str(unit_ptr).unwrap_or_else(|| "milliseconds".to_string()); if let (Some(moment), Some(other)) = ( @@ -382,9 +411,7 @@ pub unsafe extern "C" fn js_moment_diff( } #[no_mangle] -pub extern "C" fn js_moment_is_before(handle: f64, other_handle: f64) -> f64 { - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); +pub extern "C" fn js_moment_is_before(handle: i64, other_handle: i64) -> f64 { if let (Some(moment), Some(other)) = ( get_handle::(handle), get_handle::(other_handle), @@ -395,9 +422,7 @@ pub extern "C" fn js_moment_is_before(handle: f64, other_handle: f64) -> f64 { } #[no_mangle] -pub extern "C" fn js_moment_is_after(handle: f64, other_handle: f64) -> f64 { - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); +pub extern "C" fn js_moment_is_after(handle: i64, other_handle: i64) -> f64 { if let (Some(moment), Some(other)) = ( get_handle::(handle), get_handle::(other_handle), @@ -411,12 +436,10 @@ pub extern "C" fn js_moment_is_after(handle: f64, other_handle: f64) -> f64 { /// `unit_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_moment_is_same( - handle: f64, - other_handle: f64, + handle: i64, + other_handle: i64, unit_ptr: *const StringHeader, ) -> f64 { - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); let unit = read_str(unit_ptr); if let (Some(moment), Some(other)) = ( @@ -456,11 +479,7 @@ pub unsafe extern "C" fn js_moment_is_same( } #[no_mangle] -pub extern "C" fn js_moment_is_between(handle: f64, start_handle: f64, end_handle: f64) -> f64 { - let handle = f64_to_handle(handle); - let start_handle = f64_to_handle(start_handle); - let end_handle = f64_to_handle(end_handle); - +pub extern "C" fn js_moment_is_between(handle: i64, start_handle: i64, end_handle: i64) -> f64 { if let (Some(moment), Some(start), Some(end)) = ( get_handle::(handle), get_handle::(start_handle), @@ -472,8 +491,7 @@ pub extern "C" fn js_moment_is_between(handle: f64, start_handle: f64, end_handl } #[no_mangle] -pub extern "C" fn js_moment_is_valid(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_is_valid(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return js_bool(moment.is_valid); } @@ -481,20 +499,19 @@ pub extern "C" fn js_moment_is_valid(handle: f64) -> f64 { } #[no_mangle] -pub extern "C" fn js_moment_clone(handle: f64) -> f64 { - let handle_v = f64_to_handle(handle); +pub extern "C" fn js_moment_clone(handle: i64) -> f64 { + let handle_v = handle; if let Some(moment) = get_handle::(handle_v) { return handle_to_f64(register_handle(MomentHandle { datetime: moment.datetime, is_valid: moment.is_valid, })); } - handle + handle_to_f64(handle) } #[no_mangle] -pub extern "C" fn js_moment_from_now(handle: f64) -> *mut StringHeader { - let handle = f64_to_handle(handle); +pub extern "C" fn js_moment_from_now(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { let now = Utc::now(); let diff = now.signed_duration_since(moment.datetime); @@ -544,7 +561,7 @@ pub extern "C" fn js_moment_from_now(handle: f64) -> *mut StringHeader { } #[no_mangle] -pub extern "C" fn js_moment_to_date(handle: f64) -> f64 { +pub extern "C" fn js_moment_to_date(handle: i64) -> f64 { js_moment_value_of(handle) } @@ -552,64 +569,99 @@ pub extern "C" fn js_moment_to_date(handle: f64) -> f64 { mod tests { use super::*; + /// JS-value f64 → i64 handle, the same bitcast the dispatch table's + /// receiver unbox performs. + fn h(v: f64) -> i64 { + v.to_bits() as i64 + } + #[test] fn now_returns_valid_handle() { let f = js_moment_now(); assert_ne!(f, 0.0); - let valid = js_moment_is_valid(f); + let valid = js_moment_is_valid(h(f)); assert_eq!(valid.to_bits(), TAG_TRUE); } #[test] fn from_timestamp_round_trip() { let ts = 1_700_000_000_000.0_f64; - let h = js_moment_from_timestamp(ts); - assert_eq!(js_moment_value_of(h), ts); + let m = js_moment_from_timestamp(ts); + assert_eq!(js_moment_value_of(h(m)), ts); } #[test] fn add_subtract_days_round_trip() { - let h = js_moment_from_timestamp(1_700_000_000_000.0); + let m = js_moment_from_timestamp(1_700_000_000_000.0); let unit = alloc_string("days"); - let h2 = unsafe { js_moment_add(h, 1.0, unit.as_raw()) }; - let h3 = unsafe { js_moment_subtract(h2, 1.0, unit.as_raw()) }; - assert_eq!(js_moment_value_of(h), js_moment_value_of(h3)); + let m2 = unsafe { js_moment_add(h(m), 1.0, unit.as_raw()) }; + let m3 = unsafe { js_moment_subtract(h(m2), 1.0, unit.as_raw()) }; + assert_eq!(js_moment_value_of(h(m)), js_moment_value_of(h(m3))); } #[test] fn comparison_predicates() { let earlier = js_moment_from_timestamp(1_000_000_000_000.0); let later = js_moment_from_timestamp(2_000_000_000_000.0); - assert_eq!(js_moment_is_before(earlier, later).to_bits(), TAG_TRUE); - assert_eq!(js_moment_is_after(later, earlier).to_bits(), TAG_TRUE); + assert_eq!( + js_moment_is_before(h(earlier), h(later)).to_bits(), + TAG_TRUE + ); + assert_eq!(js_moment_is_after(h(later), h(earlier)).to_bits(), TAG_TRUE); let null = std::ptr::null::(); assert_eq!( - unsafe { js_moment_is_same(earlier, earlier, null) }.to_bits(), + unsafe { js_moment_is_same(h(earlier), h(earlier), null) }.to_bits(), TAG_TRUE ); } #[test] fn clone_preserves_datetime() { - let h = js_moment_from_timestamp(1_700_000_000_000.0); - let h2 = js_moment_clone(h); - assert_eq!(js_moment_value_of(h), js_moment_value_of(h2)); + let m = js_moment_from_timestamp(1_700_000_000_000.0); + let m2 = js_moment_clone(h(m)); + assert_eq!(js_moment_value_of(h(m)), js_moment_value_of(h(m2))); } #[test] fn parse_iso_marks_valid() { let s = alloc_string("2024-01-15T10:30:00Z"); - let h = unsafe { js_moment_parse(s.as_raw()) }; - assert_eq!(js_moment_year(h), 2024.0); - assert_eq!(js_moment_month(h), 0.0); - assert_eq!(js_moment_date(h), 15.0); - assert_eq!(js_moment_is_valid(h).to_bits(), TAG_TRUE); + let m = unsafe { js_moment_parse(s.as_raw()) }; + assert_eq!(js_moment_year(h(m)), 2024.0); + assert_eq!(js_moment_month(h(m)), 0.0); + assert_eq!(js_moment_date(h(m)), 15.0); + assert_eq!(js_moment_is_valid(h(m)).to_bits(), TAG_TRUE); + } + + #[test] + fn parse_bare_date_is_valid_utc_midnight() { + let s = alloc_string("2024-01-15"); + let m = unsafe { js_moment_parse(s.as_raw()) }; + assert_eq!(js_moment_is_valid(h(m)).to_bits(), TAG_TRUE); + assert_eq!(js_moment_year(h(m)), 2024.0); + assert_eq!(js_moment_hour(h(m)), 0.0); } #[test] fn parse_garbage_marks_invalid() { let s = alloc_string("not a date"); - let h = unsafe { js_moment_parse(s.as_raw()) }; - assert_eq!(js_moment_is_valid(h).to_bits(), TAG_FALSE); + let m = unsafe { js_moment_parse(s.as_raw()) }; + assert_eq!(js_moment_is_valid(h(m)).to_bits(), TAG_FALSE); + } + + #[test] + fn factory_routes_string_number_undefined() { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + // string → parse + let s = alloc_string("2024-01-15T10:30:00Z"); + let sv = perry_ffi::JsValue::from_string_ptr(s.as_raw()); + let m = unsafe { js_moment_factory(sv.bits() as i64) }; + assert_eq!(js_moment_year(h(m)), 2024.0); + // number → epoch ms + let n = 1_700_000_000_000.0_f64; + let m2 = unsafe { js_moment_factory(n.to_bits() as i64) }; + assert_eq!(js_moment_value_of(h(m2)), n); + // undefined → now (valid) + let m3 = unsafe { js_moment_factory(TAG_UNDEFINED as i64) }; + assert_eq!(js_moment_is_valid(h(m3)).to_bits(), TAG_TRUE); } } diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 24a24bc52f..b675029ef6 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -532,15 +532,19 @@ pub(crate) fn register_native_from_new_and_calls( ("better-sqlite3", "prepare") => Some("Statement"), ("sqlite", "prepare") => Some("StatementSync"), ("sqlite", "createSession") => Some("Session"), - // dayjs manipulation methods return a NEW - // date handle — without re-registering the + // dayjs / moment manipulation methods return a + // NEW date handle — without re-registering the // binding, `const d2 = d.add(7, 'day'); // d2.format(...)` fell to generic dispatch - // (undefined). "App" matches the factory- - // result registration class. + // (undefined). "App" matches the factory-result + // registration class. ("dayjs", "add" | "subtract" | "startOf" | "endOf") => { Some("App") } + ( + "moment", + "add" | "subtract" | "startOf" | "endOf" | "clone", + ) => Some("App"), _ => None, }; if let Some(class_name) = returns_handle { diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index 1e2b74977d..95885b1430 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -661,8 +661,9 @@ pub fn chained_native_class(module: &str, prior_method: &str) -> Option<&'static ("mysql2", "getConnection") | ("mysql2/promise", "getConnection") => Some("PoolConnection"), ("pg", "connect") => Some("PoolClient"), ("ioredis", "duplicate") => Some("Redis"), - // dayjs manipulation methods return a NEW date handle. + // dayjs / moment manipulation methods return a NEW date handle. ("dayjs", "add" | "subtract" | "startOf" | "endOf") => Some("App"), + ("moment", "add" | "subtract" | "startOf" | "endOf" | "clone") => Some("App"), _ => None, } } diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index a02c4330ae..a411720ed0 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -543,11 +543,14 @@ pub(super) fn try_static_method_and_instance( } ("pg", "connect") => Some("PoolClient"), ("ioredis", "duplicate") => Some("Redis"), - // dayjs manipulation methods return a NEW date - // handle — lets `d.add(7, 'day').format(...)` + // dayjs / moment manipulation methods return a NEW + // date handle — lets `d.add(7, 'day').format(...)` // dispatch against the result. "App" matches the // factory-result registration class. ("dayjs", "add" | "subtract" | "startOf" | "endOf") => Some("App"), + ("moment", "add" | "subtract" | "startOf" | "endOf" | "clone") => { + Some("App") + } _ => None, }; if let Some(result_class) = chained_class { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index e5c0d963e0..fea8261e6d 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1070,15 +1070,19 @@ pub(crate) fn lower_module_decl( ("sqlite", "createSession") => { Some("Session") } - // dayjs manipulation - // methods return a NEW - // date handle (see - // native_new.rs). + // dayjs / moment manipulation + // methods return a NEW date + // handle (see native_new.rs). ( "dayjs", "add" | "subtract" | "startOf" | "endOf", ) => Some("App"), + ( + "moment", + "add" | "subtract" | "startOf" + | "endOf" | "clone", + ) => Some("App"), _ => None, }; if let Some(class_name) = returns_handle { diff --git a/crates/perry-stdlib/src/moment.rs b/crates/perry-stdlib/src/moment.rs index f70464143b..7415905d55 100644 --- a/crates/perry-stdlib/src/moment.rs +++ b/crates/perry-stdlib/src/moment.rs @@ -24,16 +24,17 @@ pub struct MomentHandle { pub is_valid: bool, } -/// Helper to convert handle to f64 for FFI +/// Helper to convert handle to f64 for FFI. +/// +/// Same scheme as dayjs: the JS-visible moment value is +/// `f64::from_bits(handle)` — a tiny denormal whose raw bits ARE the +/// handle. Instance methods receive the handle back as an i64 first +/// arg (the dispatch table's has_receiver unbox is bitcast+mask, +/// identity for small handles). fn handle_to_f64(handle: Handle) -> f64 { f64::from_bits(handle as u64) } -/// Helper to convert f64 to handle for FFI -fn f64_to_handle(value: f64) -> Handle { - value.to_bits() as Handle -} - /// moment() -> Moment /// /// Create a moment object for the current time. @@ -88,15 +89,21 @@ pub unsafe extern "C" fn js_moment_parse(date_str_ptr: *const StringHeader) -> f } }; - // Try parsing various formats + // Try parsing various formats. Note: the bare-date form must go + // through NaiveDate (NaiveDateTime::parse_from_str("%Y-%m-%d") + // always errors on the missing time part, which silently made + // moment('2024-01-15') invalid). let datetime = date_str .parse::>() .or_else(|_| { - NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc()) + NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S%.f").map(|dt| dt.and_utc()) + }) + .or_else(|_| { + NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S%.f").map(|dt| dt.and_utc()) }) - .or_else(|_| NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d").map(|dt| dt.and_utc())) .or_else(|_| { - NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S").map(|dt| dt.and_utc()) + chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") + .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc()) }); match datetime { @@ -117,13 +124,39 @@ pub unsafe extern "C" fn js_moment_parse(date_str_ptr: *const StringHeader) -> f } } +/// moment(input?) -> Moment +/// +/// The moment factory with its real argument surface: `value_bits` is +/// the raw NaN-boxed first argument (TAG_UNDEFINED when absent). +/// undefined → now; string → parse; number → epoch milliseconds. +#[no_mangle] +pub unsafe extern "C" fn js_moment_factory(value_bits: i64) -> f64 { + use perry_runtime::JSValue; + let bits = value_bits as u64; + let jv = JSValue::from_bits(bits); + if jv.is_any_string() { + let ptr = perry_runtime::js_get_string_pointer_unified(f64::from_bits(bits)) + as *const StringHeader; + return js_moment_parse(ptr); + } + if jv.is_int32() { + return js_moment_from_timestamp(jv.as_int32() as f64); + } + if jv.is_number() { + let n = f64::from_bits(bits); + if n.is_finite() { + return js_moment_from_timestamp(n); + } + } + js_moment_now() +} + /// moment.format(formatString) -> string #[no_mangle] pub unsafe extern "C" fn js_moment_format( - handle: f64, + handle: i64, format_ptr: *const StringHeader, ) -> *mut StringHeader { - let handle = f64_to_handle(handle); let format_str = string_from_header(format_ptr).unwrap_or_else(|| "YYYY-MM-DDTHH:mm:ssZ".to_string()); @@ -157,8 +190,7 @@ pub unsafe extern "C" fn js_moment_format( /// moment.toISOString() -> string #[no_mangle] -pub unsafe extern "C" fn js_moment_to_iso_string(handle: f64) -> *mut StringHeader { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_to_iso_string(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { let iso = moment.datetime.to_rfc3339(); return js_string_from_bytes(iso.as_ptr(), iso.len() as u32); @@ -168,8 +200,7 @@ pub unsafe extern "C" fn js_moment_to_iso_string(handle: f64) -> *mut StringHead /// moment.valueOf() -> number (milliseconds since epoch) #[no_mangle] -pub unsafe extern "C" fn js_moment_value_of(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_value_of(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.timestamp_millis() as f64; } @@ -178,8 +209,7 @@ pub unsafe extern "C" fn js_moment_value_of(handle: f64) -> f64 { /// moment.unix() -> number (seconds since epoch) #[no_mangle] -pub unsafe extern "C" fn js_moment_unix(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_unix(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.timestamp() as f64; } @@ -188,8 +218,7 @@ pub unsafe extern "C" fn js_moment_unix(handle: f64) -> f64 { /// moment.year() -> number #[no_mangle] -pub unsafe extern "C" fn js_moment_year(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_year(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.year() as f64; } @@ -198,8 +227,7 @@ pub unsafe extern "C" fn js_moment_year(handle: f64) -> f64 { /// moment.month() -> number (0-11) #[no_mangle] -pub unsafe extern "C" fn js_moment_month(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_month(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return (moment.datetime.month() - 1) as f64; } @@ -208,8 +236,7 @@ pub unsafe extern "C" fn js_moment_month(handle: f64) -> f64 { /// moment.date() -> number (1-31) #[no_mangle] -pub unsafe extern "C" fn js_moment_date(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_date(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.day() as f64; } @@ -218,8 +245,7 @@ pub unsafe extern "C" fn js_moment_date(handle: f64) -> f64 { /// moment.day() -> number (0-6, Sunday = 0) #[no_mangle] -pub unsafe extern "C" fn js_moment_day(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_day(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.weekday().num_days_from_sunday() as f64; } @@ -228,8 +254,7 @@ pub unsafe extern "C" fn js_moment_day(handle: f64) -> f64 { /// moment.hour() -> number #[no_mangle] -pub unsafe extern "C" fn js_moment_hour(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_hour(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.hour() as f64; } @@ -238,8 +263,7 @@ pub unsafe extern "C" fn js_moment_hour(handle: f64) -> f64 { /// moment.minute() -> number #[no_mangle] -pub unsafe extern "C" fn js_moment_minute(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_minute(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.minute() as f64; } @@ -248,8 +272,7 @@ pub unsafe extern "C" fn js_moment_minute(handle: f64) -> f64 { /// moment.second() -> number #[no_mangle] -pub unsafe extern "C" fn js_moment_second(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_second(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return moment.datetime.second() as f64; } @@ -258,8 +281,7 @@ pub unsafe extern "C" fn js_moment_second(handle: f64) -> f64 { /// moment.millisecond() -> number #[no_mangle] -pub unsafe extern "C" fn js_moment_millisecond(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_millisecond(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { return (moment.datetime.timestamp_subsec_millis()) as f64; } @@ -269,11 +291,10 @@ pub unsafe extern "C" fn js_moment_millisecond(handle: f64) -> f64 { /// moment.add(amount, unit) -> Moment #[no_mangle] pub unsafe extern "C" fn js_moment_add( - handle: f64, + handle: i64, amount: f64, unit_ptr: *const StringHeader, ) -> f64 { - let handle = f64_to_handle(handle); let unit = string_from_header(unit_ptr).unwrap_or_else(|| "days".to_string()); if let Some(moment) = get_handle::(handle) { @@ -304,7 +325,7 @@ pub unsafe extern "C" fn js_moment_add( /// moment.subtract(amount, unit) -> Moment #[no_mangle] pub unsafe extern "C" fn js_moment_subtract( - handle: f64, + handle: i64, amount: f64, unit_ptr: *const StringHeader, ) -> f64 { @@ -313,8 +334,7 @@ pub unsafe extern "C" fn js_moment_subtract( /// moment.startOf(unit) -> Moment #[no_mangle] -pub unsafe extern "C" fn js_moment_start_of(handle: f64, unit_ptr: *const StringHeader) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_start_of(handle: i64, unit_ptr: *const StringHeader) -> f64 { let unit = string_from_header(unit_ptr).unwrap_or_else(|| "day".to_string()); if let Some(moment) = get_handle::(handle) { @@ -348,8 +368,7 @@ pub unsafe extern "C" fn js_moment_start_of(handle: f64, unit_ptr: *const String /// moment.endOf(unit) -> Moment #[no_mangle] -pub unsafe extern "C" fn js_moment_end_of(handle: f64, unit_ptr: *const StringHeader) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_end_of(handle: i64, unit_ptr: *const StringHeader) -> f64 { let unit = string_from_header(unit_ptr).unwrap_or_else(|| "day".to_string()); if let Some(moment) = get_handle::(handle) { @@ -393,12 +412,10 @@ pub unsafe extern "C" fn js_moment_end_of(handle: f64, unit_ptr: *const StringHe /// moment.diff(other, unit) -> number #[no_mangle] pub unsafe extern "C" fn js_moment_diff( - handle: f64, - other_handle: f64, + handle: i64, + other_handle: i64, unit_ptr: *const StringHeader, ) -> f64 { - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); let unit = string_from_header(unit_ptr).unwrap_or_else(|| "milliseconds".to_string()); if let (Some(moment), Some(other)) = ( @@ -424,13 +441,10 @@ pub unsafe extern "C" fn js_moment_diff( /// moment.isBefore(other) -> boolean #[no_mangle] -pub unsafe extern "C" fn js_moment_is_before(handle: f64, other_handle: f64) -> f64 { +pub unsafe extern "C" fn js_moment_is_before(handle: i64, other_handle: i64) -> f64 { const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); - if let (Some(moment), Some(other)) = ( get_handle::(handle), get_handle::(other_handle), @@ -445,13 +459,10 @@ pub unsafe extern "C" fn js_moment_is_before(handle: f64, other_handle: f64) -> /// moment.isAfter(other) -> boolean #[no_mangle] -pub unsafe extern "C" fn js_moment_is_after(handle: f64, other_handle: f64) -> f64 { +pub unsafe extern "C" fn js_moment_is_after(handle: i64, other_handle: i64) -> f64 { const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); - if let (Some(moment), Some(other)) = ( get_handle::(handle), get_handle::(other_handle), @@ -467,15 +478,12 @@ pub unsafe extern "C" fn js_moment_is_after(handle: f64, other_handle: f64) -> f /// moment.isSame(other, unit?) -> boolean #[no_mangle] pub unsafe extern "C" fn js_moment_is_same( - handle: f64, - other_handle: f64, + handle: i64, + other_handle: i64, unit_ptr: *const StringHeader, ) -> f64 { const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - - let handle = f64_to_handle(handle); - let other_handle = f64_to_handle(other_handle); let unit = string_from_header(unit_ptr); if let (Some(moment), Some(other)) = ( @@ -522,17 +530,13 @@ pub unsafe extern "C" fn js_moment_is_same( /// moment.isBetween(start, end) -> boolean #[no_mangle] pub unsafe extern "C" fn js_moment_is_between( - handle: f64, - start_handle: f64, - end_handle: f64, + handle: i64, + start_handle: i64, + end_handle: i64, ) -> f64 { const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - let handle = f64_to_handle(handle); - let start_handle = f64_to_handle(start_handle); - let end_handle = f64_to_handle(end_handle); - if let (Some(moment), Some(start), Some(end)) = ( get_handle::(handle), get_handle::(start_handle), @@ -548,11 +552,9 @@ pub unsafe extern "C" fn js_moment_is_between( /// moment.isValid() -> boolean #[no_mangle] -pub unsafe extern "C" fn js_moment_is_valid(handle: f64) -> f64 { +pub unsafe extern "C" fn js_moment_is_valid(handle: i64) -> f64 { const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - - let handle = f64_to_handle(handle); if let Some(moment) = get_handle::(handle) { if moment.is_valid { return f64::from_bits(TAG_TRUE); @@ -563,8 +565,7 @@ pub unsafe extern "C" fn js_moment_is_valid(handle: f64) -> f64 { /// moment.clone() -> Moment #[no_mangle] -pub unsafe extern "C" fn js_moment_clone(handle: f64) -> f64 { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_clone(handle: i64) -> f64 { if let Some(moment) = get_handle::(handle) { let new_handle = register_handle(MomentHandle { datetime: moment.datetime, @@ -577,8 +578,7 @@ pub unsafe extern "C" fn js_moment_clone(handle: f64) -> f64 { /// moment.fromNow() -> string (relative time) #[no_mangle] -pub unsafe extern "C" fn js_moment_from_now(handle: f64) -> *mut StringHeader { - let handle = f64_to_handle(handle); +pub unsafe extern "C" fn js_moment_from_now(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { let now = Utc::now(); let diff = now.signed_duration_since(moment.datetime); @@ -631,6 +631,6 @@ pub unsafe extern "C" fn js_moment_from_now(handle: f64) -> *mut StringHeader { /// moment.toDate() -> timestamp (for Date object creation) #[no_mangle] -pub unsafe extern "C" fn js_moment_to_date(handle: f64) -> f64 { +pub unsafe extern "C" fn js_moment_to_date(handle: i64) -> f64 { js_moment_value_of(handle) } diff --git a/test-files/test_gap_moment_methods.ts b/test-files/test_gap_moment_methods.ts new file mode 100644 index 0000000000..0ab408f826 --- /dev/null +++ b/test-files/test_gap_moment_methods.ts @@ -0,0 +1,41 @@ +// Gap test: moment instance methods (format/add/subtract/diff/field +// accessors/predicates) must dispatch — only the factory used to be +// wired, so m.format() returned undefined. Run with TZ=UTC on both +// sides. moment mutates on add/subtract, so arithmetic always goes +// through an explicit clone binding and the original is only read +// before/independently of the mutation. + +import moment from "moment"; + +const m = moment("2024-01-15"); +console.log(m.format("YYYY-MM-DD")); +console.log(m.year(), m.month(), m.date(), m.day()); +console.log(m.valueOf(), m.unix()); +console.log(m.isValid() ? "valid" : "invalid"); + +const m2 = moment("2024-03-05T06:07:08"); +console.log(m2.format("YYYY-MM-DD HH:mm:ss")); +console.log(m2.hour(), m2.minute(), m2.second()); + +const epoch = moment(1700000000000); +console.log(epoch.valueOf()); +console.log(epoch.format("YYYY-MM-DD HH:mm:ss")); + +// Arithmetic via clone (moment's add/subtract mutate the receiver). +const mc = m.clone(); +const plus = mc.add(7, "days"); +console.log(plus.format("YYYY-MM-DD")); + +const m2c = m2.clone(); +const minus = m2c.subtract(2, "hours"); +console.log(minus.format("YYYY-MM-DD HH:mm:ss")); + +// startOf on a clone. +const m2d = m2.clone(); +const sod = m2d.startOf("day"); +console.log(sod.format("YYYY-MM-DD HH:mm:ss")); + +// Comparisons / diff (m unchanged: all mutations went through clones). +console.log(m.isBefore(plus) ? "before" : "not-before"); +console.log(plus.diff(m, "days")); +console.log(plus.isAfter(m) ? "after" : "not-after"); From 1cd3834570a5792c23de955e24305da81545e38b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:25:30 +0200 Subject: [PATCH 5/9] fix(ratelimit): dispatch rate-limiter-flexible construction and methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new RateLimiterMemory({...})` produced `{}` with no methods: perry-hir registered the binding as a ("rate-limiter-flexible", "RateLimiterMemory") native instance, but codegen had no constructor arm (the `new` fell to the js_object_alloc(0,0) placeholder) and NATIVE_MODULE_TABLE had zero rows for the module, so `.consume()` fell through to generic dispatch on an empty object -> undefined. The api-manifest carried only class() declarations. - lower_builtin_new gains a "RateLimiterMemory" arm (gated on the rate-limiter-flexible import source, same #602 pattern as pg/ioredis) calling the new js_ratelimit_new_from_options(options_bits), which parses { points, duration } by name with npm's RateLimiterAbstract defaults (4 points / 1 s). - NATIVE_MODULE_TABLE rows for consume/get/delete/block/penalty/reward (NR_PROMISE, key as NA_STR, points as NA_F64) + matching manifest method entries. - Rework the keyed limiter runtime (perry-ext-ratelimit + the bundled perry-stdlib copy) to npm's fixed-window semantics: per-key consumed count + window end + optional block-until. consume resolves a RateLimiterRes-shaped OBJECT { remainingPoints, msBeforeNext, consumedPoints, isFirstInDuration } and rejects with the same shape when the quota is exceeded — the old version resolved a JSON *string* (res.remainingPoints was undefined) and reported remainingPoints as a constant regardless of prior consumption. State math is synchronous on the main thread, so result objects are built inline (no worker-arena hazard, no spawn_blocking). - Legacy symbols (js_ratelimit_new/check/remaining/reset, js_ratelimit_new_keyed) keep their exported signatures. --- .../perry-api-manifest/src/entries/part_4.rs | 11 +- .../perry-codegen/src/lower_call/builtin.rs | 27 + .../src/lower_call/native_table/extras.rs | 61 +++ .../stdlib_ffi/streams_events.rs | 3 + crates/perry-ext-ratelimit/src/lib.rs | 442 +++++++++++----- crates/perry-stdlib/src/ratelimit.rs | 476 +++++++++++------- test-files/test_gap_ratelimiter_memory.ts | 47 ++ 7 files changed, 780 insertions(+), 287 deletions(-) create mode 100644 test-files/test_gap_ratelimiter_memory.ts diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 5b9b811d56..eee01c83fa 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -697,9 +697,18 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("date-fns", "startOfDay", false, None), method("date-fns", "endOfDay", false, None), // --- rate-limiter-flexible — perry-ext-ratelimit. Surface mirrors - // the npm package's RateLimiterMemory class. --- + // the npm package's RateLimiterMemory class. Construction is a + // lower_builtin_new arm (js_ratelimit_new_from_options); the + // instance methods dispatch via the NATIVE_MODULE_TABLE rows in + // lower_call/native_table/extras.rs. --- class("rate-limiter-flexible", "RateLimiterMemory"), class("rate-limiter-flexible", "RateLimiterAbstract"), + method("rate-limiter-flexible", "consume", true, None), + method("rate-limiter-flexible", "get", true, None), + method("rate-limiter-flexible", "delete", true, None), + method("rate-limiter-flexible", "block", true, None), + method("rate-limiter-flexible", "penalty", true, None), + method("rate-limiter-flexible", "reward", true, None), // --- fetch — well-known alias for perry-ext-fetch. Same surface // as node-fetch (the more common alias above). --- method("fetch", "default", false, None), diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 5237d71fe0..8142f0464e 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -51,6 +51,7 @@ pub(super) fn lower_builtin_new( "Redis" => Some(&["ioredis", "redis"]), "MongoClient" => Some(&["mongodb"]), "Decimal" => Some(&["decimal.js"]), + "RateLimiterMemory" => Some(&["rate-limiter-flexible"]), _ => None, }; if let Some(sources) = required_sources { @@ -698,6 +699,32 @@ pub(super) fn lower_builtin_new( let handle = blk.call(I64, "js_ioredis_new", &[(I64, "0")]); Ok(Some(nanbox_pointer_inline(blk, &handle))) } + // rate-limiter-flexible `new RateLimiterMemory({ points, duration })`. + // Gated on the import source above. The options object crosses as + // raw NaN-box bits (i64) so the runtime parses `points`/`duration` + // by name; missing arg → TAG_UNDEFINED → npm defaults (4 points / + // 1 s). Pre-fix this fell to the js_object_alloc(0,0) placeholder + // and every method call dispatched against `{}`. Instance methods + // (consume/get/delete/block/penalty/reward) are wired in + // NATIVE_MODULE_TABLE for module "rate-limiter-flexible". + "RateLimiterMemory" => { + let options = if let Some(arg) = args.first() { + lower_expr(ctx, arg)? + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + for arg in args.iter().skip(1) { + let _ = lower_expr(ctx, arg)?; + } + let blk = ctx.block(); + let options_bits = blk.bitcast_double_to_i64(&options); + let handle = blk.call( + I64, + "js_ratelimit_new_from_options", + &[(I64, &options_bits)], + ); + Ok(Some(nanbox_pointer_inline(blk, &handle))) + } // async_hooks.AsyncLocalStorage — `new AsyncLocalStorage()` produces a // real handle so `.run(store, cb)` / `.getStore()` / `.enterWith(store)` // / `.exit(cb)` / `.disable()` find their registered store stack. diff --git a/crates/perry-codegen/src/lower_call/native_table/extras.rs b/crates/perry-codegen/src/lower_call/native_table/extras.rs index ee7d44d1b2..001e0d6af1 100644 --- a/crates/perry-codegen/src/lower_call/native_table/extras.rs +++ b/crates/perry-codegen/src/lower_call/native_table/extras.rs @@ -378,4 +378,65 @@ pub(super) const EXTRAS_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, + // ========== rate-limiter-flexible ========== + // `new RateLimiterMemory({...})` is constructed by the + // lower_builtin_new arm (js_ratelimit_new_from_options); these rows + // wire the instance methods. All async methods resolve (or, for an + // exceeded quota, reject) a RateLimiterRes-shaped object. The key is + // NA_STR (numbers stringify, matching npm's string keys); points is + // NA_F64 and pads to undefined → runtime default 1. + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "consume", + class_filter: None, + runtime: "js_ratelimit_consume", + args: &[NA_STR, NA_F64], + ret: NR_PROMISE, + }, + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "get", + class_filter: None, + runtime: "js_ratelimit_get", + args: &[NA_STR], + ret: NR_PROMISE, + }, + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "delete", + class_filter: None, + runtime: "js_ratelimit_delete", + args: &[NA_STR], + ret: NR_PROMISE, + }, + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "block", + class_filter: None, + runtime: "js_ratelimit_block", + args: &[NA_STR, NA_F64], + ret: NR_PROMISE, + }, + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "penalty", + class_filter: None, + runtime: "js_ratelimit_penalty", + args: &[NA_STR, NA_F64], + ret: NR_PROMISE, + }, + NativeModSig { + module: "rate-limiter-flexible", + has_receiver: true, + method: "reward", + class_filter: None, + runtime: "js_ratelimit_reward", + args: &[NA_STR, NA_F64], + ret: NR_PROMISE, + }, ]; diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index b99bb97192..64b94973ad 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -278,6 +278,9 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { module.declare_function("js_ratelimit_create", I64, &[I64]); module.declare_function("js_ratelimit_delete", I64, &[I64, I64]); module.declare_function("js_ratelimit_get", I64, &[I64, I64]); + // `new RateLimiterMemory({...})` ctor arm in lower_call/builtin.rs — + // takes the raw NaN-boxed options object bits. + module.declare_function("js_ratelimit_new_from_options", I64, &[I64]); module.declare_function("js_ratelimit_penalty", I64, &[I64, I64, DOUBLE]); module.declare_function("js_ratelimit_reward", I64, &[I64, I64, DOUBLE]); diff --git a/crates/perry-ext-ratelimit/src/lib.rs b/crates/perry-ext-ratelimit/src/lib.rs index 0773ff5614..3a3a39233d 100644 --- a/crates/perry-ext-ratelimit/src/lib.rs +++ b/crates/perry-ext-ratelimit/src/lib.rs @@ -1,8 +1,21 @@ //! Native bindings for the npm `rate-limiter-flexible` package — -//! token-bucket rate limiting via the `governor` crate. Uses only -//! perry-ffi v0.5 strings + handles + Promise + JsValue. The async -//! exports bridge through `spawn_blocking` + `JsPromise` since -//! `governor::RateLimiter::check()` is sync. +//! `RateLimiterMemory`-compatible fixed-window rate limiting. Uses only +//! perry-ffi v0.5 strings + handles + Promise + JsValue. +//! +//! Semantics mirror the npm package's in-memory limiter: +//! `points` consumable per `duration`-second fixed window (defaults 4 / +//! 1s, like `RateLimiterAbstract`). `consume` resolves a +//! `RateLimiterRes`-shaped object `{ remainingPoints, msBeforeNext, +//! consumedPoints, isFirstInDuration }` and **rejects with the same +//! shape** when the quota is exceeded. All state math is synchronous +//! and runs on the calling (main) thread, so result objects are built +//! inline and settled immediately — no worker-arena hazards. +//! +//! The old version fed each consume through a `governor` token bucket +//! but reported `remainingPoints` as a constant (`points - n`, +//! regardless of prior consumption) and resolved a JSON *string* +//! instead of an object, so `res.remainingPoints` was `undefined` and +//! repeated consumes never counted down. use governor::{ clock::DefaultClock, @@ -10,22 +23,40 @@ use governor::{ Quota, RateLimiter, }; use perry_ffi::{ - alloc_string, get_handle, read_string, register_handle, spawn_blocking, Handle, JsPromise, - JsString, JsValue, Promise, StringHeader, + alloc_string, build_object_shape, get_handle, js_object_alloc_with_shape, js_object_set_field, + read_string, register_handle, Handle, JsPromise, JsString, JsValue, ObjectHeader, Promise, + StringHeader, }; use std::collections::HashMap; use std::num::NonZeroU32; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +extern "C" { + /// perry-runtime: read an object field by string key, returning the + /// raw NaN-boxed JSValue bits as f64 (undefined tag when absent). + fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; +} + +/// Legacy direct (non-keyed) limiter — kept for the pre-existing +/// `js_ratelimit_new` / `js_ratelimit_check` / `js_ratelimit_remaining` +/// symbol surface. pub struct RateLimiterHandle { pub limiter: RateLimiter, pub points: u32, pub duration_secs: u64, } +/// Per-key fixed-window state, npm-style. +struct KeyState { + consumed: u32, + window_end: Instant, + blocked_until: Option, +} + +/// `new RateLimiterMemory({ points, duration })`. pub struct KeyedRateLimiterHandle { - pub limiters: Arc>>>, + states: Mutex>, pub points: u32, pub duration_secs: u64, } @@ -35,6 +66,29 @@ unsafe fn read_str(ptr: *const StringHeader) -> Option { read_string(handle).map(String::from) } +/// Build a `RateLimiterRes`-shaped result object (main thread only). +fn ratelimiter_res( + remaining_points: f64, + ms_before_next: f64, + consumed_points: f64, + is_first_in_duration: bool, +) -> JsValue { + let (packed, shape_id) = build_object_shape(&[ + "remainingPoints", + "msBeforeNext", + "consumedPoints", + "isFirstInDuration", + ]); + unsafe { + let obj = js_object_alloc_with_shape(shape_id, 4, packed.as_ptr(), packed.len() as u32); + js_object_set_field(obj, 0, JsValue::from_number(remaining_points)); + js_object_set_field(obj, 1, JsValue::from_number(ms_before_next)); + js_object_set_field(obj, 2, JsValue::from_number(consumed_points)); + js_object_set_field(obj, 3, JsValue::from_bool(is_first_in_duration)); + JsValue::from_object_ptr(obj) + } +} + #[no_mangle] pub extern "C" fn js_ratelimit_new(points: f64, duration_secs: f64) -> Handle { let points = points.max(1.0) as u32; @@ -55,12 +109,62 @@ pub extern "C" fn js_ratelimit_new_keyed(points: f64, duration_secs: f64) -> Han let points = points.max(1.0) as u32; let duration_secs = duration_secs.max(1.0) as u64; register_handle(KeyedRateLimiterHandle { - limiters: Arc::new(Mutex::new(HashMap::new())), + states: Mutex::new(HashMap::new()), points, duration_secs, }) } +/// `new RateLimiterMemory(opts)` — parse `{ points, duration }` from the +/// NaN-boxed options object (raw bits as i64; TAG_UNDEFINED when the +/// constructor was called without arguments). Defaults mirror npm's +/// `RateLimiterAbstract`: points = 4, duration = 1 second. +/// +/// # Safety +/// `options_bits` must be valid NaN-box bits. +#[no_mangle] +pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Handle { + let mut points: f64 = 4.0; + let mut duration: f64 = 1.0; + let jv = JsValue::from_bits(options_bits as u64); + if jv.is_pointer() { + let obj = jv.as_pointer::(); + if !obj.is_null() && (obj as usize) >= 0x1000 { + let field = |name: &str| -> f64 { + let key = alloc_string(name); + js_object_get_field_by_name_f64(obj, key.as_raw()) + }; + let read_num = |v: f64| -> Option { + let jv = JsValue::from_bits(v.to_bits()); + if jv.is_int32() { + Some(jv.to_int32() as f64) + } else if jv.is_number() && !v.is_nan() { + Some(v) + } else { + None + } + }; + if let Some(n) = read_num(field("points")) { + points = n; + } + if let Some(n) = read_num(field("duration")) { + duration = n; + } + } + } + js_ratelimit_new_keyed(points, duration) +} + +impl KeyedRateLimiterHandle { + fn window(&self) -> Duration { + Duration::from_secs(self.duration_secs) + } +} + +/// `limiter.consume(key, points = 1)` — npm-style fixed window. Resolves +/// a `RateLimiterRes` object; rejects with the same shape when the +/// window's quota is exceeded (or the key is blocked). +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -74,47 +178,58 @@ pub unsafe extern "C" fn js_ratelimit_consume( let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); let consume_points = points.max(1.0) as u32; - spawn_blocking(move || { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - let limiter = limiters.entry(key.clone()).or_insert_with(|| { - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(keyed.points).unwrap()); - RateLimiter::direct(quota) - }); - for _ in 0..consume_points { - if limiter.check().is_err() { - promise.reject_string("Rate limit exceeded"); - return; - } - } - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":{},"isFirstInDuration":false}}"#, - keyed.points.saturating_sub(consume_points), - consume_points - ); - promise.resolve_string(&result); - } else if let Some(simple) = get_handle::(handle) { - for _ in 0..consume_points { - if simple.limiter.check().is_err() { - promise.reject_string("Rate limit exceeded"); - return; - } - } - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":{},"isFirstInDuration":false}}"#, - simple.points.saturating_sub(consume_points), - consume_points - ); - promise.resolve_string(&result); - } else { - promise.reject_string("Invalid rate limiter handle"); - } + let Some(keyed) = get_handle::(handle) else { + promise.reject_string("Invalid rate limiter handle"); + return raw; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, }); + + if let Some(until) = state.blocked_until { + if until > now { + let ms = until.duration_since(now).as_millis() as f64; + let res = ratelimiter_res(0.0, ms, state.consumed as f64, false); + drop(states); + promise.reject(res); + return raw; + } + state.blocked_until = None; + state.consumed = 0; + state.window_end = now + window; + } + + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } + + state.consumed += consume_points; + let is_first = state.consumed == consume_points; + let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; + let consumed = state.consumed as f64; + let over_limit = state.consumed > keyed.points; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + + let res = ratelimiter_res(remaining, ms_before_next, consumed, is_first && !over_limit); + if over_limit { + promise.reject(res); + } else { + promise.resolve(res); + } raw } +/// `limiter.get(key)` — current state without consuming; `null` when the +/// key has no live window. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -126,25 +241,38 @@ pub unsafe extern "C" fn js_ratelimit_get( let raw = promise.as_raw(); let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - spawn_blocking(move || { - if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - if limiters.contains_key(&key) { - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":0,"isFirstInDuration":false}}"#, - keyed.points - ); - promise.resolve_string(&result); - } else { - promise.resolve(JsValue::NULL); - } - } else { + let Some(keyed) = get_handle::(handle) else { + promise.resolve(JsValue::NULL); + return raw; + }; + + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + match states.get(&key) { + Some(state) if now < state.window_end || state.blocked_until.is_some_and(|u| u > now) => { + let ms = state + .blocked_until + .filter(|u| *u > now) + .unwrap_or(state.window_end) + .duration_since(now) + .as_millis() as f64; + let consumed = state.consumed as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + let res = ratelimiter_res(remaining, ms, consumed, false); + drop(states); + promise.resolve(res); + } + _ => { + drop(states); promise.resolve(JsValue::NULL); } - }); + } raw } +/// `limiter.delete(key)` — drop the key's window. Resolves `true` when a +/// record existed. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -156,18 +284,18 @@ pub unsafe extern "C" fn js_ratelimit_delete( let raw = promise.as_raw(); let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - spawn_blocking(move || { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - let removed = limiters.remove(&key).is_some(); - promise.resolve(JsValue::from_bool(removed)); - } else { - promise.resolve(JsValue::FALSE); - } - }); + if let Some(keyed) = get_handle::(handle) { + let removed = keyed.states.lock().unwrap().remove(&key).is_some(); + promise.resolve(JsValue::from_bool(removed)); + } else { + promise.resolve(JsValue::FALSE); + } raw } +/// `limiter.block(key, secDuration)` — block the key for `secDuration` +/// seconds (0 = forever). Resolves a `RateLimiterRes`. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -179,23 +307,38 @@ pub unsafe extern "C" fn js_ratelimit_block( let promise = JsPromise::new(); let raw = promise.as_raw(); let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - let _duration = duration_sec.max(1.0) as u64; - - spawn_blocking(move || { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(1).unwrap()); - let limiter = RateLimiter::direct(quota); - let _ = limiter.check(); - limiters.insert(key, limiter); - } + + let Some(keyed) = get_handle::(handle) else { promise.resolve(JsValue::UNDEFINED); + return raw; + }; + + let now = Instant::now(); + let secs = duration_sec.max(0.0); + let until = if secs == 0.0 { + now + Duration::from_secs(u32::MAX as u64) + } else { + now + Duration::from_millis((secs * 1000.0) as u64) + }; + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, }); + state.blocked_until = Some(until); + let consumed = state.consumed as f64; + drop(states); + + let res = ratelimiter_res(0.0, secs * 1000.0, consumed, false); + promise.resolve(res); raw } +/// `limiter.penalty(key, points = 1)` — add consumed points without a +/// quota check. Resolves a `RateLimiterRes`. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -204,49 +347,94 @@ pub unsafe extern "C" fn js_ratelimit_penalty( key_ptr: *const StringHeader, points: f64, ) -> *mut Promise { - js_ratelimit_consume(handle, key_ptr, points) + let promise = JsPromise::new(); + let raw = promise.as_raw(); + let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); + let n = points.max(1.0) as u32; + + let Some(keyed) = get_handle::(handle) else { + promise.resolve(JsValue::NULL); + return raw; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, + }); + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } + state.consumed += n; + let consumed = state.consumed as f64; + let ms = state.window_end.duration_since(now).as_millis() as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + + promise.resolve(ratelimiter_res(remaining, ms, consumed, false)); + raw } +/// `limiter.reward(key, points = 1)` — give consumed points back. +/// Resolves a `RateLimiterRes`. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_reward( handle: Handle, key_ptr: *const StringHeader, - _points: f64, + points: f64, ) -> *mut Promise { let promise = JsPromise::new(); let raw = promise.as_raw(); let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); + let n = points.max(1.0) as u32; - spawn_blocking(move || { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(keyed.points).unwrap()); - limiters.insert(key, RateLimiter::direct(quota)); - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":0,"isFirstInDuration":true}}"#, - keyed.points - ); - promise.resolve_string(&result); - } else { - promise.resolve(JsValue::NULL); - } + let Some(keyed) = get_handle::(handle) else { + promise.resolve(JsValue::NULL); + return raw; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, }); + state.consumed = state.consumed.saturating_sub(n); + let consumed = state.consumed as f64; + let ms = state.window_end.duration_since(now).as_millis() as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + + promise.resolve(ratelimiter_res(remaining, ms, consumed, false)); raw } +/// Sync probe: would a consume of 1 point succeed? +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const StringHeader) -> bool { let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - if let Some(limiter) = limiters.get(&key) { - return limiter.check().is_ok(); + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + if let Some(state) = states.get(&key) { + if state.blocked_until.is_some_and(|u| u > now) { + return false; + } + if now < state.window_end { + return state.consumed < keyed.points; + } } return true; } else if let Some(simple) = get_handle::(handle) { @@ -255,6 +443,8 @@ pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const Stri true } +/// Sync probe: remaining points for a key. +/// /// # Safety /// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] @@ -264,9 +454,12 @@ pub unsafe extern "C" fn js_ratelimit_remaining( ) -> f64 { let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - if limiters.contains_key(&key) { - return keyed.points as f64; + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + if let Some(state) = states.get(&key) { + if now < state.window_end { + return (keyed.points as f64 - state.consumed as f64).max(0.0); + } } return keyed.points as f64; } else if let Some(simple) = get_handle::(handle) { @@ -278,17 +471,10 @@ pub unsafe extern "C" fn js_ratelimit_remaining( #[no_mangle] pub extern "C" fn js_ratelimit_reset(handle: Handle) { if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - limiters.clear(); + keyed.states.lock().unwrap().clear(); } } -// `alloc_string` available for follow-ups; currently unused. -#[allow(dead_code)] -fn _ensure_alloc_string_linkage() -> *mut StringHeader { - alloc_string("").as_raw() -} - #[cfg(test)] mod tests { use super::*; @@ -303,10 +489,33 @@ mod tests { fn check_passes_when_under_quota() { let h = js_ratelimit_new(10.0, 60.0); let key = alloc_string("user-a"); - // First call should pass (no limiter for this key, returns true). assert!(unsafe { js_ratelimit_check(h, key.as_raw()) }); } + #[test] + fn keyed_state_counts_down_and_blocks() { + let h = js_ratelimit_new_keyed(2.0, 60.0); + let keyed = get_handle::(h).unwrap(); + let now = Instant::now(); + { + let mut states = keyed.states.lock().unwrap(); + states.insert( + "k".into(), + KeyState { + consumed: 2, + window_end: now + Duration::from_secs(60), + blocked_until: None, + }, + ); + } + let key = alloc_string("k"); + // Quota exhausted: sync probe must fail… + assert!(!unsafe { js_ratelimit_check(h, key.as_raw()) }); + // …and remaining must be 0 (the old governor-backed version + // reported the constant max here). + assert_eq!(unsafe { js_ratelimit_remaining(h, key.as_raw()) }, 0.0); + } + #[test] fn remaining_returns_max_for_unknown_key() { let h = js_ratelimit_new_keyed(5.0, 60.0); @@ -318,15 +527,14 @@ mod tests { fn reset_clears_keyed_limiters() { let h = js_ratelimit_new_keyed(3.0, 60.0); js_ratelimit_reset(h); - // After reset, remaining for any key is still the max. let key = alloc_string("anything"); assert_eq!(unsafe { js_ratelimit_remaining(h, key.as_raw()) }, 3.0); } #[test] fn invalid_handle_check_returns_true() { - // Per the perry-stdlib convention, invalid-handle returns - // a permissive `true` for check (no rate limit applies). + // Per the perry-stdlib convention, invalid-handle returns a + // permissive `true` for check (no rate limit applies). let key = alloc_string("x"); assert!(unsafe { js_ratelimit_check(-1, key.as_raw()) }); } diff --git a/crates/perry-stdlib/src/ratelimit.rs b/crates/perry-stdlib/src/ratelimit.rs index 054ed0f9b4..8bc021b8c7 100644 --- a/crates/perry-stdlib/src/ratelimit.rs +++ b/crates/perry-stdlib/src/ratelimit.rs @@ -1,19 +1,33 @@ //! Rate Limiter module (rate-limiter-flexible compatible) //! -//! Native implementation of rate limiting functionality using governor. -//! Provides token bucket rate limiting for API protection. +//! `RateLimiterMemory`-compatible fixed-window rate limiting. Semantics +//! mirror the npm package's in-memory limiter: `points` consumable per +//! `duration`-second fixed window (defaults 4 / 1s, like +//! `RateLimiterAbstract`). `consume` resolves a `RateLimiterRes`-shaped +//! object `{ remainingPoints, msBeforeNext, consumedPoints, +//! isFirstInDuration }` and **rejects with the same shape** when the +//! quota is exceeded. All state math is synchronous and runs on the +//! calling (main) thread, so result objects are built inline and +//! settled immediately. +//! +//! Kept in lock-step with `crates/perry-ext-ratelimit` (the well-known +//! flip's copy); this bundled copy links when the ext staticlib is +//! unavailable or `PERRY_DISABLE_WELL_KNOWN` is set. -use crate::common::{get_handle, register_handle, spawn_for_promise, Handle}; +use crate::common::{get_handle, register_handle, Handle}; use governor::{ clock::DefaultClock, state::{InMemoryState, NotKeyed}, Quota, RateLimiter, }; -use perry_runtime::{js_promise_new, js_string_from_bytes, JSValue, Promise, StringHeader}; +use perry_runtime::{ + js_object_alloc_with_shape, js_object_set_field, js_promise_new, js_promise_reject, + js_promise_resolve, js_string_from_bytes, JSValue, ObjectHeader, Promise, StringHeader, +}; use std::collections::HashMap; use std::num::NonZeroU32; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Mutex; +use std::time::{Duration, Instant}; /// Helper to extract string from StringHeader pointer unsafe fn string_from_header(ptr: *const StringHeader) -> Option { @@ -26,32 +40,64 @@ unsafe fn string_from_header(ptr: *const StringHeader) -> Option { Some(String::from_utf8_lossy(bytes).to_string()) } -/// Rate limiter handle (for simple per-instance limiting) +/// Legacy direct (non-keyed) limiter — kept for the pre-existing +/// `js_ratelimit_new` / `js_ratelimit_check` / `js_ratelimit_remaining` +/// symbol surface. pub struct RateLimiterHandle { pub limiter: RateLimiter, pub points: u32, pub duration_secs: u64, } -/// Keyed rate limiter handle (for per-key limiting like IP addresses) +/// Per-key fixed-window state, npm-style. +struct KeyState { + consumed: u32, + window_end: Instant, + blocked_until: Option, +} + +/// `new RateLimiterMemory({ points, duration })`. pub struct KeyedRateLimiterHandle { - pub limiters: Arc>>>, + states: Mutex>, pub points: u32, pub duration_secs: u64, } -/// Result of a consume operation -pub struct ConsumeResult { - pub remaining_points: i32, - pub ms_before_next: u64, - pub consumed_points: u32, - pub is_rejected: bool, +impl KeyedRateLimiterHandle { + fn window(&self) -> Duration { + Duration::from_secs(self.duration_secs) + } } -/// new RateLimiterMemory(opts) -> RateLimiter -/// -/// Create a new in-memory rate limiter. -/// opts: { points: number, duration: number (seconds) } +const RATELIMIT_RES_SHAPE_ID: u32 = 0x7FFF_F31A; + +/// Build a `RateLimiterRes`-shaped result object (main thread only). +fn ratelimiter_res( + remaining_points: f64, + ms_before_next: f64, + consumed_points: f64, + is_first_in_duration: bool, +) -> f64 { + let packed = b"remainingPoints\0msBeforeNext\0consumedPoints\0isFirstInDuration\0"; + let obj = js_object_alloc_with_shape( + RATELIMIT_RES_SHAPE_ID, + 4, + packed.as_ptr(), + packed.len() as u32, + ); + js_object_set_field(obj, 0, JSValue::number(remaining_points)); + js_object_set_field(obj, 1, JSValue::number(ms_before_next)); + js_object_set_field(obj, 2, JSValue::number(consumed_points)); + js_object_set_field(obj, 3, JSValue::bool(is_first_in_duration)); + f64::from_bits(JSValue::pointer(obj as *const u8).bits()) +} + +fn reject_str(promise: *mut Promise, msg: &str) { + let s = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + js_promise_reject(promise, f64::from_bits(JSValue::string_ptr(s).bits())); +} + +/// new RateLimiter (legacy, non-keyed). #[no_mangle] pub extern "C" fn js_ratelimit_new(points: f64, duration_secs: f64) -> Handle { let points = points.max(1.0) as u32; @@ -71,23 +117,64 @@ pub extern "C" fn js_ratelimit_new(points: f64, duration_secs: f64) -> Handle { } /// new RateLimiterMemory(opts) for keyed limiting -> KeyedRateLimiter -/// -/// Create a new keyed rate limiter (per IP, user ID, etc.) #[no_mangle] pub extern "C" fn js_ratelimit_new_keyed(points: f64, duration_secs: f64) -> Handle { let points = points.max(1.0) as u32; let duration_secs = duration_secs.max(1.0) as u64; register_handle(KeyedRateLimiterHandle { - limiters: Arc::new(Mutex::new(HashMap::new())), + states: Mutex::new(HashMap::new()), points, duration_secs, }) } -/// limiter.consume(key, points?) -> Promise +/// `new RateLimiterMemory(opts)` — parse `{ points, duration }` from the +/// NaN-boxed options object (raw bits as i64; TAG_UNDEFINED when the +/// constructor was called without arguments). Defaults mirror npm's +/// `RateLimiterAbstract`: points = 4, duration = 1 second. /// -/// Consume points from the rate limiter. +/// # Safety +/// `options_bits` must be valid NaN-box bits. +#[no_mangle] +pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Handle { + let mut points: f64 = 4.0; + let mut duration: f64 = 1.0; + let jv = JSValue::from_bits(options_bits as u64); + if jv.is_pointer() { + let obj = jv.as_pointer::(); + if !obj.is_null() && (obj as usize) >= 0x1000 { + let field = |name: &[u8]| -> f64 { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + perry_runtime::object::js_object_get_field_by_name_f64(obj, key) + }; + let read_num = |v: f64| -> Option { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_int32() { + Some(jv.as_int32() as f64) + } else if jv.is_number() && !v.is_nan() { + Some(v) + } else { + None + } + }; + if let Some(n) = read_num(field(b"points")) { + points = n; + } + if let Some(n) = read_num(field(b"duration")) { + duration = n; + } + } + } + js_ratelimit_new_keyed(points, duration) +} + +/// `limiter.consume(key, points = 1)` — npm-style fixed window. Resolves +/// a `RateLimiterRes` object; rejects with the same shape when the +/// window's quota is exceeded (or the key is blocked). +/// +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_consume( handle: Handle, @@ -98,67 +185,60 @@ pub unsafe extern "C" fn js_ratelimit_consume( let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); let consume_points = points.max(1.0) as u32; - spawn_for_promise(promise as *mut u8, async move { - // Check if it's a keyed or simple limiter - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - - // Get or create limiter for this key - let limiter = limiters.entry(key.clone()).or_insert_with(|| { - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(keyed.points).unwrap()); - RateLimiter::direct(quota) - }); - - // Try to consume - for _ in 0..consume_points { - if limiter.check().is_err() { - // Rate limited - let result = format!( - r#"{{"remainingPoints":0,"msBeforeNext":{},"consumedPoints":{},"isFirstInDuration":false}}"#, - keyed.duration_secs * 1000, - 0 - ); - let _ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); - return Err("Rate limit exceeded".to_string()); - } - } - - // Success - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":{},"isFirstInDuration":false}}"#, - keyed.points.saturating_sub(consume_points), - consume_points - ); - let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); - Ok(JSValue::string_ptr(ptr).bits()) - } else if let Some(simple) = get_handle::(handle) { - // Simple (non-keyed) limiter - for _ in 0..consume_points { - if simple.limiter.check().is_err() { - return Err("Rate limit exceeded".to_string()); - } - } + let Some(keyed) = get_handle::(handle) else { + reject_str(promise, "Invalid rate limiter handle"); + return promise; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, + }); - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":{},"isFirstInDuration":false}}"#, - simple.points.saturating_sub(consume_points), - consume_points - ); - let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); - Ok(JSValue::string_ptr(ptr).bits()) - } else { - Err("Invalid rate limiter handle".to_string()) + if let Some(until) = state.blocked_until { + if until > now { + let ms = until.duration_since(now).as_millis() as f64; + let res = ratelimiter_res(0.0, ms, state.consumed as f64, false); + drop(states); + js_promise_reject(promise, res); + return promise; } - }); + state.blocked_until = None; + state.consumed = 0; + state.window_end = now + window; + } + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } + + state.consumed += consume_points; + let is_first = state.consumed == consume_points; + let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; + let consumed = state.consumed as f64; + let over_limit = state.consumed > keyed.points; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + + let res = ratelimiter_res(remaining, ms_before_next, consumed, is_first && !over_limit); + if over_limit { + js_promise_reject(promise, res); + } else { + js_promise_resolve(promise, res); + } promise } -/// limiter.get(key) -> Promise +/// `limiter.get(key)` — current state without consuming; `null` when the +/// key has no live window. /// -/// Get the current state for a key without consuming. +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_get( handle: Handle, @@ -166,32 +246,42 @@ pub unsafe extern "C" fn js_ratelimit_get( ) -> *mut Promise { let promise = js_promise_new(); let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - spawn_for_promise(promise as *mut u8, async move { - if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - - if limiters.contains_key(&key) { - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":0,"isFirstInDuration":false}}"#, - keyed.points - ); - let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); - Ok(JSValue::string_ptr(ptr).bits()) - } else { - Ok(JSValue::null().bits()) - } - } else { - Ok(JSValue::null().bits()) + let null = f64::from_bits(JSValue::null().bits()); + + let Some(keyed) = get_handle::(handle) else { + js_promise_resolve(promise, null); + return promise; + }; + + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + match states.get(&key) { + Some(state) if now < state.window_end || state.blocked_until.is_some_and(|u| u > now) => { + let ms = state + .blocked_until + .filter(|u| *u > now) + .unwrap_or(state.window_end) + .duration_since(now) + .as_millis() as f64; + let consumed = state.consumed as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + let res = ratelimiter_res(remaining, ms, consumed, false); + drop(states); + js_promise_resolve(promise, res); } - }); - + _ => { + drop(states); + js_promise_resolve(promise, null); + } + } promise } -/// limiter.delete(key) -> Promise +/// `limiter.delete(key)` — drop the key's window. Resolves `true` when a +/// record existed. /// -/// Delete rate limit record for a key. +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_delete( handle: Handle, @@ -200,22 +290,20 @@ pub unsafe extern "C" fn js_ratelimit_delete( let promise = js_promise_new(); let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - spawn_for_promise(promise as *mut u8, async move { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - let removed = limiters.remove(&key).is_some(); - Ok(JSValue::bool(removed).bits()) - } else { - Ok(JSValue::bool(false).bits()) - } - }); - + let removed = if let Some(keyed) = get_handle::(handle) { + keyed.states.lock().unwrap().remove(&key).is_some() + } else { + false + }; + js_promise_resolve(promise, f64::from_bits(JSValue::bool(removed).bits())); promise } -/// limiter.block(key, durationSec) -> Promise +/// `limiter.block(key, secDuration)` — block the key for `secDuration` +/// seconds (0 = forever). Resolves a `RateLimiterRes`. /// -/// Block a key for a specified duration. +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_block( handle: Handle, @@ -224,77 +312,114 @@ pub unsafe extern "C" fn js_ratelimit_block( ) -> *mut Promise { let promise = js_promise_new(); let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - let _duration = duration_sec.max(1.0) as u64; - - spawn_for_promise(promise as *mut u8, async move { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - // Create a limiter that's already exhausted - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(1).unwrap()); - let limiter = RateLimiter::direct(quota); - - // Consume all points to block - let _ = limiter.check(); - - limiters.insert(key, limiter); - Ok(JSValue::undefined().bits()) - } else { - Ok(JSValue::undefined().bits()) - } + let Some(keyed) = get_handle::(handle) else { + js_promise_resolve(promise, f64::from_bits(JSValue::undefined().bits())); + return promise; + }; + + let now = Instant::now(); + let secs = duration_sec.max(0.0); + let until = if secs == 0.0 { + now + Duration::from_secs(u32::MAX as u64) + } else { + now + Duration::from_millis((secs * 1000.0) as u64) + }; + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, }); - + state.blocked_until = Some(until); + let consumed = state.consumed as f64; + drop(states); + + js_promise_resolve( + promise, + ratelimiter_res(0.0, secs * 1000.0, consumed, false), + ); promise } -/// limiter.penalty(key, points) -> Promise +/// `limiter.penalty(key, points = 1)` — add consumed points without a +/// quota check. Resolves a `RateLimiterRes`. /// -/// Add penalty points (consume extra). +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_penalty( handle: Handle, key_ptr: *const StringHeader, points: f64, ) -> *mut Promise { - js_ratelimit_consume(handle, key_ptr, points) + let promise = js_promise_new(); + let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); + let n = points.max(1.0) as u32; + + let Some(keyed) = get_handle::(handle) else { + js_promise_resolve(promise, f64::from_bits(JSValue::null().bits())); + return promise; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, + }); + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } + state.consumed += n; + let consumed = state.consumed as f64; + let ms = state.window_end.duration_since(now).as_millis() as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + + js_promise_resolve(promise, ratelimiter_res(remaining, ms, consumed, false)); + promise } -/// limiter.reward(key, points) -> Promise +/// `limiter.reward(key, points = 1)` — give consumed points back. +/// Resolves a `RateLimiterRes`. /// -/// Reward points (add back to quota). -/// Note: This is a simplified implementation that just resets the limiter. +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_reward( handle: Handle, key_ptr: *const StringHeader, - _points: f64, + points: f64, ) -> *mut Promise { let promise = js_promise_new(); let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - spawn_for_promise(promise as *mut u8, async move { - if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - - // Reset the limiter for this key (simplified reward) - let quota = Quota::with_period(Duration::from_secs(keyed.duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(keyed.points).unwrap()); - limiters.insert(key, RateLimiter::direct(quota)); - - let result = format!( - r#"{{"remainingPoints":{},"msBeforeNext":0,"consumedPoints":0,"isFirstInDuration":true}}"#, - keyed.points - ); - let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32); - Ok(JSValue::string_ptr(ptr).bits()) - } else { - Ok(JSValue::null().bits()) - } + let n = points.max(1.0) as u32; + + let Some(keyed) = get_handle::(handle) else { + js_promise_resolve(promise, f64::from_bits(JSValue::null().bits())); + return promise; + }; + + let now = Instant::now(); + let window = keyed.window(); + let mut states = keyed.states.lock().unwrap(); + let state = states.entry(key).or_insert_with(|| KeyState { + consumed: 0, + window_end: now + window, + blocked_until: None, }); + state.consumed = state.consumed.saturating_sub(n); + let consumed = state.consumed as f64; + let ms = state.window_end.duration_since(now).as_millis() as f64; + let remaining = (keyed.points as f64 - consumed).max(0.0); + drop(states); + js_promise_resolve(promise, ratelimiter_res(remaining, ms, consumed, false)); promise } @@ -303,16 +428,25 @@ pub unsafe extern "C" fn js_ratelimit_reward( // ============================================================================ /// Check if a key would be rate limited (without consuming) +/// +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const StringHeader) -> bool { let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - if let Some(limiter) = limiters.get(&key) { - return limiter.check().is_ok(); + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + if let Some(state) = states.get(&key) { + if state.blocked_until.is_some_and(|u| u > now) { + return false; + } + if now < state.window_end { + return state.consumed < keyed.points; + } } - return true; // No limiter yet means not rate limited + return true; // No live window means not rate limited } else if let Some(simple) = get_handle::(handle) { return simple.limiter.check().is_ok(); } @@ -321,6 +455,9 @@ pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const Stri } /// Get remaining points for a key +/// +/// # Safety +/// `key_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ratelimit_remaining( handle: Handle, @@ -329,10 +466,12 @@ pub unsafe extern "C" fn js_ratelimit_remaining( let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); if let Some(keyed) = get_handle::(handle) { - let limiters = keyed.limiters.lock().unwrap(); - if limiters.contains_key(&key) { - // Simplified: return max points (actual tracking would require more state) - return keyed.points as f64; + let now = Instant::now(); + let states = keyed.states.lock().unwrap(); + if let Some(state) = states.get(&key) { + if now < state.window_end { + return (keyed.points as f64 - state.consumed as f64).max(0.0); + } } return keyed.points as f64; } else if let Some(simple) = get_handle::(handle) { @@ -344,9 +483,8 @@ pub unsafe extern "C" fn js_ratelimit_remaining( /// Reset all rate limiters #[no_mangle] -pub unsafe extern "C" fn js_ratelimit_reset(handle: Handle) { +pub extern "C" fn js_ratelimit_reset(handle: Handle) { if let Some(keyed) = get_handle::(handle) { - let mut limiters = keyed.limiters.lock().unwrap(); - limiters.clear(); + keyed.states.lock().unwrap().clear(); } } diff --git a/test-files/test_gap_ratelimiter_memory.ts b/test-files/test_gap_ratelimiter_memory.ts new file mode 100644 index 0000000000..1e2083a75e --- /dev/null +++ b/test-files/test_gap_ratelimiter_memory.ts @@ -0,0 +1,47 @@ +// Gap test: rate-limiter-flexible's RateLimiterMemory must construct a +// real limiter (not `{}`) and dispatch consume/get/delete. Consumption +// counts down within the fixed window; exceeding the quota rejects with +// a RateLimiterRes-shaped value. msBeforeNext is time-dependent, so it +// is only asserted as a boolean. + +import { RateLimiterMemory } from "rate-limiter-flexible"; + +async function main() { + const limiter = new RateLimiterMemory({ points: 3, duration: 60 }); + + const r1 = await limiter.consume("alice"); + console.log("r1", r1.remainingPoints, r1.consumedPoints, r1.isFirstInDuration); + const r2 = await limiter.consume("alice"); + console.log("r2", r2.remainingPoints, r2.consumedPoints, r2.isFirstInDuration); + const r3 = await limiter.consume("alice", 1); + console.log("r3", r3.remainingPoints, r3.consumedPoints); + + // Quota exhausted: consume must reject with a res-shaped value. + try { + await limiter.consume("alice"); + console.log("unexpected: not limited"); + } catch (rej: any) { + console.log("limited", rej.remainingPoints, "msBeforeNext positive:", rej.msBeforeNext > 0); + } + + // Independent key, multi-point consume. + const bob = await limiter.consume("bob", 2); + console.log("bob", bob.remainingPoints, bob.consumedPoints, bob.isFirstInDuration); + + // get() reads without consuming; unknown key resolves null. + const got = await limiter.get("alice"); + console.log("get alice", got === null ? "null" : got.remainingPoints); + const gotNone = await limiter.get("carol"); + console.log("get carol", gotNone === null ? "null" : "not-null"); + + // delete() drops the window; the key becomes fresh again. + const del = await limiter.delete("alice"); + console.log("deleted", del); + const after = await limiter.get("alice"); + console.log("after delete", after === null ? "null" : "not-null"); + + const fresh = await limiter.consume("alice"); + console.log("fresh", fresh.remainingPoints, fresh.consumedPoints, fresh.isFirstInDuration); +} + +main(); From cfe307717f1c2c923fb1ead9ddb3ca1647000ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 08:26:02 +0200 Subject: [PATCH 6/9] fix(cron): register the npm `cron` package's CronJob constructor and drive the tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new CronJob(expr, fn)` (the npm `cron` package, distinct from node-cron) yielded a method-less object: perry-hir registered the binding as a ("cron", "CronJob") native instance and the start/stop/isRunning/nextDate dispatch rows already existed, but the constructor had no lower_builtin_new arm, so the receiver was the js_object_alloc(0,0) placeholder instead of a cron handle. Only node-cron's `schedule()` factory was wired. On top of that, two latent defects meant no cron callback could ever fire even for node-cron: 1. The generated event loop never called js_cron_timer_tick / js_cron_timer_has_pending — the CRON_TIMERS machinery existed on both the stdlib and perry-ext-cron sides (with stale "called from module_init.rs" comments) but nothing drove it, and a program whose only live work was a cron job exited immediately. The event loop in codegen/entry.rs now ticks the cron queue each iteration and ORs js_cron_timer_has_pending into the keep-alive gate. Runtime-only links get no-op fallbacks in perry-runtime's stdlib_stubs.rs (same pattern as js_stdlib_process_pending); the real implementations in perry-stdlib / perry-ext-cron win when linked. 2. The node-cron schedule row passed the callback as raw NaN-box bits (NA_JSV), but the tick invokes it via js_closure_call0 on a raw ClosureHeader pointer — the tagged bits threw "value is not a function" on the first fire. The row now unboxes (NA_PTR), and the new CronJob arm does the same. The fix itself: - New runtime entry point js_cron_job_new(expr, callback, start) in perry-ext-cron and the bundled perry-stdlib copy: registers a CronJobHandle on the shared CRON_TIMERS machinery but — matching the npm package, and unlike node-cron's auto-starting schedule() — only arms the timer when the 4th constructor argument (`start`) is truthy. `job.start()` / `job.stop()` then use the existing js_cron_job_start/stop semantics (start re-arms with a fresh deadline; stop clears the queue entry so the event loop can exit). - lower_builtin_new gains a "CronJob" arm gated on the cron/node-cron import source (#602 pattern); onComplete is lowered for side effects only; 5-field expressions are normalized to 6-field like js_cron_schedule. - api-manifest gains the class("cron", "CronJob") entry. Verified byte-identical against node + npm `cron` (TZ=UTC): manual start/stop, no-auto-start on construction, and the 4-arg start=true form all fire and stop on schedule. --- .../perry-api-manifest/src/entries/part_2.rs | 5 ++ crates/perry-codegen/src/codegen/entry.rs | 14 +++- .../perry-codegen/src/lower_call/builtin.rs | 43 +++++++++++ .../src/lower_call/native_table/media.rs | 7 +- .../runtime_decls/stdlib_ffi/third_party.rs | 3 + crates/perry-ext-cron/src/lib.rs | 72 +++++++++++++++++++ crates/perry-runtime/src/stdlib_stubs.rs | 21 ++++++ crates/perry-stdlib/src/cron.rs | 67 +++++++++++++++++ test-files/test_gap_cron_cronjob.ts | 46 ++++++++++++ 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_cron_cronjob.ts diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index e5e2568b99..bc1fbadcb8 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -621,6 +621,11 @@ pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ method("cron", "stop", true, None), method("cron", "isRunning", true, None), method("cron", "nextDate", true, None), + // npm `cron` package class form: `new CronJob(cronTime, onTick, + // onComplete?, start?)` — constructed by the lower_builtin_new arm + // (js_cron_job_new; no auto-start, matching the npm package). The + // instance methods reuse the ("cron", true, …) rows above. + class("cron", "CronJob"), method_sig( "perry/tui", "Text", diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 88460db72c..7f3430763b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -901,6 +901,16 @@ pub(super) fn compile_module_entry( let has_timers = ctx.block().call(I32, "js_timer_has_pending", &[]); let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]); let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]); + // Cron jobs (node-cron schedule() / npm cron's CronJob). + // The symbol always resolves: perry-ext-cron or the + // bundled scheduler provide the real queue; perry-stdlib + // exports a 0-returning stub otherwise. Without this gate + // (and the tick in loop_body below) a program whose only + // live work is a running cron job exits immediately and + // scheduled callbacks never fire — the CRON_TIMERS + // machinery existed but nothing in the generated event + // loop drove it. + let has_cron = ctx.block().call(I32, "js_cron_timer_has_pending", &[]); let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); // #591: TASK_QUEUE may carry a pending `.then` continuation // that was queued by `js_run_stdlib_pump`'s resolution path @@ -912,7 +922,8 @@ pub(super) fn compile_module_entry( let any1 = ctx.block().or(I32, &has_timers, &has_callbacks); let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib); let any3 = ctx.block().or(I32, &any1, &any2); - let any = ctx.block().or(I32, &any3, &has_microtasks); + let any4 = ctx.block().or(I32, &any3, &has_cron); + let any = ctx.block().or(I32, &any4, &has_microtasks); let cmp = ctx.block().icmp_ne(I32, &any, &zero); ctx.block().cond_br(&cmp, &body_label, &exit_label); @@ -924,6 +935,7 @@ pub(super) fn compile_module_entry( let _ = ctx.block().call(I32, "js_timer_tick", &[]); let _ = ctx.block().call(I32, "js_callback_timer_tick", &[]); let _ = ctx.block().call(I32, "js_interval_timer_tick", &[]); + let _ = ctx.block().call(I32, "js_cron_timer_tick", &[]); ctx.block().call_void("js_run_stdlib_pump", &[]); // Issue #84: condvar-backed wait. Returns immediately when // a tokio worker (net/ws/http/fetch/redis/spawn) notifies diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 8142f0464e..d06ef28eec 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -52,6 +52,7 @@ pub(super) fn lower_builtin_new( "MongoClient" => Some(&["mongodb"]), "Decimal" => Some(&["decimal.js"]), "RateLimiterMemory" => Some(&["rate-limiter-flexible"]), + "CronJob" => Some(&["cron", "node-cron"]), _ => None, }; if let Some(sources) = required_sources { @@ -725,6 +726,48 @@ pub(super) fn lower_builtin_new( ); Ok(Some(nanbox_pointer_inline(blk, &handle))) } + // npm `cron` package: `new CronJob(cronTime, onTick, onComplete?, + // start?)`. Gated on the import source above. Unlike node-cron's + // `schedule()` factory (which auto-starts), a CronJob only begins + // firing when the 4th argument is truthy or `job.start()` is + // called — `js_cron_job_new` implements that. The onTick closure + // is UNBOXED to a raw ClosureHeader pointer (unbox_to_i64): the + // cron tick calls it via js_closure_call0 on the raw pointer, so + // tagged NaN-box bits would throw "value is not a function" on + // the first fire. onComplete is lowered for side effects only. + // start/stop/isRunning/nextDate dispatch via the existing + // ("cron", true, …) NATIVE_MODULE_TABLE rows. + "CronJob" => { + let expr_ptr = if let Some(arg) = args.first() { + get_raw_string_ptr(ctx, arg)? + } else { + "0".to_string() + }; + let on_tick = if let Some(arg) = args.get(1) { + lower_expr(ctx, arg)? + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + if let Some(arg) = args.get(2) { + let _ = lower_expr(ctx, arg)?; + } + let start = if let Some(arg) = args.get(3) { + lower_expr(ctx, arg)? + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + for arg in args.iter().skip(4) { + let _ = lower_expr(ctx, arg)?; + } + let blk = ctx.block(); + let cb_ptr = unbox_to_i64(blk, &on_tick); + let handle = blk.call( + I64, + "js_cron_job_new", + &[(I64, &expr_ptr), (I64, &cb_ptr), (DOUBLE, &start)], + ); + Ok(Some(nanbox_pointer_inline(blk, &handle))) + } // async_hooks.AsyncLocalStorage — `new AsyncLocalStorage()` produces a // real handle so `.run(store, cb)` / `.getStore()` / `.enterWith(store)` // / `.exit(cb)` / `.disable()` find their registered store stack. diff --git a/crates/perry-codegen/src/lower_call/native_table/media.rs b/crates/perry-codegen/src/lower_call/native_table/media.rs index a907ca169a..0364f36ba3 100644 --- a/crates/perry-codegen/src/lower_call/native_table/media.rs +++ b/crates/perry-codegen/src/lower_call/native_table/media.rs @@ -677,13 +677,18 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ args: &[NA_STR], ret: NR_F64, }, + // schedule's callback must be the raw ClosureHeader pointer (NA_PTR + // unbox) — the cron tick invokes it via js_closure_call0 on the raw + // pointer, so passing tagged NaN-box bits (the old NA_JSV) threw + // "value is not a function" on the first fire. Latent until the + // event loop actually drove js_cron_timer_tick. NativeModSig { module: "cron", has_receiver: false, method: "schedule", class_filter: None, runtime: "js_cron_schedule", - args: &[NA_STR, NA_JSV], + args: &[NA_STR, NA_PTR], ret: NR_PTR, }, NativeModSig { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index cb943dd56c..a7cbd84a66 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -122,6 +122,9 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_cron_clear_timeout", VOID, &[I64]); module.declare_function("js_cron_describe", I64, &[I64]); module.declare_function("js_cron_job_is_running", DOUBLE, &[I64]); + // npm `cron` CronJob ctor arm in lower_call/builtin.rs — + // (expr StringHeader, onTick closure bits, NaN-boxed start flag). + module.declare_function("js_cron_job_new", I64, &[I64, I64, DOUBLE]); module.declare_function("js_cron_job_start", VOID, &[I64]); module.declare_function("js_cron_job_stop", VOID, &[I64]); module.declare_function("js_cron_next_date", I64, &[I64]); diff --git a/crates/perry-ext-cron/src/lib.rs b/crates/perry-ext-cron/src/lib.rs index 2f7a5ac18c..643cbc7acf 100644 --- a/crates/perry-ext-cron/src/lib.rs +++ b/crates/perry-ext-cron/src/lib.rs @@ -243,6 +243,78 @@ pub unsafe extern "C" fn js_cron_schedule(expr_ptr: *const StringHeader, callbac }) } +extern "C" { + /// perry-runtime: JS truthiness probe for a NaN-boxed value. + fn js_is_truthy(value: f64) -> i32; +} + +/// `new CronJob(cronTime, onTick, onComplete?, start?)` — the npm `cron` +/// package's constructor. Unlike node-cron's `schedule()` factory, a +/// CronJob does **not** start automatically: it only begins firing when +/// the 4th constructor argument is truthy or `job.start()` is called. +/// +/// `callback` is the raw closure pointer (i64), same convention as +/// `js_cron_schedule`. `start` is the NaN-boxed 4th argument +/// (TAG_UNDEFINED when absent → not started). +/// +/// # Safety +/// `expr_ptr` must be null or a Perry-runtime `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_cron_job_new( + expr_ptr: *const StringHeader, + callback: i64, + start: f64, +) -> Handle { + ensure_gc_scanner_registered(); + + let expr = match read_str(expr_ptr) { + Some(e) => e, + None => return -1, + }; + // The `cron` package accepts 5-field (no seconds) and 6-field forms. + let expr = if expr.split_whitespace().count() == 5 { + format!("0 {}", expr) + } else { + expr + }; + let schedule = match Schedule::from_str(&expr) { + Ok(s) => s, + Err(_) => return -1, + }; + + let start_now = js_is_truthy(start) != 0; + + let timer_id = { + let mut next = CRON_NEXT_TIMER_ID.lock().unwrap(); + let id = *next; + *next += 1; + id + }; + let running = Arc::new(AtomicBool::new(start_now)); + + if start_now { + if let Some(next_deadline) = next_cron_instant(&schedule) { + if let Ok(mut q) = CRON_TIMERS.lock() { + q.push(CronTimer { + id: timer_id, + schedule: schedule.clone(), + callback, + next_deadline, + running: running.clone(), + cleared: false, + }); + } + } + } + + register_handle(CronJobHandle { + schedule, + running, + callback, + timer_id, + }) +} + #[no_mangle] pub extern "C" fn js_cron_job_start(handle: Handle) { let job = match get_handle::(handle) { diff --git a/crates/perry-runtime/src/stdlib_stubs.rs b/crates/perry-runtime/src/stdlib_stubs.rs index 5bda201b3b..20ae4684c4 100644 --- a/crates/perry-runtime/src/stdlib_stubs.rs +++ b/crates/perry-runtime/src/stdlib_stubs.rs @@ -133,6 +133,27 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { 0 } +// === Cron scheduler stubs === +// The generated event loop calls js_cron_timer_tick / +// js_cron_timer_has_pending unconditionally each iteration (cron jobs +// otherwise never fire — the CRON_TIMERS machinery existed but nothing +// drove it). Runtime-only links (no perry-stdlib) need these no-op +// fallbacks so the binary links; with perry-stdlib or perry-ext-cron +// linked, the real queue implementations win. Android's stdlib stubs +// cover that target independently. +#[cfg(not(target_os = "android"))] +#[no_mangle] +pub extern "C" fn js_cron_timer_tick() -> i32 { + // Hot-loop drain — silent stub, same as js_stdlib_process_pending. + 0 +} + +#[cfg(not(target_os = "android"))] +#[no_mangle] +pub extern "C" fn js_cron_timer_has_pending() -> i32 { + 0 +} + #[cfg(not(target_os = "android"))] #[no_mangle] pub extern "C" fn js_stdlib_init_dispatch() { diff --git a/crates/perry-stdlib/src/cron.rs b/crates/perry-stdlib/src/cron.rs index 0dec79519f..0587c87d4b 100644 --- a/crates/perry-stdlib/src/cron.rs +++ b/crates/perry-stdlib/src/cron.rs @@ -307,6 +307,73 @@ pub unsafe extern "C" fn js_cron_schedule(expr_ptr: *const StringHeader, callbac }) } +/// `new CronJob(cronTime, onTick, onComplete?, start?)` — the npm `cron` +/// package's constructor. Unlike node-cron's `schedule()` factory, a +/// CronJob does **not** start automatically: it only begins firing when +/// the 4th constructor argument is truthy or `job.start()` is called. +/// +/// `callback` is the raw closure pointer (i64), same convention as +/// `js_cron_schedule`. `start` is the NaN-boxed 4th argument +/// (TAG_UNDEFINED when absent → not started). +/// +/// # Safety +/// `expr_ptr` must be null or a Perry-runtime `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_cron_job_new( + expr_ptr: *const StringHeader, + callback: i64, + start: f64, +) -> Handle { + ensure_gc_scanner_registered(); + + let expr = match string_from_header(expr_ptr) { + Some(e) => e, + None => return -1, + }; + // The `cron` package accepts 5-field (no seconds) and 6-field forms. + let expr = if expr.split_whitespace().count() == 5 { + format!("0 {}", expr) + } else { + expr + }; + let schedule = match Schedule::from_str(&expr) { + Ok(s) => s, + Err(_) => return -1, + }; + + let start_now = perry_runtime::value::js_is_truthy(start) != 0; + + let timer_id = { + let mut next = CRON_NEXT_TIMER_ID.lock().unwrap(); + let id = *next; + *next += 1; + id + }; + let running = Arc::new(AtomicBool::new(start_now)); + + if start_now { + if let Some(next_deadline) = next_cron_instant(&schedule) { + if let Ok(mut q) = CRON_TIMERS.lock() { + q.push(CronTimer { + id: timer_id, + schedule: schedule.clone(), + callback, + next_deadline, + running: running.clone(), + cleared: false, + }); + } + } + } + + register_handle(CronJobHandle { + schedule, + running, + callback, + timer_id, + }) +} + /// job.start() -> void /// /// Start (or re-start) the scheduled job. After `stop()` removed it, `start()` diff --git a/test-files/test_gap_cron_cronjob.ts b/test-files/test_gap_cron_cronjob.ts new file mode 100644 index 0000000000..a8c43f8103 --- /dev/null +++ b/test-files/test_gap_cron_cronjob.ts @@ -0,0 +1,46 @@ +// Gap test: the npm `cron` package's CronJob class (distinct from +// node-cron's schedule() factory). `new CronJob(expr, fn)` must NOT +// auto-start; the 4-arg form with start=true must; start()/stop() must +// dispatch. Tick counts are asserted as booleans and only the first two +// manual ticks print, so output is deterministic despite the timing. + +import { CronJob } from "cron"; + +async function main() { + let ticks = 0; + const job = new CronJob("* * * * * *", () => { + ticks++; + if (ticks <= 2) { + console.log("tick", ticks); + } + }); + console.log("constructed, ticks now:", ticks); + + // A never-started job must not fire (would print below and break the diff). + const never = new CronJob("* * * * * *", () => { + console.log("SHOULD-NOT-RUN"); + }); + + // 4-arg form: onComplete null, start=true — begins firing immediately. + let autoTicks = 0; + const auto = new CronJob( + "* * * * * *", + () => { + autoTicks++; + }, + null, + true + ); + + job.start(); + await new Promise((resolve) => setTimeout(resolve, 3200)); + job.stop(); + auto.stop(); + + console.log("manual ticked at least twice:", ticks >= 2); + console.log("auto ticked at least twice:", autoTicks >= 2); + console.log("never-started stayed quiet:", true); + console.log("done"); +} + +main(); From c577282024da7275eca7694982830d0002ebc84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 09:37:46 +0200 Subject: [PATCH 7/9] fix(codegen): gate the cron event-loop tick on needs_stdlib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit js_cron_timer_tick / js_cron_timer_has_pending are defined by perry-stdlib (src/cron.rs) and perry-ext-cron. A runtime-only link — a program that imports neither — carries neither definition, so the unconditional calls emitted into the event loop left the symbols undefined at link time. Gate both on cross_module.needs_stdlib, mirroring the existing js_stdlib_init_dispatch guard above: any program that can schedule a cron job necessarily pulls stdlib in, so the gate never suppresses a tick a live job needs. --- crates/perry-codegen/src/codegen/entry.rs | 30 +++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 7f3430763b..640a8939c8 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -902,15 +902,23 @@ pub(super) fn compile_module_entry( let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]); let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]); // Cron jobs (node-cron schedule() / npm cron's CronJob). - // The symbol always resolves: perry-ext-cron or the - // bundled scheduler provide the real queue; perry-stdlib - // exports a 0-returning stub otherwise. Without this gate - // (and the tick in loop_body below) a program whose only - // live work is a running cron job exits immediately and - // scheduled callbacks never fire — the CRON_TIMERS - // machinery existed but nothing in the generated event - // loop drove it. - let has_cron = ctx.block().call(I32, "js_cron_timer_has_pending", &[]); + // Guarded on `needs_stdlib` like js_stdlib_init_dispatch + // above — the runtime-only link doesn't carry the cron + // symbols (and a cron import always pulls stdlib in). + // With stdlib linked the symbol always resolves: + // perry-ext-cron or the bundled scheduler provide the + // real queue; perry-stdlib exports a 0-returning stub + // otherwise. Without this gate (and the tick in + // loop_body below) a program whose only live work is a + // running cron job exits immediately and scheduled + // callbacks never fire — the CRON_TIMERS machinery + // existed but nothing in the generated event loop drove + // it. + let has_cron = if cross_module.needs_stdlib { + ctx.block().call(I32, "js_cron_timer_has_pending", &[]) + } else { + "0".to_string() + }; let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); // #591: TASK_QUEUE may carry a pending `.then` continuation // that was queued by `js_run_stdlib_pump`'s resolution path @@ -935,7 +943,9 @@ pub(super) fn compile_module_entry( let _ = ctx.block().call(I32, "js_timer_tick", &[]); let _ = ctx.block().call(I32, "js_callback_timer_tick", &[]); let _ = ctx.block().call(I32, "js_interval_timer_tick", &[]); - let _ = ctx.block().call(I32, "js_cron_timer_tick", &[]); + if cross_module.needs_stdlib { + let _ = ctx.block().call(I32, "js_cron_timer_tick", &[]); + } ctx.block().call_void("js_run_stdlib_pump", &[]); // Issue #84: condvar-backed wait. Returns immediately when // a tokio worker (net/ws/http/fetch/redis/spawn) notifies From 00aa8de8dae2dd5671243c76e7221786cbc6c201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 09:37:46 +0200 Subject: [PATCH 8/9] test(gap): make the dayjs/moment gap tests timezone-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests asserted a mix of wall-clock and instant values, which only agreed with node under TZ=UTC: dayjs/moment read offset-less input in the host timezone, while Perry's bundled bindings (perry-stdlib/src/{dayjs,moment}.rs) are UTC-based throughout. On a non-UTC host — i.e. any normal dev machine — the suite went red while passing in CI. Assert wall-clock inputs only through wall-clock observations (format, component getters) and instants only from offset-bearing input or epoch numbers. This still pins the bugs the fixes address (the factory used to ignore its argument; moment's methods didn't dispatch) and is now byte-identical to node under UTC, Europe/Berlin, Asia/Tokyo, America/New_York, America/Sao_Paulo and Australia/Lord_Howe. The UTC-vs-local mismatch itself, and a .valueOf() fold that makes dayjs(x).valueOf() chained inline yield the raw handle, are separate gaps noted in the test comments. --- test-files/test_gap_dayjs_factory_arg.ts | 29 +++++++++++++++++------- test-files/test_gap_moment_methods.ts | 24 +++++++++++++++----- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/test-files/test_gap_dayjs_factory_arg.ts b/test-files/test_gap_dayjs_factory_arg.ts index c7c92084f7..063347552e 100644 --- a/test-files/test_gap_dayjs_factory_arg.ts +++ b/test-files/test_gap_dayjs_factory_arg.ts @@ -1,25 +1,38 @@ // Gap test: the dayjs factory must parse its argument (ISO strings and -// epoch milliseconds) instead of always returning "now". Run with -// TZ=UTC on both sides — dayjs treats offset-less inputs as local time -// and Perry's date runtime is UTC-based. +// epoch milliseconds) instead of always returning "now". +// +// Every assertion here is timezone-independent, so the test is +// deterministic on any host: wall-clock inputs are only ever observed as +// wall-clock (format / component getters), and instants are only ever +// asserted from offset-bearing input or epoch numbers. Mixing the two — +// e.g. `dayjs("2024-01-15").valueOf()` — is deliberately avoided: dayjs +// reads offset-less input in the host timezone, while Perry's bundled +// dayjs binding (perry-stdlib/src/dayjs.rs, the `bundled-dayjs` feature) +// is UTC-based throughout. That mismatch is a separate known gap; pinning +// it here would make the test pass only under TZ=UTC. import dayjs from "dayjs"; -// Bare date string. +// Bare date string — the headline bug: this used to return "now". const d = dayjs("2024-01-15"); console.log(d.format("YYYY-MM-DD")); console.log(d.year(), d.month(), d.date(), d.day()); -console.log(d.valueOf()); -// Offset-less ISO datetime. +// Offset-less ISO datetime, observed as wall-clock. const dt = dayjs("2024-03-05T06:07:08"); console.log(dt.format("YYYY-MM-DD HH:mm:ss")); console.log(dt.hour(), dt.minute(), dt.second()); -// Epoch milliseconds. +// Offset-bearing input pins a real instant in every timezone. +// NB: the receiver is bound first — `dayjs(x).valueOf()` chained inline +// off the factory call currently yields the raw handle instead of the +// epoch (a separate `.valueOf()`-fold gap; `.format()` is unaffected). +const fixed = dayjs("2024-01-15T00:00:00Z"); +console.log(fixed.valueOf()); + +// Epoch milliseconds round-trip. const epoch = dayjs(1700000000000); console.log(epoch.valueOf()); -console.log(epoch.format("YYYY-MM-DD HH:mm:ss")); // Arithmetic on parsed dates (dayjs is immutable — no clone needed). const plus = d.add(7, "day"); diff --git a/test-files/test_gap_moment_methods.ts b/test-files/test_gap_moment_methods.ts index 0ab408f826..8bc5a7566d 100644 --- a/test-files/test_gap_moment_methods.ts +++ b/test-files/test_gap_moment_methods.ts @@ -1,25 +1,37 @@ // Gap test: moment instance methods (format/add/subtract/diff/field // accessors/predicates) must dispatch — only the factory used to be -// wired, so m.format() returned undefined. Run with TZ=UTC on both -// sides. moment mutates on add/subtract, so arithmetic always goes -// through an explicit clone binding and the original is only read -// before/independently of the mutation. +// wired, so m.format() returned undefined. moment mutates on +// add/subtract, so arithmetic always goes through an explicit clone +// binding and the original is only read before/independently of the +// mutation. +// +// Every assertion here is timezone-independent, so the test is +// deterministic on any host: wall-clock inputs are only ever observed as +// wall-clock (format / component getters), and instants are only ever +// asserted from offset-bearing input or epoch numbers. Mixing the two — +// e.g. `moment("2024-01-15").valueOf()` — is deliberately avoided: moment +// reads offset-less input in the host timezone, while Perry's bundled +// moment binding (perry-stdlib/src/moment.rs) is UTC-based throughout. +// That mismatch is a separate known gap; pinning it here would make the +// test pass only under TZ=UTC. import moment from "moment"; const m = moment("2024-01-15"); console.log(m.format("YYYY-MM-DD")); console.log(m.year(), m.month(), m.date(), m.day()); -console.log(m.valueOf(), m.unix()); console.log(m.isValid() ? "valid" : "invalid"); const m2 = moment("2024-03-05T06:07:08"); console.log(m2.format("YYYY-MM-DD HH:mm:ss")); console.log(m2.hour(), m2.minute(), m2.second()); +// Offset-bearing input pins a real instant in every timezone. +const fixed = moment("2024-01-15T00:00:00Z"); +console.log(fixed.valueOf(), fixed.unix()); + const epoch = moment(1700000000000); console.log(epoch.valueOf()); -console.log(epoch.format("YYYY-MM-DD HH:mm:ss")); // Arithmetic via clone (moment's add/subtract mutate the receiver). const mc = m.clone(); From e00a008e0fbd65892bc612a073b9c203a9607608 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 04:12:50 -0700 Subject: [PATCH 9/9] fix(stdlib): address CodeRabbit safe fixes (handle guards, saturating overflow, block-aware introspection, reward-window reset, moment/dayjs invalid inputs, slugify optional arg) --- .../perry-api-manifest/src/entries/part_1.rs | 18 ++++++++++++-- crates/perry-ext-dayjs/src/lib.rs | 11 ++++++--- crates/perry-ext-moment/src/lib.rs | 22 +++++++++++++++-- crates/perry-ext-ratelimit/src/lib.rs | 24 +++++++++++++++---- crates/perry-ext-slugify/src/lib.rs | 2 +- crates/perry-stdlib/src/dayjs.rs | 11 ++++++--- crates/perry-stdlib/src/moment.rs | 19 +++++++++++++-- crates/perry-stdlib/src/ratelimit.rs | 24 +++++++++++++++---- crates/perry-stdlib/src/slugify.rs | 2 +- 9 files changed, 111 insertions(+), 22 deletions(-) diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 2ef4d1f74c..03ca51cdf9 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1200,7 +1200,14 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ "default", false, None, - &[p_str("p0"), p_any("p1")], + &[ + p_str("p0"), + ParamSpec::Named { + name: "p1", + ty: TypeSpec::Any, + optional: true, + }, + ], TypeSpec::String, ), method_sig( @@ -1208,7 +1215,14 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ "slugify", false, None, - &[p_str("p0"), p_any("p1")], + &[ + p_str("p0"), + ParamSpec::Named { + name: "p1", + ty: TypeSpec::Any, + optional: true, + }, + ], TypeSpec::String, ), method_sig( diff --git a/crates/perry-ext-dayjs/src/lib.rs b/crates/perry-ext-dayjs/src/lib.rs index e5d0836863..4c34ef2b3c 100644 --- a/crates/perry-ext-dayjs/src/lib.rs +++ b/crates/perry-ext-dayjs/src/lib.rs @@ -39,9 +39,14 @@ pub extern "C" fn js_dayjs_now() -> f64 { #[no_mangle] pub extern "C" fn js_dayjs_from_timestamp(timestamp: f64) -> f64 { - let secs = (timestamp / 1000.0) as i64; - let nanos = ((timestamp % 1000.0) * 1_000_000.0) as u32; - if let Some(dt) = DateTime::from_timestamp(secs, nanos) { + // NaN / ±∞ → Invalid Date (dayjs's invalid sentinel is `0.0`). + if !timestamp.is_finite() { + return 0.0; + } + // Millisecond-safe conversion: `from_timestamp_millis` handles + // negative epochs correctly (the old secs/nanos split truncated + // negative fractions toward zero and saturated `nanos` to 0). + if let Some(dt) = DateTime::from_timestamp_millis(timestamp as i64) { handle_to_f64(register_handle(DayjsHandle::new(dt))) } else { 0.0 diff --git a/crates/perry-ext-moment/src/lib.rs b/crates/perry-ext-moment/src/lib.rs index 15fe1122de..4d87b546c6 100644 --- a/crates/perry-ext-moment/src/lib.rs +++ b/crates/perry-ext-moment/src/lib.rs @@ -141,8 +141,21 @@ pub unsafe extern "C" fn js_moment_factory(value_bits: i64) -> f64 { if n.is_finite() { return js_moment_from_timestamp(n); } + // NaN / ±∞ → Invalid Date. + return handle_to_f64(register_handle(MomentHandle { + datetime: Utc::now(), + is_valid: false, + })); } - js_moment_now() + if jv.is_undefined() { + return js_moment_now(); + } + // `null` (and any other non-undefined, non-number, non-string input) + // → Invalid Date, matching moment(null); only undefined maps to now. + handle_to_f64(register_handle(MomentHandle { + datetime: Utc::now(), + is_valid: false, + })) } /// # Safety @@ -182,7 +195,12 @@ pub unsafe extern "C" fn js_moment_format( #[no_mangle] pub extern "C" fn js_moment_to_iso_string(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { - return alloc_string(&moment.datetime.to_rfc3339()).as_raw(); + return alloc_string( + &moment + .datetime + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + ) + .as_raw(); } std::ptr::null_mut() } diff --git a/crates/perry-ext-ratelimit/src/lib.rs b/crates/perry-ext-ratelimit/src/lib.rs index 3a3a39233d..45baa78cfb 100644 --- a/crates/perry-ext-ratelimit/src/lib.rs +++ b/crates/perry-ext-ratelimit/src/lib.rs @@ -129,7 +129,7 @@ pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Han let jv = JsValue::from_bits(options_bits as u64); if jv.is_pointer() { let obj = jv.as_pointer::(); - if !obj.is_null() && (obj as usize) >= 0x1000 { + if !obj.is_null() && (obj as usize) >= 0x100000 { let field = |name: &str| -> f64 { let key = alloc_string(name); js_object_get_field_by_name_f64(obj, key.as_raw()) @@ -210,7 +210,7 @@ pub unsafe extern "C" fn js_ratelimit_consume( state.window_end = now + window; } - state.consumed += consume_points; + state.consumed = state.consumed.saturating_add(consume_points); let is_first = state.consumed == consume_points; let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; let consumed = state.consumed as f64; @@ -257,7 +257,14 @@ pub unsafe extern "C" fn js_ratelimit_get( .duration_since(now) .as_millis() as f64; let consumed = state.consumed as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); + // A key still inside an active block reports zero remaining + // points even after its normal window would have expired. + let blocked = state.blocked_until.is_some_and(|u| u > now); + let remaining = if blocked { + 0.0 + } else { + (keyed.points as f64 - consumed).max(0.0) + }; let res = ratelimiter_res(remaining, ms, consumed, false); drop(states); promise.resolve(res); @@ -369,7 +376,7 @@ pub unsafe extern "C" fn js_ratelimit_penalty( state.consumed = 0; state.window_end = now + window; } - state.consumed += n; + state.consumed = state.consumed.saturating_add(n); let consumed = state.consumed as f64; let ms = state.window_end.duration_since(now).as_millis() as f64; let remaining = (keyed.points as f64 - consumed).max(0.0); @@ -408,6 +415,12 @@ pub unsafe extern "C" fn js_ratelimit_reward( window_end: now + window, blocked_until: None, }); + // Reset an expired window before reward math so a delayed reward() + // cannot underflow the (now stale) window_end in `duration_since`. + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } state.consumed = state.consumed.saturating_sub(n); let consumed = state.consumed as f64; let ms = state.window_end.duration_since(now).as_millis() as f64; @@ -457,6 +470,9 @@ pub unsafe extern "C" fn js_ratelimit_remaining( let now = Instant::now(); let states = keyed.states.lock().unwrap(); if let Some(state) = states.get(&key) { + if state.blocked_until.is_some_and(|u| u > now) { + return 0.0; + } if now < state.window_end { return (keyed.points as f64 - state.consumed as f64).max(0.0); } diff --git a/crates/perry-ext-slugify/src/lib.rs b/crates/perry-ext-slugify/src/lib.rs index 2200ad3e28..12e7e3a0d8 100644 --- a/crates/perry-ext-slugify/src/lib.rs +++ b/crates/perry-ext-slugify/src/lib.rs @@ -247,7 +247,7 @@ unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { return opts; } let obj = jv.as_pointer::(); - if obj.is_null() || (obj as usize) < 0x1000 { + if obj.is_null() || (obj as usize) < 0x100000 { return opts; } diff --git a/crates/perry-stdlib/src/dayjs.rs b/crates/perry-stdlib/src/dayjs.rs index 701e63e229..ce3c2ff56b 100644 --- a/crates/perry-stdlib/src/dayjs.rs +++ b/crates/perry-stdlib/src/dayjs.rs @@ -56,10 +56,15 @@ pub extern "C" fn js_dayjs_now() -> f64 { /// Create a dayjs object from a Unix timestamp (milliseconds). #[no_mangle] pub extern "C" fn js_dayjs_from_timestamp(timestamp: f64) -> f64 { - let secs = (timestamp / 1000.0) as i64; - let nanos = ((timestamp % 1000.0) * 1_000_000.0) as u32; + // NaN / ±∞ → Invalid Date (dayjs's invalid sentinel is `0.0`). + if !timestamp.is_finite() { + return 0.0; // Invalid timestamp + } - if let Some(dt) = DateTime::from_timestamp(secs, nanos) { + // Millisecond-safe conversion: `from_timestamp_millis` handles + // negative epochs correctly (the old secs/nanos split truncated + // negative fractions toward zero and saturated `nanos` to 0). + if let Some(dt) = DateTime::from_timestamp_millis(timestamp as i64) { let handle = register_handle(DayjsHandle::new(dt)); handle_to_f64(handle) } else { diff --git a/crates/perry-stdlib/src/moment.rs b/crates/perry-stdlib/src/moment.rs index 7415905d55..3cda2edec4 100644 --- a/crates/perry-stdlib/src/moment.rs +++ b/crates/perry-stdlib/src/moment.rs @@ -147,8 +147,21 @@ pub unsafe extern "C" fn js_moment_factory(value_bits: i64) -> f64 { if n.is_finite() { return js_moment_from_timestamp(n); } + // NaN / ±∞ → Invalid Date. + return handle_to_f64(register_handle(MomentHandle { + datetime: Utc::now(), + is_valid: false, + })); } - js_moment_now() + if jv.is_undefined() { + return js_moment_now(); + } + // `null` (and any other non-undefined, non-number, non-string input) + // → Invalid Date, matching moment(null); only undefined maps to now. + handle_to_f64(register_handle(MomentHandle { + datetime: Utc::now(), + is_valid: false, + })) } /// moment.format(formatString) -> string @@ -192,7 +205,9 @@ pub unsafe extern "C" fn js_moment_format( #[no_mangle] pub unsafe extern "C" fn js_moment_to_iso_string(handle: i64) -> *mut StringHeader { if let Some(moment) = get_handle::(handle) { - let iso = moment.datetime.to_rfc3339(); + let iso = moment + .datetime + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); return js_string_from_bytes(iso.as_ptr(), iso.len() as u32); } std::ptr::null_mut() diff --git a/crates/perry-stdlib/src/ratelimit.rs b/crates/perry-stdlib/src/ratelimit.rs index 8bc021b8c7..7e0a97440b 100644 --- a/crates/perry-stdlib/src/ratelimit.rs +++ b/crates/perry-stdlib/src/ratelimit.rs @@ -143,7 +143,7 @@ pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Han let jv = JSValue::from_bits(options_bits as u64); if jv.is_pointer() { let obj = jv.as_pointer::(); - if !obj.is_null() && (obj as usize) >= 0x1000 { + if !obj.is_null() && (obj as usize) >= 0x100000 { let field = |name: &[u8]| -> f64 { let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); perry_runtime::object::js_object_get_field_by_name_f64(obj, key) @@ -217,7 +217,7 @@ pub unsafe extern "C" fn js_ratelimit_consume( state.window_end = now + window; } - state.consumed += consume_points; + state.consumed = state.consumed.saturating_add(consume_points); let is_first = state.consumed == consume_points; let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; let consumed = state.consumed as f64; @@ -264,7 +264,14 @@ pub unsafe extern "C" fn js_ratelimit_get( .duration_since(now) .as_millis() as f64; let consumed = state.consumed as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); + // A key still inside an active block reports zero remaining + // points even after its normal window would have expired. + let blocked = state.blocked_until.is_some_and(|u| u > now); + let remaining = if blocked { + 0.0 + } else { + (keyed.points as f64 - consumed).max(0.0) + }; let res = ratelimiter_res(remaining, ms, consumed, false); drop(states); js_promise_resolve(promise, res); @@ -375,7 +382,7 @@ pub unsafe extern "C" fn js_ratelimit_penalty( state.consumed = 0; state.window_end = now + window; } - state.consumed += n; + state.consumed = state.consumed.saturating_add(n); let consumed = state.consumed as f64; let ms = state.window_end.duration_since(now).as_millis() as f64; let remaining = (keyed.points as f64 - consumed).max(0.0); @@ -413,6 +420,12 @@ pub unsafe extern "C" fn js_ratelimit_reward( window_end: now + window, blocked_until: None, }); + // Reset an expired window before reward math so a delayed reward() + // cannot underflow the (now stale) window_end in `duration_since`. + if now >= state.window_end { + state.consumed = 0; + state.window_end = now + window; + } state.consumed = state.consumed.saturating_sub(n); let consumed = state.consumed as f64; let ms = state.window_end.duration_since(now).as_millis() as f64; @@ -469,6 +482,9 @@ pub unsafe extern "C" fn js_ratelimit_remaining( let now = Instant::now(); let states = keyed.states.lock().unwrap(); if let Some(state) = states.get(&key) { + if state.blocked_until.is_some_and(|u| u > now) { + return 0.0; + } if now < state.window_end { return (keyed.points as f64 - state.consumed as f64).max(0.0); } diff --git a/crates/perry-stdlib/src/slugify.rs b/crates/perry-stdlib/src/slugify.rs index e0629eb0d4..b03f1eae6f 100644 --- a/crates/perry-stdlib/src/slugify.rs +++ b/crates/perry-stdlib/src/slugify.rs @@ -238,7 +238,7 @@ unsafe fn options_from_bits(options_bits: i64) -> SlugifyOptions { return opts; } let obj = jv.as_pointer::(); - if obj.is_null() || (obj as usize) < 0x1000 { + if obj.is_null() || (obj as usize) < 0x100000 { return opts; }