From 9ccb4ee33f68c5640866d5f541f9a31cd10e10d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 11 Jun 2026 12:58:21 +0200 Subject: [PATCH 1/2] fix(stdlib): real semantics or loud warn for silently-ignored adapter options and no-ops (#4917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zlib: deflate-family stream factories + deflateRawSync honor options.level (threaded through make_codec_state; .reset() rebuilds at the same level); a supplied dictionary warns once via the shared runtime validator (covers ext-zlib too); Brotli/zstd factories warn once when an options object is passed. exponential-backoff: real npm semantics — numOfAttempts/startingDelay/ timeMultiple/maxDelay/delayFirstAttempt/jitter/retry parsed and honored, and Promise-returning tasks now retry on REJECTION (previously the first promise was passed through and no retry ever happened) via a GC-rooted state machine chaining js_promise_then + timer-queue delays instead of blocking thread::sleep. mongodb: findOne resolves a parsed document object (the JSON.parse property-access bug that blocked this is fixed); BSON types surface in relaxed extended-JSON shape. mysql2/pg: FieldPacket.type/columnType carry the numeric MySQL wire type ID (name->ID map; sqlx 0.8 keeps the raw byte pub(crate)); pg fields get numeric dataTypeID (type OID), tableID/columnID from the RowDescription via sqlx relation_id()/relation_attribute_no(), dataTypeSize/-Modifier sentinel -1, format "text". Both stdlib and ext twins updated. http.Agent: keepSocketAlive/reuseSocket warn once (reqwest owns the pool); destroy() un-flagged — it really drops the per-agent client on the ext path. fastify: storing an 'upgrade' handler warns at registration (#1113 tracks real dispatch). worker.ref/unref: already real (event-loop refcount verified both directions); stale stub notes dropped — the lines #4917 cited are MessagePort no-ops, not Worker. Manifest: #4917 stub inventory 18 -> 9 with narrowed notes; keystone test updated; docs regenerated. New parity test test_gap_zlib_4917_level.ts matches node v26 byte-for-byte. --- crates/perry-api-manifest/src/entries.rs | 60 ++- .../tests/stub_inventory.rs | 30 +- .../src/lower_call/native_table/media.rs | 2 +- .../src/runtime_decls/stdlib_ffi.rs | 2 +- crates/perry-ext-http/src/agent.rs | 9 +- crates/perry-ext-mysql2/src/lib.rs | 50 ++- crates/perry-ext-pg/src/lib.rs | 35 +- .../perry-runtime/src/node_submodules/zlib.rs | 14 + .../perry-stdlib/src/exponential_backoff.rs | 385 ++++++++++++++---- crates/perry-stdlib/src/fastify/app.rs | 10 + crates/perry-stdlib/src/http.rs | 12 +- crates/perry-stdlib/src/mongodb.rs | 18 +- crates/perry-stdlib/src/mysql2/result.rs | 21 +- crates/perry-stdlib/src/mysql2/types.rs | 71 +++- crates/perry-stdlib/src/pg/types.rs | 51 ++- crates/perry-stdlib/src/zlib.rs | 121 ++++-- docs/api/perry.d.ts | 24 +- docs/src/api/reference.md | 36 +- test-files/test_gap_zlib_4917_level.ts | 64 +++ 19 files changed, 792 insertions(+), 223 deletions(-) create mode 100644 test-files/test_gap_zlib_4917_level.ts diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index ba3dc17e87..37ddf43b8e 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -428,7 +428,19 @@ const ZLIB_OPTIONS_PARAM: ParamSpec = ParamSpec::Named { }; const fn zlib_stream_factory(name: &'static str) -> ApiEntry { method_sig("zlib", name, false, None, ZLIB_STREAM_OPTS, TypeSpec::Any) - .stub_note("options (level/chunkSize/dictionary/...) accepted but ignored (#4917)") +} +/// Deflate-family compressor factory: `level` is honored (#4917); +/// `strategy`/`memLevel` are validated but not applied, and a supplied +/// `dictionary` warns once instead of silently mis-compressing. +const fn zlib_compressor_factory(name: &'static str) -> ApiEntry { + zlib_stream_factory(name) + .stub_note("level honored; strategy/memLevel validated but not applied (#4917)") +} +/// Brotli/zstd factory: their `params` option shape is not wired up; a +/// passed options object warns once (#4917). +const fn zlib_params_factory(name: &'static str) -> ApiEntry { + zlib_stream_factory(name) + .stub_note("params/quality options accepted but ignored, warns once (#4917)") } /// Source-of-truth manifest. See module-level docs for what feeds it. @@ -590,8 +602,9 @@ pub static API_MANIFEST: &[ApiEntry] = &[ method("mongodb", "insertOne", true, None), method("mongodb", "insertMany", true, None), method("mongodb", "find", true, None), - method("mongodb", "findOne", true, None) - .stub_note("resolves a JSON string, not a document object (#4917)"), + // #4917 — resolves a parsed document object (BSON-specific types in + // relaxed extended-JSON shape, e.g. `_id.$oid`), or null. + method("mongodb", "findOne", true, None), method("mongodb", "updateOne", true, None), method("mongodb", "updateMany", true, None), method("mongodb", "deleteOne", true, None), @@ -1632,6 +1645,9 @@ pub static API_MANIFEST: &[ApiEntry] = &[ }], TypeSpec::Bool, ), + // #4917 — real retry semantics: options (numOfAttempts/startingDelay/ + // timeMultiple/maxDelay/delayFirstAttempt/jitter/retry) honored; + // Promise-returning tasks retry on rejection via promise reactions. method_sig( "exponential-backoff", "backOff", @@ -1639,8 +1655,7 @@ pub static API_MANIFEST: &[ApiEntry] = &[ None, &[p_any("p0"), p_any("p1")], TypeSpec::Any, - ) - .stub_note("retry options ignored; hardcoded 3 attempts / 100ms / x2 (#4917)"), + ), method_sig( "argon2", "hash", @@ -2223,19 +2238,22 @@ pub static API_MANIFEST: &[ApiEntry] = &[ ), // #1843 — Transform-stream factories. Each returns a stream handle // supporting `.write`/`.end`/`.on('data'|'end'|'error')`/`.pipe`. - zlib_stream_factory("createGzip"), + // #4917 — deflate-family factories honor `options.level`; a supplied + // `dictionary` warns once (decompressors fail loudly without it, so + // the plain factories are no longer flagged). + zlib_compressor_factory("createGzip"), zlib_stream_factory("createGunzip"), - zlib_stream_factory("createDeflate"), + zlib_compressor_factory("createDeflate"), zlib_stream_factory("createInflate"), - zlib_stream_factory("createDeflateRaw"), + zlib_compressor_factory("createDeflateRaw"), zlib_stream_factory("createInflateRaw"), zlib_stream_factory("createUnzip"), - zlib_stream_factory("createBrotliCompress"), + zlib_params_factory("createBrotliCompress"), // `zlib.createBrotliDecompress(options?)` — now a real Transform stream // (still passes axios's `typeof === 'function'` module-init gate). - zlib_stream_factory("createBrotliDecompress"), - zlib_stream_factory("createZstdCompress"), - zlib_stream_factory("createZstdDecompress"), + zlib_params_factory("createBrotliDecompress"), + zlib_params_factory("createZstdCompress"), + zlib_params_factory("createZstdDecompress"), method_sig( "cron", "validate", @@ -2797,10 +2815,11 @@ pub static API_MANIFEST: &[ApiEntry] = &[ method("worker_threads", "once", true, Some("Worker")), method("worker_threads", "off", true, Some("Worker")), method("worker_threads", "terminate", true, Some("Worker")), - method("worker_threads", "ref", true, Some("Worker")) - .stub_note("no-op; does not affect process event-loop ref-count (#4917)"), - method("worker_threads", "unref", true, Some("Worker")) - .stub_note("no-op; does not affect process event-loop ref-count (#4917)"), + // #4917 — real: `ref()`/`unref()` flip `WorkerRecord.refed`, which + // `js_worker_threads_has_pending` checks to keep the event loop alive + // (a live refed worker holds the process; `unref()` releases it). + method("worker_threads", "ref", true, Some("Worker")), + method("worker_threads", "unref", true, Some("Worker")), method("worker_threads", "getHeapStatistics", true, Some("Worker")), method("worker_threads", "cpuUsage", true, Some("Worker")), method("worker_threads", "getHeapSnapshot", true, Some("Worker")), @@ -4670,13 +4689,14 @@ pub static API_MANIFEST: &[ApiEntry] = &[ class("http", "Agent"), method("http", "Agent", false, None), method("http", "getName", true, Some("Agent")), - method("http", "destroy", true, Some("Agent")) - .stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"), + // #4917 — `destroy()` really drops the per-agent reqwest client (= + // releases its keep-alive pool) and flips `destroyed`; not a stub. + method("http", "destroy", true, Some("Agent")), method("http", "close", true, Some("Agent")), method("http", "keepSocketAlive", true, Some("Agent")) - .stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"), + .stub_note("reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917)"), method("http", "reuseSocket", true, Some("Agent")) - .stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"), + .stub_note("reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917)"), // Synthetic `__get_` / `__set_` accessor methods (HIR // rewrites bare `agent.maxSockets` reads to `__get_maxSockets()` // when the receiver is class-tagged) + their bare-name twins for diff --git a/crates/perry-api-manifest/tests/stub_inventory.rs b/crates/perry-api-manifest/tests/stub_inventory.rs index 27239c4d74..01ef743285 100644 --- a/crates/perry-api-manifest/tests/stub_inventory.rs +++ b/crates/perry-api-manifest/tests/stub_inventory.rs @@ -82,7 +82,17 @@ fn stub_inventory_matches_known_clusters() { // open/url/waitForDebugger/Session.post(4) + repl // start/REPLServer no-eval-loop(2). ("#4916", 10), - ("#4917", 18), // stdlib adapters: zlib(11) + http.Agent(3) + worker ref/unref(2) + mongodb(1) + backoff(1) + // #4917 (stdlib-adapter no-ops) — what remains after the real + // semantics landed: zlib deflate-family compressor factories + // honor `level` but still drop strategy/memLevel (3) + + // Brotli/zstd factories ignore `params`, warn once (4) + + // http.Agent per-socket keepSocketAlive/reuseSocket hooks (2). + // Intentionally absent now: zlib decompressor factories (level + // honored; a missing dictionary fails loudly), Agent.destroy + // (drops the per-agent reqwest pool), worker ref/unref (real + // event-loop refcount), mongodb.findOne (parsed document), + // exponential-backoff options (honored, incl. retry predicate). + ("#4917", 9), ]; let expected_map: BTreeMap = expected.iter().map(|(k, v)| (k.to_string(), *v)).collect(); @@ -127,8 +137,10 @@ fn keystone_apis_are_flagged() { let must_be_stub: &[(&str, &str)] = &[ ("repl", "start"), ("inspector", "post"), + // still partial after #4917: level honored, strategy/memLevel dropped ("zlib", "createGzip"), - ("mongodb", "findOne"), + ("zlib", "createBrotliCompress"), + ("http", "keepSocketAlive"), ]; for (module, name) in must_be_stub { let found = iter_entries().any(|e| e.module == *module && e.name == *name && e.stub); @@ -137,8 +149,18 @@ fn keystone_apis_are_flagged() { // The inverse: APIs implemented for real must NOT stay flagged. // v8 heap snapshots emit a real GC-walk object graph since #4916. - let must_not_be_stub: &[(&str, &str)] = - &[("v8", "getHeapSnapshot"), ("v8", "writeHeapSnapshot")]; + // mongodb.findOne resolves a parsed document, backOff honors its + // options, and worker ref/unref drive the event-loop refcount since + // #4917. + let must_not_be_stub: &[(&str, &str)] = &[ + ("v8", "getHeapSnapshot"), + ("v8", "writeHeapSnapshot"), + ("mongodb", "findOne"), + ("exponential-backoff", "backOff"), + ("worker_threads", "ref"), + ("worker_threads", "unref"), + ("http", "destroy"), + ]; for (module, name) in must_not_be_stub { let flagged = iter_entries().any(|e| e.module == *module && e.name == *name && e.stub); assert!( 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 1ee51d538a..713a07464f 100644 --- a/crates/perry-codegen/src/lower_call/native_table/media.rs +++ b/crates/perry-codegen/src/lower_call/native_table/media.rs @@ -382,7 +382,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ method: "deflateRawSync", class_filter: None, runtime: "js_zlib_deflate_raw_sync", - args: &[NA_F64], + args: &[NA_F64, NA_F64], ret: NR_PTR, }, NativeModSig { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index 5f2272a232..9844fe6b54 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -596,7 +596,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { module.declare_function("js_zlib_gzip", VOID, &[DOUBLE, DOUBLE]); module.declare_function("js_zlib_inflate_sync", I64, &[I64]); module.declare_function("js_zlib_inflate", VOID, &[DOUBLE, DOUBLE]); - module.declare_function("js_zlib_deflate_raw_sync", I64, &[DOUBLE]); + module.declare_function("js_zlib_deflate_raw_sync", I64, &[DOUBLE, DOUBLE]); module.declare_function("js_zlib_deflate_raw", VOID, &[DOUBLE, DOUBLE]); module.declare_function("js_zlib_inflate_raw_sync", I64, &[DOUBLE]); module.declare_function("js_zlib_inflate_raw", VOID, &[DOUBLE, DOUBLE]); diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index ece124b728..6aa4d3ade6 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -851,11 +851,18 @@ fn json_value_to_string(v: &serde_json::Value) -> String { } // ------------------------------------------------------------------ -// destroy / keepSocketAlive / reuseSocket — chainable no-ops +// keepSocketAlive / reuseSocket — chainable no-ops (reqwest owns the +// keep-alive pool, so there is no per-socket hook to forward to); +// destroy is real (drops the cached client below). // ------------------------------------------------------------------ #[no_mangle] pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { + perry_runtime::stub_diag::perry_stub_warn( + "http.Agent keepSocketAlive/reuseSocket", + "reqwest owns the keep-alive pool; per-socket hooks are no-ops", + Some("#4917"), + ); handle } diff --git a/crates/perry-ext-mysql2/src/lib.rs b/crates/perry-ext-mysql2/src/lib.rs index 560ec669c1..b86f78b7d1 100644 --- a/crates/perry-ext-mysql2/src/lib.rs +++ b/crates/perry-ext-mysql2/src/lib.rs @@ -320,16 +320,56 @@ fn raw_row_to_js_array(row: &RawRowData) -> *mut ArrayHeader { arr } +/// Map sqlx's MySQL type *name* back to the wire-protocol numeric type ID +/// (`enum_field_types`, what Node's mysql2 puts in `field.type`/`columnType`). +/// Twin of `perry_stdlib::mysql2::types::mysql_type_id_from_name` (#4917) — +/// this crate cannot depend on perry-stdlib, keep the two in sync. +fn mysql_type_id_from_name(name: &str) -> f64 { + let base = name.strip_suffix(" UNSIGNED").unwrap_or(name); + let id: u8 = match base { + "BOOLEAN" | "TINYINT" => 1, + "SMALLINT" => 2, + "INT" => 3, + "FLOAT" => 4, + "DOUBLE" => 5, + "NULL" => 6, + "TIMESTAMP" => 7, + "BIGINT" => 8, + "MEDIUMINT" => 9, + "DATE" => 10, + "TIME" => 11, + "DATETIME" => 12, + "YEAR" => 13, + "BIT" => 16, + "JSON" => 245, + "DECIMAL" => 246, + "ENUM" => 247, + "SET" => 248, + "TINYBLOB" | "TINYTEXT" => 249, + "MEDIUMBLOB" | "MEDIUMTEXT" => 250, + "LONGBLOB" | "LONGTEXT" => 251, + "BLOB" | "TEXT" => 252, + "VARCHAR" | "VARBINARY" => 253, + "CHAR" | "BINARY" => 254, + "GEOMETRY" => 255, + _ => 0, + }; + id as f64 +} + fn raw_column_to_field_packet(col: &RawColumnInfo) -> *mut ObjectHeader { - let (packed, shape_id) = build_object_shape(&["name", "type", "length"]); + let (packed, shape_id) = build_object_shape(&["name", "type", "columnType", "length"]); let obj = - unsafe { js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32) }; + unsafe { js_object_alloc_with_shape(shape_id, 4, packed.as_ptr(), packed.len() as u32) }; let name_str = alloc_string(&col.name); - let type_str = alloc_string(&col.type_name); + // #4917: `type`/`columnType` carry the numeric wire ID mysql2 exposes; + // `length` stays 0 (sqlx 0.8 keeps the wire `max_size` pub(crate)). + let type_id = mysql_type_id_from_name(&col.type_name); unsafe { js_object_set_field(obj, 0, JsValue::from_string_ptr(name_str.as_raw())); - js_object_set_field(obj, 1, JsValue::from_string_ptr(type_str.as_raw())); - js_object_set_field(obj, 2, JsValue::from_number(0.0)); + js_object_set_field(obj, 1, JsValue::from_number(type_id)); + js_object_set_field(obj, 2, JsValue::from_number(type_id)); + js_object_set_field(obj, 3, JsValue::from_number(0.0)); } obj } diff --git a/crates/perry-ext-pg/src/lib.rs b/crates/perry-ext-pg/src/lib.rs index 17a06b6078..5537b3492e 100644 --- a/crates/perry-ext-pg/src/lib.rs +++ b/crates/perry-ext-pg/src/lib.rs @@ -162,17 +162,40 @@ fn row_to_js_object(row: &PgRow) -> *mut ObjectHeader { obj } -/// Build a `FieldDef`-shaped object: `{ name, dataTypeID, tableID }`. +/// Build a `FieldDef`-shaped object matching node-pg's `result.fields[i]` +/// (#4917): `dataTypeID` is the numeric type OID, `tableID`/`columnID` come +/// from the RowDescription (0 for expression columns, like Node). +/// `dataTypeSize`/`dataTypeModifier` are not exposed by sqlx 0.8 and report +/// the "unknown/variable" sentinel -1. Twin of +/// `perry_stdlib::pg::types::column_to_field_def` — keep in sync. fn column_to_field_def(col: &PgColumn) -> *mut ObjectHeader { - let (packed, shape_id) = build_object_shape(&["name", "dataTypeID", "tableID"]); + let (packed, shape_id) = build_object_shape(&[ + "name", + "tableID", + "columnID", + "dataTypeID", + "dataTypeSize", + "dataTypeModifier", + "format", + ]); let obj = - unsafe { js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32) }; + unsafe { js_object_alloc_with_shape(shape_id, 7, packed.as_ptr(), packed.len() as u32) }; let name_str = alloc_string(col.name()); - let type_str = alloc_string(col.type_info().name()); + let table_id = col.relation_id().map(|oid| oid.0 as f64).unwrap_or(0.0); + let column_id = col + .relation_attribute_no() + .map(|attno| attno as f64) + .unwrap_or(0.0); + let data_type_id = col.type_info().oid().map(|oid| oid.0 as f64).unwrap_or(0.0); + let format_str = alloc_string("text"); unsafe { js_object_set_field(obj, 0, JsValue::from_string_ptr(name_str.as_raw())); - js_object_set_field(obj, 1, JsValue::from_string_ptr(type_str.as_raw())); - js_object_set_field(obj, 2, JsValue::from_number(0.0)); + js_object_set_field(obj, 1, JsValue::from_number(table_id)); + js_object_set_field(obj, 2, JsValue::from_number(column_id)); + js_object_set_field(obj, 3, JsValue::from_number(data_type_id)); + js_object_set_field(obj, 4, JsValue::from_number(-1.0)); + js_object_set_field(obj, 5, JsValue::from_number(-1.0)); + js_object_set_field(obj, 6, JsValue::from_string_ptr(format_str.as_raw())); } obj } diff --git a/crates/perry-runtime/src/node_submodules/zlib.rs b/crates/perry-runtime/src/node_submodules/zlib.rs index 285560f749..6b5b3de168 100644 --- a/crates/perry-runtime/src/node_submodules/zlib.rs +++ b/crates/perry-runtime/src/node_submodules/zlib.rs @@ -219,6 +219,20 @@ pub extern "C" fn js_zlib_validate_options(opts: f64, min_window_bits: i32) { validate_option_range(ptr, b"strategy", "options.strategy", 0, 4); validate_option_chunk_size(ptr); validate_option_range(ptr, b"flush", "options.flush", 0, 5); + + // #4917: `level` is honored and the ranged options above are validated, + // but a preset `dictionary` is not threaded through flate2. Silently + // dropping it would mis-compress against peers that expect it to apply, + // so warn once instead. + let dict = read_option_field(ptr, b"dictionary"); + let dv = crate::value::JSValue::from_bits(dict.to_bits()); + if !dv.is_undefined() && !dv.is_null() { + crate::stub_diag::perry_stub_warn( + "zlib options.dictionary", + "the preset dictionary option is accepted but not applied", + Some("#4917"), + ); + } } /// Validate the `buffer` argument to a one-shot zlib codec (`gzipSync`, diff --git a/crates/perry-stdlib/src/exponential_backoff.rs b/crates/perry-stdlib/src/exponential_backoff.rs index 9efe0cd7e9..69802de3bb 100644 --- a/crates/perry-stdlib/src/exponential_backoff.rs +++ b/crates/perry-stdlib/src/exponential_backoff.rs @@ -1,13 +1,23 @@ //! Exponential Backoff implementation //! //! Native implementation of the `exponential-backoff` npm package. -//! Provides retry functionality with exponential delays. +//! +//! `backOff(task, options?)` honors the package's real option surface +//! (#4917): `numOfAttempts`, `startingDelay`, `timeMultiple`, `maxDelay`, +//! `delayFirstAttempt`, `jitter: 'full'`, and the `retry(e, attemptNumber)` +//! predicate. Promise-returning tasks are retried on **rejection** via +//! promise reactions chained through the timer queue (no blocking +//! `thread::sleep` on the main thread); each retry waits +//! `startingDelay * timeMultiple^n` ms, capped at `maxDelay`. +use perry_runtime::promise::js_promise_then; use perry_runtime::{ - js_closure_call0, js_promise_new, js_promise_reject, js_promise_resolve, ClosureHeader, Promise, + js_closure_call0, js_closure_call2, js_is_promise, js_promise_new, js_promise_reject, + js_promise_resolve, ClosureHeader, JSValue, Promise, }; -use std::thread; -use std::time::Duration; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, Once}; /// Check if an f64 value represents a "real" success value. /// NaN-boxed tagged values (pointers, strings, int32, booleans, etc.) are valid results. @@ -34,92 +44,329 @@ fn is_valid_result(result: f64) -> bool { tag >= 0x7FFA } -/// Execute a function with exponential backoff retry logic -/// fn_ptr: Closure to execute (should return a Promise) -/// options_ptr: Object containing options (numOfAttempts, startingDelay, etc.) -/// Returns: Promise that resolves with the result or rejects after all retries fail -#[no_mangle] -pub extern "C" fn backOff( - fn_ptr: *const ClosureHeader, - _options_ptr: *const perry_runtime::ObjectHeader, -) -> *mut Promise { - if fn_ptr.is_null() { - let promise = js_promise_new(); - js_promise_reject(promise, f64::NAN); - return promise; +fn js_undefined() -> f64 { + f64::from_bits(JSValue::undefined().bits()) +} + +/// Options mirroring the npm package's `BackoffOptions` (with its defaults: +/// 10 attempts, 100ms 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, + } } +} - // Call the function once - for async callbacks (which return Promises), - // we just pass through the Promise. The caller will await it. - let result = js_closure_call0(fn_ptr); +/// 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 (successfully started and settled/failed). + attempts_done: u32, + opts: BackoffOptions, +} - // Check if the result is a NaN-boxed pointer (Promise, object, etc.) - let bits = result.to_bits(); - let tag = bits >> 48; +static STATES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); +static GC_REGISTERED: Once = Once::new(); - if tag == 0x7FFD { - // POINTER_TAG - the callback returned a Promise or object pointer. - // Extract the raw pointer and return it directly as a Promise. - // This avoids wrapping Promise-in-Promise. - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut Promise; - if !ptr.is_null() { - return ptr; +fn ensure_backoff_gc_scanner() { + GC_REGISTERED.call_once(|| { + perry_runtime::gc::gc_register_mutable_root_scanner_named( + "stdlib:exponential-backoff", + scan_backoff_roots, + ); + }); +} + +fn scan_backoff_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { + 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; + } } } +} - // For non-Promise results, wrap in a new Promise - let promise = js_promise_new(); +unsafe fn option_field(ptr: *const perry_runtime::ObjectHeader, name: &[u8]) -> f64 { + let key = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); + perry_runtime::object::js_object_get_field_by_name_f64(ptr, key) +} - if is_valid_result(result) { - js_promise_resolve(promise, result); - return promise; +fn option_number(value: f64) -> Option { + let jv = JSValue::from_bits(value.to_bits()); + if jv.is_int32() { + Some(jv.as_int32() as f64) + } else if jv.is_number() && !value.is_nan() { + Some(value) + } else { + None } +} - // Result looks like an error (raw NaN). Retry with backoff. - // Default backoff options - let num_of_attempts: u32 = 3; - let starting_delay: u64 = 100; - let max_delay: u64 = 10000; - let time_multiple: f64 = 2.0; - - // TODO: Parse options from options_ptr if provided +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; + } - let mut attempt = 1; // Already did attempt 1 above - let mut current_delay = starting_delay; + if let Some(n) = option_number(option_field(ptr, b"numOfAttempts")) { + opts.num_of_attempts = n.max(1.0) as u32; + } + if let Some(n) = option_number(option_field(ptr, b"startingDelay")) { + opts.starting_delay = n.max(0.0); + } + if let Some(n) = option_number(option_field(ptr, b"timeMultiple")) { + opts.time_multiple = n.max(1.0); + } + if let Some(n) = option_number(option_field(ptr, b"maxDelay")) { + opts.max_delay = n.max(0.0); + } + let dfa = option_field(ptr, b"delayFirstAttempt"); + if perry_runtime::value::js_is_truthy(dfa) != 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, b"jitter"); + if JSValue::from_bits(jitter.to_bits()).is_any_string() { + let s = perry_runtime::js_get_string_pointer_unified(jitter) + as *const perry_runtime::StringHeader; + if !s.is_null() && (*s).byte_len == 4 { + let data = (s as *const u8).add(std::mem::size_of::()); + if std::slice::from_raw_parts(data, 4) == b"full" { + opts.jitter_full = true; + } + } + } + let retry = option_field(ptr, b"retry"); + let retry_bits = retry.to_bits(); + if JSValue::from_bits(retry_bits).is_pointer() { + let raw = (retry_bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if perry_runtime::closure::is_closure_ptr(raw) { + opts.retry_cb = retry_bits; + } + } + opts +} - loop { - attempt += 1; +/// Build a 1-arg promise-reaction closure capturing the backoff state id. +fn bound_reaction(func_ptr: *const u8, state_id: u64) -> *const ClosureHeader { + perry_runtime::closure::js_register_closure_arity(func_ptr, 1); + let closure = perry_runtime::closure::js_closure_alloc(func_ptr, 1); + perry_runtime::closure::js_closure_set_capture_f64(closure, 0, f64::from_bits(state_id)); + closure as *const ClosureHeader +} - if attempt > num_of_attempts { - js_promise_reject(promise, f64::NAN); - return promise; - } +fn state_id_from_closure(closure: *const ClosureHeader) -> u64 { + perry_runtime::closure::js_closure_get_capture_f64(closure, 0).to_bits() +} - // Wait before retrying - thread::sleep(Duration::from_millis(current_delay)); +fn settle(id: u64, resolve: bool, value: f64) { + let Some(st) = STATES.lock().unwrap().remove(&id) else { + return; + }; + let outer = (st.outer & 0x0000_FFFF_FFFF_FFFF) as *mut Promise; + if resolve { + js_promise_resolve(outer, value); + } else { + js_promise_reject(outer, value); + } +} - // Call the function again - let result = js_closure_call0(fn_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 + } +} - let bits = result.to_bits(); - let tag = bits >> 48; +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) + }; + let timer = perry_runtime::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(), + ); +} - if tag == 0x7FFD { - // Promise returned - extract and return directly - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut Promise; - if !ptr.is_null() { - return ptr; - } +/// 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 — + // Perry does not await it.) + let cb = ((retry_cb & 0x0000_FFFF_FFFF_FFFF) as usize) as *const ClosureHeader; + let should_retry = js_closure_call2(cb, error, attempts_done as f64); + if perry_runtime::value::js_is_truthy(should_retry) == 0 { + settle(id, false, error); + return; } + } + schedule_next_attempt(id); +} - if is_valid_result(result) { - js_promise_resolve(promise, result); - return promise; +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 & 0x0000_FFFF_FFFF_FFFF) as usize) as *const ClosureHeader; + let result = js_closure_call0(task_ptr); + + let bits = result.to_bits(); + if JSValue::from_bits(bits).is_pointer() { + let raw = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut Promise; + if !raw.is_null() && js_is_promise(raw) != 0 { + js_promise_then( + raw, + bound_reaction(backoff_on_fulfilled as *const u8, id), + bound_reaction(backoff_on_rejected as *const u8, id), + ); + return; } + } + if is_valid_result(result) { + settle(id, true, result); + } else { + handle_failure(id, result); + } +} - // Increase delay exponentially - current_delay = ((current_delay as f64) * time_multiple).min(max_delay as f64) as u64; +extern "C" fn backoff_on_fulfilled(closure: *const ClosureHeader, value: f64) -> f64 { + settle(state_id_from_closure(closure), true, value); + js_undefined() +} + +extern "C" fn backoff_on_rejected(closure: *const ClosureHeader, error: f64) -> f64 { + handle_failure(state_id_from_closure(closure), error); + js_undefined() +} + +extern "C" fn backoff_on_timer(closure: *const ClosureHeader, _value: f64) -> f64 { + run_attempt(state_id_from_closure(closure)); + js_undefined() +} + +/// 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 ClosureHeader, options: f64) -> *mut Promise { + let promise = js_promise_new(); + if fn_ptr.is_null() { + 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); + STATES.lock().unwrap().insert( + id, + BackoffState { + task: JSValue::pointer(fn_ptr as *const u8).bits(), + outer: JSValue::pointer(promise as *const u8).bits(), + attempts_done: 0, + opts, + }, + ); + + if delay_first { + schedule_next_attempt(id); + } else { + run_attempt(id); } + promise } /// Simplified backOff that takes just the function and retry count @@ -153,7 +400,7 @@ pub extern "C" fn js_backoff_simple( } // Wait before retrying - thread::sleep(Duration::from_millis(current_delay)); + std::thread::sleep(std::time::Duration::from_millis(current_delay)); // Increase delay exponentially current_delay = (current_delay * 2).min(10000); diff --git a/crates/perry-stdlib/src/fastify/app.rs b/crates/perry-stdlib/src/fastify/app.rs index 8622f6a967..dd7afa730b 100644 --- a/crates/perry-stdlib/src/fastify/app.rs +++ b/crates/perry-stdlib/src/fastify/app.rs @@ -360,6 +360,16 @@ pub unsafe extern "C" fn js_fastify_app_on(app_handle: Handle, event_ptr: i64, c let event = std::str::from_utf8(std::slice::from_raw_parts(data, len)).unwrap_or(""); if event == "upgrade" { + // Warn at registration, not first upgrade request: storing the + // callback used to be silent, and a registered-but-never-fired + // handler is exactly the "works at boot, breaks in production" + // lie #4917 targets. Upgrade requests themselves get a loud 501 + // in server.rs until #1113 lands real dispatch. + perry_runtime::stub_diag::perry_stub_warn( + "fastify app.server.on('upgrade')", + "the handler is stored but never fires; upgrade requests get 501", + Some("#1113"), + ); if let Some(app) = get_handle_mut::(app_handle) { app.upgrade_handlers.push(callback); } diff --git a/crates/perry-stdlib/src/http.rs b/crates/perry-stdlib/src/http.rs index 2d08a7eeab..887fdee420 100644 --- a/crates/perry-stdlib/src/http.rs +++ b/crates/perry-stdlib/src/http.rs @@ -1785,10 +1785,18 @@ unsafe fn append_https_agent_name_fields(name: &mut String, options_f64: f64) { push_truthy_string(name, "privateKeyEngine"); } -/// `agent.destroy()` — release pooled sockets. Perry doesn't pool today, so -/// it's a no-op that returns the receiver for chainability. +/// `agent.keepSocketAlive(socket)` / `agent.reuseSocket(socket, req)` — +/// this Agent flavor exposes no per-socket hooks to act on, so these return +/// the receiver for chainability but otherwise do nothing. Warn once instead +/// of silently succeeding (#4917). (Default builds route http through +/// perry-ext-http, where reqwest owns the keep-alive pool.) #[no_mangle] pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { + perry_runtime::stub_diag::perry_stub_warn( + "http.Agent keepSocketAlive/reuseSocket", + "this http Agent has no per-socket hooks; the call is a no-op", + Some("#4917"), + ); handle } diff --git a/crates/perry-stdlib/src/mongodb.rs b/crates/perry-stdlib/src/mongodb.rs index abeba4f29d..d63c22c3c1 100644 --- a/crates/perry-stdlib/src/mongodb.rs +++ b/crates/perry-stdlib/src/mongodb.rs @@ -308,16 +308,20 @@ pub unsafe extern "C" fn js_mongodb_collection_find_one( } }, |result: Option| { - // Pre-existing limitation: findOne resolves with a JSON STRING - // (not a parsed object). Tried converting via js_json_parse - // here, but a separate Perry-wide JSON-parse-then-property- - // access bug surfaced (`JSON.parse('{"foo":"perry"}').foo` -> - // NaN), so leaving the string return for now. Users do - // `JSON.parse(await coll.findOne(...))` once that's fixed. + // #4917: resolve a real document object (`doc.field` works), not + // a JSON string. This converter runs on the main thread, so + // parsing may allocate JSValues. The JSON comes straight from + // serde so it always parses; on the off chance it doesn't, fall + // back to the raw string rather than throwing from inside the + // resolution pump. BSON-specific types surface in their relaxed + // extended-JSON shape (e.g. `_id.$oid`). match result { Some(json) => { let ptr = js_string_from_bytes(json.as_ptr(), json.len() as u32); - JSValue::string_ptr(ptr).bits() + match unsafe { perry_runtime::json::js_json_parse_result(ptr) } { + Ok(value) => value.bits(), + Err(_) => JSValue::string_ptr(ptr).bits(), + } } None => JSValue::null().bits(), } diff --git a/crates/perry-stdlib/src/mysql2/result.rs b/crates/perry-stdlib/src/mysql2/result.rs index 2afdde812e..dc118dfcba 100644 --- a/crates/perry-stdlib/src/mysql2/result.rs +++ b/crates/perry-stdlib/src/mysql2/result.rs @@ -244,8 +244,8 @@ fn raw_value_to_jsvalue(value: &RawValue) -> JSValue { /// Convert a raw column to a field packet (must be called on main thread) fn raw_column_to_field_packet(col: &RawColumnInfo) -> *mut perry_runtime::ObjectHeader { - let obj = js_object_alloc(0, 3); - let mut keys_array = js_array_alloc(3); + let obj = js_object_alloc(0, 4); + let mut keys_array = js_array_alloc(4); // Set name let name_ptr = js_string_from_bytes(col.name.as_ptr(), col.name.len() as u32); @@ -253,17 +253,22 @@ fn raw_column_to_field_packet(col: &RawColumnInfo) -> *mut perry_runtime::Object let key0 = js_string_from_bytes("name".as_ptr(), 4); keys_array = js_array_push(keys_array, JSValue::string_ptr(key0)); - // Set type - let type_ptr = js_string_from_bytes(col.type_name.as_ptr(), col.type_name.len() as u32); - js_object_set_field(obj, 1, JSValue::string_ptr(type_ptr)); + // Set type — numeric wire ID, matching mysql2's FieldPacket (#4917). + let type_id = super::types::mysql_type_id_from_name(&col.type_name); + js_object_set_field(obj, 1, JSValue::number(type_id)); let key1 = js_string_from_bytes("type".as_ptr(), 4); keys_array = js_array_push(keys_array, JSValue::string_ptr(key1)); - // Set length (0 for now) - js_object_set_field(obj, 2, JSValue::number(0.0)); - let key2 = js_string_from_bytes("length".as_ptr(), 6); + // mysql2 exposes the same value as `columnType` too. + js_object_set_field(obj, 2, JSValue::number(type_id)); + let key2 = js_string_from_bytes("columnType".as_ptr(), 10); keys_array = js_array_push(keys_array, JSValue::string_ptr(key2)); + // Set length (0 — not recoverable through sqlx 0.8's public API, #4917) + js_object_set_field(obj, 3, JSValue::number(0.0)); + let key3 = js_string_from_bytes("length".as_ptr(), 6); + keys_array = js_array_push(keys_array, JSValue::string_ptr(key3)); + js_object_set_keys(obj, keys_array); obj } diff --git a/crates/perry-stdlib/src/mysql2/types.rs b/crates/perry-stdlib/src/mysql2/types.rs index bd9ac75da2..39c4bca2de 100644 --- a/crates/perry-stdlib/src/mysql2/types.rs +++ b/crates/perry-stdlib/src/mysql2/types.rs @@ -330,16 +330,59 @@ fn column_value_to_jsvalue(row: &MySqlRow, index: usize) -> JSValue { } } +/// Map sqlx's MySQL type *name* back to the wire-protocol numeric type ID +/// (`enum_field_types`, what Node's mysql2 puts in `field.type`/`columnType`). +/// sqlx 0.8 keeps the raw `ColumnType` byte `pub(crate)`, but its `name()` +/// strings are a bijection over (type, BINARY/UNSIGNED flags), so the wire ID +/// is recoverable (#4917). `DECIMAL` maps to 246 (`NEWDECIMAL`) and +/// `VARCHAR` to 253 (`VAR_STRING`) — the values servers actually send in +/// result sets, not the legacy 0/15 aliases. +pub fn mysql_type_id_from_name(name: &str) -> f64 { + let base = name.strip_suffix(" UNSIGNED").unwrap_or(name); + let id: u8 = match base { + "BOOLEAN" | "TINYINT" => 1, + "SMALLINT" => 2, + "INT" => 3, + "FLOAT" => 4, + "DOUBLE" => 5, + "NULL" => 6, + "TIMESTAMP" => 7, + "BIGINT" => 8, + "MEDIUMINT" => 9, + "DATE" => 10, + "TIME" => 11, + "DATETIME" => 12, + "YEAR" => 13, + "BIT" => 16, + "JSON" => 245, + "DECIMAL" => 246, + // Servers usually transmit ENUM/SET as STRING (254) plus a flag, but + // sqlx has already folded the flag into the name; report the named ID. + "ENUM" => 247, + "SET" => 248, + "TINYBLOB" | "TINYTEXT" => 249, + "MEDIUMBLOB" | "MEDIUMTEXT" => 250, + "LONGBLOB" | "LONGTEXT" => 251, + "BLOB" | "TEXT" => 252, + "VARCHAR" | "VARBINARY" => 253, + "CHAR" | "BINARY" => 254, + "GEOMETRY" => 255, + _ => 0, + }; + id as f64 +} + /// Create a FieldPacket object for a column pub fn column_to_field_packet(col: &sqlx::mysql::MySqlColumn) -> *mut ObjectHeader { // FieldPacket has these fields: // 0: name (string) - // 1: type (number - MySQL type code) - // 2: length (number) - let obj = js_object_alloc(0, 3); + // 1: type (number - MySQL wire type ID) + // 2: columnType (number - mysql2 alias of `type`) + // 3: length (number) + let obj = js_object_alloc(0, 4); // Create keys array for property name lookup - let mut keys_array = js_array_alloc(3); + let mut keys_array = js_array_alloc(4); // Set name let name = col.name(); @@ -348,18 +391,24 @@ pub fn column_to_field_packet(col: &sqlx::mysql::MySqlColumn) -> *mut ObjectHead let key0 = js_string_from_bytes("name".as_ptr(), 4); keys_array = js_array_push(keys_array, JSValue::string_ptr(key0)); - // Set type (as string for now) - let type_name = col.type_info().name(); - let type_ptr = js_string_from_bytes(type_name.as_ptr(), type_name.len() as u32); - js_object_set_field(obj, 1, JSValue::string_ptr(type_ptr)); + // Set type — the numeric wire ID mysql2 exposes (#4917), recovered from + // sqlx's type name. + let type_id = mysql_type_id_from_name(col.type_info().name()); + js_object_set_field(obj, 1, JSValue::number(type_id)); let key1 = js_string_from_bytes("type".as_ptr(), 4); keys_array = js_array_push(keys_array, JSValue::string_ptr(key1)); - // Set length (0 for now - would need to extract from column metadata) - js_object_set_field(obj, 2, JSValue::number(0.0)); - let key2 = js_string_from_bytes("length".as_ptr(), 6); + // mysql2 exposes the same value as `columnType` too. + js_object_set_field(obj, 2, JSValue::number(type_id)); + let key2 = js_string_from_bytes("columnType".as_ptr(), 10); keys_array = js_array_push(keys_array, JSValue::string_ptr(key2)); + // Set length (0 — sqlx 0.8 keeps the wire `max_size` pub(crate), so the + // column display length is not recoverable; see #4917) + js_object_set_field(obj, 3, JSValue::number(0.0)); + let key3 = js_string_from_bytes("length".as_ptr(), 6); + keys_array = js_array_push(keys_array, JSValue::string_ptr(key3)); + // Attach keys to object js_object_set_keys(obj, keys_array); diff --git a/crates/perry-stdlib/src/pg/types.rs b/crates/perry-stdlib/src/pg/types.rs index 1e3baef431..c86b75b9df 100644 --- a/crates/perry-stdlib/src/pg/types.rs +++ b/crates/perry-stdlib/src/pg/types.rs @@ -1,8 +1,8 @@ //! Type conversions between PostgreSQL types and JSValue use perry_runtime::{ - js_object_alloc, js_object_get_field, js_object_set_field, js_string_from_bytes, JSValue, - ObjectHeader, StringHeader, + js_array_alloc, js_array_push, js_object_alloc, js_object_get_field, js_object_set_field, + js_object_set_keys, js_string_from_bytes, JSValue, ObjectHeader, StringHeader, }; use sqlx::postgres::PgRow; use sqlx::{Column, Row, TypeInfo}; @@ -189,26 +189,45 @@ fn column_value_to_jsvalue(row: &PgRow, index: usize) -> JSValue { } } -/// Create a FieldDef object for a column +/// Create a FieldDef object for a column, shaped like node-pg's +/// `result.fields[i]` (#4917): `dataTypeID` is the numeric type OID (what +/// `pg-types`-style custom parsers key on), `tableID`/`columnID` come from +/// the RowDescription via sqlx's `relation_id()`/`relation_attribute_no()` +/// (0 for expression columns, like Node). `dataTypeSize`/`dataTypeModifier` +/// are not exposed by sqlx 0.8 and report the "unknown/variable" sentinel -1. pub fn column_to_field_def(col: &sqlx::postgres::PgColumn) -> *mut ObjectHeader { - // FieldDef has these fields: - // 0: name (string) - // 1: dataTypeID (number - PostgreSQL OID) - // 2: tableID (number) - let obj = js_object_alloc(0, 3); + let obj = js_object_alloc(0, 7); + let mut keys_array = js_array_alloc(7); + let mut set = |obj: *mut ObjectHeader, idx: u32, key: &str, value: JSValue| { + js_object_set_field(obj, idx, value); + let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); + keys_array = js_array_push(keys_array, JSValue::string_ptr(key_ptr)); + }; - // Set name let name = col.name(); let name_ptr = js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field(obj, 0, JSValue::string_ptr(name_ptr)); + set(obj, 0, "name", JSValue::string_ptr(name_ptr)); - // Set dataTypeID (as string type name for now) - let type_name = col.type_info().name(); - let type_ptr = js_string_from_bytes(type_name.as_ptr(), type_name.len() as u32); - js_object_set_field(obj, 1, JSValue::string_ptr(type_ptr)); + let table_id = col.relation_id().map(|oid| oid.0 as f64).unwrap_or(0.0); + set(obj, 1, "tableID", JSValue::number(table_id)); + + let column_id = col + .relation_attribute_no() + .map(|attno| attno as f64) + .unwrap_or(0.0); + set(obj, 2, "columnID", JSValue::number(column_id)); + + // `oid()` is None only for custom types sqlx has not resolved against + // the catalog; report 0 (the `InvalidOid` sentinel) in that case. + let data_type_id = col.type_info().oid().map(|oid| oid.0 as f64).unwrap_or(0.0); + set(obj, 3, "dataTypeID", JSValue::number(data_type_id)); + + set(obj, 4, "dataTypeSize", JSValue::number(-1.0)); + set(obj, 5, "dataTypeModifier", JSValue::number(-1.0)); - // Set tableID (0 for now) - js_object_set_field(obj, 2, JSValue::number(0.0)); + let format_ptr = js_string_from_bytes("text".as_ptr(), 4); + set(obj, 6, "format", JSValue::string_ptr(format_ptr)); + js_object_set_keys(obj, keys_array); obj } diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 030ef9420f..7b965884b4 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -222,11 +222,15 @@ pub unsafe extern "C" fn js_zlib_inflate_sync(data_bits: i64) -> *mut BufferHead } /// Raw deflate compress synchronously (no zlib header, no adler32). -/// zlib.deflateRawSync(data) -> Buffer +/// zlib.deflateRawSync(data, options?) -> Buffer +/// +/// #4917: honor `options.level` (see `js_zlib_gzip_sync`). #[no_mangle] -pub unsafe extern "C" fn js_zlib_deflate_raw_sync(data_value: f64) -> *mut BufferHeader { +pub unsafe extern "C" fn js_zlib_deflate_raw_sync(data_value: f64, opts: f64) -> *mut BufferHeader { + perry_runtime::js_zlib_validate_options(opts, 8); + let level = Compression::new(perry_runtime::js_zlib_resolve_level(opts) as u32); let data = codec_bytes(data_value); - let mut encoder = DeflateEncoder::new(&data[..], Compression::default()); + let mut encoder = DeflateEncoder::new(&data[..], level); let mut compressed = Vec::new(); match encoder.read_to_end(&mut compressed) { Ok(_) => buffer_from_slice(&compressed), @@ -542,6 +546,9 @@ pub unsafe extern "C" fn js_zlib_zstd_decompress(data_value: f64, callback_value struct ZlibStreamState { codec: Codec, + /// Compression level resolved from the factory's `{ level }` option + /// (#4917) — kept so `.reset()` rebuilds the codec at the same level. + level: Compression, /// Streaming codec, fed incrementally by `.write()`. `None` for /// `createUnzip` (uses `input` + `run_codec` on `.end()`) or once finalized. codec_state: Option, @@ -624,14 +631,15 @@ fn next_zlib_id() -> i64 { id } -fn create_zlib_stream(codec: Codec) -> i64 { +fn create_zlib_stream(codec: Codec, level: Compression) -> i64 { ensure_zlib_gc_scanner(); let id = next_zlib_id(); ZLIB_STREAMS.lock().unwrap().insert( id, ZlibStreamState { codec, - codec_state: make_codec_state(codec), + level, + codec_state: make_codec_state(codec, level), input: Vec::new(), ended: false, bytes_written: 0, @@ -652,62 +660,90 @@ pub fn is_zlib_stream_handle(handle: i64) -> bool { // ── factories ────────────────────────────────────────────────────────────── +/// Resolve the `{ level }` option for a deflate-family stream factory +/// (decompressors resolve too — the value is simply unused — so a bad `level` +/// throws the same `RangeError` Node's shared `Zlib` constructor raises). +unsafe fn stream_factory_level(opts: f64) -> Compression { + Compression::new(perry_runtime::js_zlib_resolve_level(opts) as u32) +} + +/// Warn once when a Brotli/zstd factory receives an options object: their +/// option shape (`params` quality/window knobs) is not wired up yet (#4917). +unsafe fn warn_ignored_codec_params(opts: f64, name: &'static str) { + if JSValue::from_bits(opts.to_bits()).is_pointer() { + perry_runtime::stub_diag::perry_stub_warn( + name, + "options (params/quality) are accepted but ignored", + Some("#4917"), + ); + } +} + /// # Safety -/// FFI entry; `_opts` is the (ignored) NaN-boxed options object. +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_gzip(opts: f64) -> i64 { // Node validates the options object in the stream constructor, before any // data flows. Gzip compression needs `windowBits >= 9` (#3662). perry_runtime::js_zlib_validate_options(opts, 9); - create_zlib_stream(Codec::Gzip) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::Gzip, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_gunzip(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::Gunzip) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::Gunzip, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_deflate(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::Deflate) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::Deflate, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_inflate(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::Inflate) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::Inflate, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_deflate_raw(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::DeflateRaw) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::DeflateRaw, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_inflate_raw(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::InflateRaw) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::InflateRaw, level) } /// # Safety -/// FFI entry; `opts` is the NaN-boxed options object (validated, then ignored). +/// FFI entry; `opts` is the NaN-boxed options object. #[no_mangle] pub unsafe extern "C" fn js_zlib_create_unzip(opts: f64) -> i64 { perry_runtime::js_zlib_validate_options(opts, 8); - create_zlib_stream(Codec::Unzip) + let level = stream_factory_level(opts); + create_zlib_stream(Codec::Unzip, level) } /// # Safety -/// FFI entry; `_opts` is the (ignored) NaN-boxed options object. +/// FFI entry; `opts` is the NaN-boxed options object (Brotli `params` are not +/// wired up yet — a warn-once fires when an options object is passed). #[no_mangle] -pub unsafe extern "C" fn js_zlib_create_brotli_compress(_opts: f64) -> i64 { - create_zlib_stream(Codec::BrotliCompress) +pub unsafe extern "C" fn js_zlib_create_brotli_compress(opts: f64) -> i64 { + warn_ignored_codec_params(opts, "zlib.createBrotliCompress options"); + create_zlib_stream(Codec::BrotliCompress, Compression::default()) } /// `zlib.createBrotliDecompress(options?)` — returns a real Transform-stream /// handle. (Previously a feature-check Buffer stub; axios's @@ -715,22 +751,28 @@ pub unsafe extern "C" fn js_zlib_create_brotli_compress(_opts: f64) -> i64 { /// response now actually decodes.) /// /// # Safety -/// FFI entry; `_opts` is the (ignored) NaN-boxed options object. +/// FFI entry; `opts` is the NaN-boxed options object (decompression params +/// are not wired up yet — a warn-once fires when an options object is passed). #[no_mangle] -pub unsafe extern "C" fn js_zlib_create_brotli_decompress(_opts: f64) -> i64 { - create_zlib_stream(Codec::BrotliDecompress) +pub unsafe extern "C" fn js_zlib_create_brotli_decompress(opts: f64) -> i64 { + warn_ignored_codec_params(opts, "zlib.createBrotliDecompress options"); + create_zlib_stream(Codec::BrotliDecompress, Compression::default()) } /// # Safety -/// FFI entry; `_opts` is the (ignored) NaN-boxed options object. +/// FFI entry; `opts` is the NaN-boxed options object (zstd params are not +/// wired up yet — a warn-once fires when an options object is passed). #[no_mangle] -pub unsafe extern "C" fn js_zlib_create_zstd_compress(_opts: f64) -> i64 { - create_zlib_stream(Codec::ZstdCompress) +pub unsafe extern "C" fn js_zlib_create_zstd_compress(opts: f64) -> i64 { + warn_ignored_codec_params(opts, "zlib.createZstdCompress options"); + create_zlib_stream(Codec::ZstdCompress, Compression::default()) } /// # Safety -/// FFI entry; `_opts` is the (ignored) NaN-boxed options object. +/// FFI entry; `opts` is the NaN-boxed options object (zstd params are not +/// wired up yet — a warn-once fires when an options object is passed). #[no_mangle] -pub unsafe extern "C" fn js_zlib_create_zstd_decompress(_opts: f64) -> i64 { - create_zlib_stream(Codec::ZstdDecompress) +pub unsafe extern "C" fn js_zlib_create_zstd_decompress(opts: f64) -> i64 { + warn_ignored_codec_params(opts, "zlib.createZstdDecompress options"); + create_zlib_stream(Codec::ZstdDecompress, Compression::default()) } // ── one-shot codec used by the streams on .end() ───────────────────────────── @@ -851,19 +893,14 @@ impl CodecState { } } -fn make_codec_state(codec: Codec) -> Option { +fn make_codec_state(codec: Codec, level: Compression) -> Option { use flate2::write; Some(match codec { - Codec::Gzip => CodecState::GzEnc(write::GzEncoder::new(Vec::new(), Compression::default())), + Codec::Gzip => CodecState::GzEnc(write::GzEncoder::new(Vec::new(), level)), Codec::Gunzip => CodecState::GzDec(write::GzDecoder::new(Vec::new())), - Codec::Deflate => { - CodecState::ZlibEnc(write::ZlibEncoder::new(Vec::new(), Compression::default())) - } + Codec::Deflate => CodecState::ZlibEnc(write::ZlibEncoder::new(Vec::new(), level)), Codec::Inflate => CodecState::ZlibDec(write::ZlibDecoder::new(Vec::new())), - Codec::DeflateRaw => CodecState::DeflateEnc(write::DeflateEncoder::new( - Vec::new(), - Compression::default(), - )), + Codec::DeflateRaw => CodecState::DeflateEnc(write::DeflateEncoder::new(Vec::new(), level)), Codec::InflateRaw => CodecState::DeflateDec(write::DeflateDecoder::new(Vec::new())), Codec::BrotliCompress => { CodecState::BrotliEnc(brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22)) @@ -993,7 +1030,7 @@ pub fn zlib_stream_params(_handle: i64, cb: i64) { pub fn zlib_stream_reset(handle: i64) { let mut g = ZLIB_STREAMS.lock().unwrap(); if let Some(s) = g.get_mut(&handle) { - s.codec_state = make_codec_state(s.codec); + s.codec_state = make_codec_state(s.codec, s.level); s.input.clear(); s.ended = false; s.bytes_written = 0; @@ -1321,7 +1358,7 @@ pub unsafe extern "C" fn js_zlib_native_dispatch( ptr_to_f64(js_zlib_deflate_sync(arg(0).to_bits() as i64, arg(1)) as *const u8) } "inflateSync" => ptr_to_f64(js_zlib_inflate_sync(arg(0).to_bits() as i64) as *const u8), - "deflateRawSync" => ptr_to_f64(js_zlib_deflate_raw_sync(arg(0)) as *const u8), + "deflateRawSync" => ptr_to_f64(js_zlib_deflate_raw_sync(arg(0), arg(1)) as *const u8), "inflateRawSync" => ptr_to_f64(js_zlib_inflate_raw_sync(arg(0)) as *const u8), "unzipSync" => ptr_to_f64(js_zlib_unzip_sync(arg(0)) as *const u8), "brotliCompressSync" => { diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index c865764495..95123f2caf 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1409,7 +1409,7 @@ declare module "events" { } declare module "exponential-backoff" { - /** stdlib @perryStub retry options ignored; hardcoded 3 attempts / 100ms / x2 (#4917) */ + /** stdlib */ export function backOff(p0: any, p1: any): any; } @@ -4182,27 +4182,27 @@ declare module "zlib" { export function brotliDecompressSync(p0: string): Buffer; /** stdlib */ export function crc32(p0: string, seed?: number): number; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub params/quality options accepted but ignored, warns once (#4917) */ export function createBrotliCompress(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub params/quality options accepted but ignored, warns once (#4917) */ export function createBrotliDecompress(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub level honored; strategy/memLevel validated but not applied (#4917) */ export function createDeflate(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub level honored; strategy/memLevel validated but not applied (#4917) */ export function createDeflateRaw(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib */ export function createGunzip(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub level honored; strategy/memLevel validated but not applied (#4917) */ export function createGzip(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib */ export function createInflate(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib */ export function createInflateRaw(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib */ export function createUnzip(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub params/quality options accepted but ignored, warns once (#4917) */ export function createZstdCompress(options?: any): any; - /** stdlib @perryStub options (level/chunkSize/dictionary/...) accepted but ignored (#4917) */ + /** stdlib @perryStub params/quality options accepted but ignored, warns once (#4917) */ export function createZstdDecompress(options?: any): any; /** stdlib */ export function deflate(buffer: any, callback: any): void; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index b9d8f065d7..d45ade80cf 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -1200,7 +1200,7 @@ Total: 2782 entries across 114 modules. ### Methods -- `backOff` — module ⚠ **stub** — retry options ignored; hardcoded 3 attempts / 100ms / x2 (#4917) +- `backOff` — module ## `fastify` @@ -1521,7 +1521,7 @@ Total: 2782 entries across 114 modules. - `createServer` — module - `createServer` — module - `defaultPort` — instance *(class: `Agent`)* -- `destroy` — instance *(class: `Agent`)* ⚠ **stub** — real Agent object, but Perry does not pool sockets; this is a no-op (#4917) +- `destroy` — instance *(class: `Agent`)* - `destroy` — instance *(class: `IncomingMessage`)* - `destroy` — instance *(class: `ClientRequest`)* - `destroyed` — instance *(class: `Agent`)* @@ -1548,7 +1548,7 @@ Total: 2782 entries across 114 modules. - `keepAliveMsecs` — instance *(class: `Agent`)* - `keepAliveTimeout` — instance *(class: `HttpServer`)* - `keepAliveTimeoutBuffer` — instance *(class: `HttpServer`)* -- `keepSocketAlive` — instance *(class: `Agent`)* ⚠ **stub** — real Agent object, but Perry does not pool sockets; this is a no-op (#4917) +- `keepSocketAlive` — instance *(class: `Agent`)* ⚠ **stub** — reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917) - `listen` — instance *(class: `HttpServer`)* - `listenerCount` — instance *(class: `ClientRequest`)* - `listening` — instance *(class: `HttpServer`)* @@ -1570,7 +1570,7 @@ Total: 2782 entries across 114 modules. - `requestTimeout` — instance *(class: `HttpServer`)* - `requests` — instance *(class: `Agent`)* - `resume` — instance *(class: `IncomingMessage`)* -- `reuseSocket` — instance *(class: `Agent`)* ⚠ **stub** — real Agent object, but Perry does not pool sockets; this is a no-op (#4917) +- `reuseSocket` — instance *(class: `Agent`)* ⚠ **stub** — reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917) - `setEncoding` — instance *(class: `IncomingMessage`)* - `setGlobalProxyFromEnv` — module - `setHeader` — instance *(class: `ClientRequest`)* @@ -1883,7 +1883,7 @@ Total: 2782 entries across 114 modules. - `deleteMany` — instance - `deleteOne` — instance - `find` — instance -- `findOne` — instance ⚠ **stub** — resolves a JSON string, not a document object (#4917) +- `findOne` — instance - `insertMany` — instance - `insertOne` — instance - `updateMany` — instance @@ -3645,12 +3645,12 @@ Total: 2782 entries across 114 modules. - `once` — instance *(class: `Worker`)* - `postMessageToThread` — module - `receiveMessageOnPort` — module -- `ref` — instance *(class: `Worker`)* ⚠ **stub** — no-op; does not affect process event-loop ref-count (#4917) +- `ref` — instance *(class: `Worker`)* - `setEnvironmentData` — module - `startCpuProfile` — instance *(class: `Worker`)* - `startHeapProfile` — instance *(class: `Worker`)* - `terminate` — instance *(class: `Worker`)* -- `unref` — instance *(class: `Worker`)* ⚠ **stub** — no-op; does not affect process event-loop ref-count (#4917) +- `unref` — instance *(class: `Worker`)* ### Properties @@ -3726,17 +3726,17 @@ Total: 2782 entries across 114 modules. - `brotliDecompress` — module - `brotliDecompressSync` — module - `crc32` — module -- `createBrotliCompress` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createBrotliDecompress` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createDeflate` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createDeflateRaw` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createGunzip` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createGzip` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createInflate` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createInflateRaw` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createUnzip` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createZstdCompress` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) -- `createZstdDecompress` — module ⚠ **stub** — options (level/chunkSize/dictionary/...) accepted but ignored (#4917) +- `createBrotliCompress` — module ⚠ **stub** — params/quality options accepted but ignored, warns once (#4917) +- `createBrotliDecompress` — module ⚠ **stub** — params/quality options accepted but ignored, warns once (#4917) +- `createDeflate` — module ⚠ **stub** — level honored; strategy/memLevel validated but not applied (#4917) +- `createDeflateRaw` — module ⚠ **stub** — level honored; strategy/memLevel validated but not applied (#4917) +- `createGunzip` — module +- `createGzip` — module ⚠ **stub** — level honored; strategy/memLevel validated but not applied (#4917) +- `createInflate` — module +- `createInflateRaw` — module +- `createUnzip` — module +- `createZstdCompress` — module ⚠ **stub** — params/quality options accepted but ignored, warns once (#4917) +- `createZstdDecompress` — module ⚠ **stub** — params/quality options accepted but ignored, warns once (#4917) - `deflate` — module - `deflateRaw` — module - `deflateRawSync` — module diff --git a/test-files/test_gap_zlib_4917_level.ts b/test-files/test_gap_zlib_4917_level.ts new file mode 100644 index 0000000000..853c531576 --- /dev/null +++ b/test-files/test_gap_zlib_4917_level.ts @@ -0,0 +1,64 @@ +// #4917 — zlib options are honored, not silently dropped: `level` must +// change compressor output on the stream factories and on deflateRawSync. +// Byte-for-byte parity vs `node --experimental-strip-types`: only +// relational facts are printed (absolute compressed sizes legitimately +// differ between flate2 and Node's zlib). +import * as zlib from "node:zlib"; + +const data = "abcdefghij-0123456789-".repeat(20000); + +// ── one-shot: deflateRawSync(data, { level }) ── +const raw1 = zlib.deflateRawSync(data, { level: 1 }); +const raw9 = zlib.deflateRawSync(data, { level: 9 }); +console.log("deflateRawSync level1 >= level9:", raw1.length >= raw9.length); +console.log("deflateRawSync levels differ:", raw1.length !== raw9.length); +console.log( + "deflateRawSync roundtrip l1:", + zlib.inflateRawSync(raw1).toString() === data +); +console.log( + "deflateRawSync roundtrip l9:", + zlib.inflateRawSync(raw9).toString() === data +); + +// out-of-range level still throws Node's RangeError first +try { + zlib.deflateRawSync(data, { level: 99 }); + console.log("deflateRawSync level 99: no throw"); +} catch (e) { + console.log("deflateRawSync level 99 throws:", (e as Error).name); +} + +// ── stream factories: createGzip({ level }) / createDeflate({ level }) ── +function collect( + stream: any, + input: string, + done: (total: number) => void +): void { + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.length; + }); + stream.on("end", () => done(total)); + stream.end(input); +} + +collect(zlib.createGzip({ level: 1 }), data, (gz1) => { + collect(zlib.createGzip({ level: 9 }), data, (gz9) => { + console.log("createGzip level1 >= level9:", gz1 >= gz9); + console.log("createGzip levels differ:", gz1 !== gz9); + collect(zlib.createDeflate({ level: 1 }), data, (df1) => { + collect(zlib.createDeflate({ level: 9 }), data, (df9) => { + console.log("createDeflate level1 >= level9:", df1 >= df9); + console.log("createDeflate levels differ:", df1 !== df9); + // level-9 stream output must still gunzip back to the input + const gzBuf = zlib.gzipSync(data, { level: 9 }); + console.log( + "gzipSync l9 roundtrip:", + zlib.gunzipSync(gzBuf).toString() === data + ); + console.log("done"); + }); + }); + }); +}); From 60bc98157bc6581d0dcfa95064441521e9aeb74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 11 Jun 2026 13:14:10 +0200 Subject: [PATCH 2/2] lint: unbreak main's audit gates inherited from #4994 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit da4f8be7f (#4994) landed with a red lint job (admin merge), so every PR's merge-ref now fails it. Two findings, both from that commit: - array/sort.rs:126 — the second tail-copy loop's store sits one line outside the ±6-line window of the existing GC_STORE_AUDIT(STACK) marker; add the same marker next to that loop (same caller-rooted scratch-buffer justification). - array/from_concat.rs:174 + object/prototype_chain.rs:58 — new GcHeader probes outside addr_class.rs (from_concat is the #4994 split of the already-allowlisted concat_reverse probe; prototype_chain's is guarded by is_above_handle_band + is_valid_obj_ptr). Allowlist both with justifications. --- crates/perry-runtime/src/array/sort.rs | 1 + scripts/addr_class_allowlist.txt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/perry-runtime/src/array/sort.rs b/crates/perry-runtime/src/array/sort.rs index 4e60626692..e82b19cee3 100644 --- a/crates/perry-runtime/src/array/sort.rs +++ b/crates/perry-runtime/src/array/sort.rs @@ -122,6 +122,7 @@ unsafe fn stable_merge_sort_raw( l += 1; k += 1; } + // GC_STORE_AUDIT(STACK): tail copies target caller-rooted scratch buffers. while r < right { *dst.add(k) = *src.add(r); r += 1; diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index c9993a52e0..870ea1bfc8 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -23,6 +23,7 @@ crates/perry-runtime/src/arena/tests.rs | * | arena allocator/walker internals: crates/perry-runtime/src/arena/walk.rs | * | arena allocator/walker internals: header addresses come from block iteration or fresh allocation, never from NaN-box payloads crates/perry-runtime/src/array/alloc.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/concat_reverse.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up +crates/perry-runtime/src/array/from_concat.rs | * | #4994 split of the pre-existing concat GcHeader probe; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/flat_clone.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/generic.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/array/header.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up @@ -90,6 +91,7 @@ crates/perry-runtime/src/object/instanceof.rs | * | pre-existing GcHeader probe crates/perry-runtime/src/object/mod.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/native_call_method.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/object/object_ops.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up +crates/perry-runtime/src/object/prototype_chain.rs | as *const crate::gc::GcHeader | #4994 array-target proto probe; address guarded by is_above_handle_band + is_valid_obj_ptr immediately before the header read crates/perry-runtime/src/object/util_types.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/os/signal.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up crates/perry-runtime/src/path.rs | * | pre-existing GcHeader probe predating addr_class; address validated by call-site guards (magnitude/registry/is_valid_obj_ptr) -- migrate to addr_class::try_read_gc_header in a follow-up