Skip to content

Commit 9e7ef0e

Browse files
author
Ralph Küpper
committed
fix(stdlib): real semantics or loud warn for silently-ignored adapter options and no-ops (#4917)
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.
1 parent 48d6f8d commit 9e7ef0e

19 files changed

Lines changed: 792 additions & 223 deletions

File tree

crates/perry-api-manifest/src/entries.rs

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,19 @@ const ZLIB_OPTIONS_PARAM: ParamSpec = ParamSpec::Named {
428428
};
429429
const fn zlib_stream_factory(name: &'static str) -> ApiEntry {
430430
method_sig("zlib", name, false, None, ZLIB_STREAM_OPTS, TypeSpec::Any)
431-
.stub_note("options (level/chunkSize/dictionary/...) accepted but ignored (#4917)")
431+
}
432+
/// Deflate-family compressor factory: `level` is honored (#4917);
433+
/// `strategy`/`memLevel` are validated but not applied, and a supplied
434+
/// `dictionary` warns once instead of silently mis-compressing.
435+
const fn zlib_compressor_factory(name: &'static str) -> ApiEntry {
436+
zlib_stream_factory(name)
437+
.stub_note("level honored; strategy/memLevel validated but not applied (#4917)")
438+
}
439+
/// Brotli/zstd factory: their `params` option shape is not wired up; a
440+
/// passed options object warns once (#4917).
441+
const fn zlib_params_factory(name: &'static str) -> ApiEntry {
442+
zlib_stream_factory(name)
443+
.stub_note("params/quality options accepted but ignored, warns once (#4917)")
432444
}
433445

434446
/// Source-of-truth manifest. See module-level docs for what feeds it.
@@ -590,8 +602,9 @@ pub static API_MANIFEST: &[ApiEntry] = &[
590602
method("mongodb", "insertOne", true, None),
591603
method("mongodb", "insertMany", true, None),
592604
method("mongodb", "find", true, None),
593-
method("mongodb", "findOne", true, None)
594-
.stub_note("resolves a JSON string, not a document object (#4917)"),
605+
// #4917 — resolves a parsed document object (BSON-specific types in
606+
// relaxed extended-JSON shape, e.g. `_id.$oid`), or null.
607+
method("mongodb", "findOne", true, None),
595608
method("mongodb", "updateOne", true, None),
596609
method("mongodb", "updateMany", true, None),
597610
method("mongodb", "deleteOne", true, None),
@@ -1632,15 +1645,17 @@ pub static API_MANIFEST: &[ApiEntry] = &[
16321645
}],
16331646
TypeSpec::Bool,
16341647
),
1648+
// #4917 — real retry semantics: options (numOfAttempts/startingDelay/
1649+
// timeMultiple/maxDelay/delayFirstAttempt/jitter/retry) honored;
1650+
// Promise-returning tasks retry on rejection via promise reactions.
16351651
method_sig(
16361652
"exponential-backoff",
16371653
"backOff",
16381654
false,
16391655
None,
16401656
&[p_any("p0"), p_any("p1")],
16411657
TypeSpec::Any,
1642-
)
1643-
.stub_note("retry options ignored; hardcoded 3 attempts / 100ms / x2 (#4917)"),
1658+
),
16441659
method_sig(
16451660
"argon2",
16461661
"hash",
@@ -2223,19 +2238,22 @@ pub static API_MANIFEST: &[ApiEntry] = &[
22232238
),
22242239
// #1843 — Transform-stream factories. Each returns a stream handle
22252240
// supporting `.write`/`.end`/`.on('data'|'end'|'error')`/`.pipe`.
2226-
zlib_stream_factory("createGzip"),
2241+
// #4917 — deflate-family factories honor `options.level`; a supplied
2242+
// `dictionary` warns once (decompressors fail loudly without it, so
2243+
// the plain factories are no longer flagged).
2244+
zlib_compressor_factory("createGzip"),
22272245
zlib_stream_factory("createGunzip"),
2228-
zlib_stream_factory("createDeflate"),
2246+
zlib_compressor_factory("createDeflate"),
22292247
zlib_stream_factory("createInflate"),
2230-
zlib_stream_factory("createDeflateRaw"),
2248+
zlib_compressor_factory("createDeflateRaw"),
22312249
zlib_stream_factory("createInflateRaw"),
22322250
zlib_stream_factory("createUnzip"),
2233-
zlib_stream_factory("createBrotliCompress"),
2251+
zlib_params_factory("createBrotliCompress"),
22342252
// `zlib.createBrotliDecompress(options?)` — now a real Transform stream
22352253
// (still passes axios's `typeof === 'function'` module-init gate).
2236-
zlib_stream_factory("createBrotliDecompress"),
2237-
zlib_stream_factory("createZstdCompress"),
2238-
zlib_stream_factory("createZstdDecompress"),
2254+
zlib_params_factory("createBrotliDecompress"),
2255+
zlib_params_factory("createZstdCompress"),
2256+
zlib_params_factory("createZstdDecompress"),
22392257
method_sig(
22402258
"cron",
22412259
"validate",
@@ -2797,10 +2815,11 @@ pub static API_MANIFEST: &[ApiEntry] = &[
27972815
method("worker_threads", "once", true, Some("Worker")),
27982816
method("worker_threads", "off", true, Some("Worker")),
27992817
method("worker_threads", "terminate", true, Some("Worker")),
2800-
method("worker_threads", "ref", true, Some("Worker"))
2801-
.stub_note("no-op; does not affect process event-loop ref-count (#4917)"),
2802-
method("worker_threads", "unref", true, Some("Worker"))
2803-
.stub_note("no-op; does not affect process event-loop ref-count (#4917)"),
2818+
// #4917 — real: `ref()`/`unref()` flip `WorkerRecord.refed`, which
2819+
// `js_worker_threads_has_pending` checks to keep the event loop alive
2820+
// (a live refed worker holds the process; `unref()` releases it).
2821+
method("worker_threads", "ref", true, Some("Worker")),
2822+
method("worker_threads", "unref", true, Some("Worker")),
28042823
method("worker_threads", "getHeapStatistics", true, Some("Worker")),
28052824
method("worker_threads", "cpuUsage", true, Some("Worker")),
28062825
method("worker_threads", "getHeapSnapshot", true, Some("Worker")),
@@ -4665,13 +4684,14 @@ pub static API_MANIFEST: &[ApiEntry] = &[
46654684
class("http", "Agent"),
46664685
method("http", "Agent", false, None),
46674686
method("http", "getName", true, Some("Agent")),
4668-
method("http", "destroy", true, Some("Agent"))
4669-
.stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"),
4687+
// #4917 — `destroy()` really drops the per-agent reqwest client (=
4688+
// releases its keep-alive pool) and flips `destroyed`; not a stub.
4689+
method("http", "destroy", true, Some("Agent")),
46704690
method("http", "close", true, Some("Agent")),
46714691
method("http", "keepSocketAlive", true, Some("Agent"))
4672-
.stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"),
4692+
.stub_note("reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917)"),
46734693
method("http", "reuseSocket", true, Some("Agent"))
4674-
.stub_note("real Agent object, but Perry does not pool sockets; this is a no-op (#4917)"),
4694+
.stub_note("reqwest owns the keep-alive pool; per-socket hooks are no-ops, warns once (#4917)"),
46754695
// Synthetic `__get_<name>` / `__set_<name>` accessor methods (HIR
46764696
// rewrites bare `agent.maxSockets` reads to `__get_maxSockets()`
46774697
// when the receiver is class-tagged) + their bare-name twins for

crates/perry-api-manifest/tests/stub_inventory.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,17 @@ fn stub_inventory_matches_known_clusters() {
8282
// open/url/waitForDebugger/Session.post(4) + repl
8383
// start/REPLServer no-eval-loop(2).
8484
("#4916", 10),
85-
("#4917", 18), // stdlib adapters: zlib(11) + http.Agent(3) + worker ref/unref(2) + mongodb(1) + backoff(1)
85+
// #4917 (stdlib-adapter no-ops) — what remains after the real
86+
// semantics landed: zlib deflate-family compressor factories
87+
// honor `level` but still drop strategy/memLevel (3) +
88+
// Brotli/zstd factories ignore `params`, warn once (4) +
89+
// http.Agent per-socket keepSocketAlive/reuseSocket hooks (2).
90+
// Intentionally absent now: zlib decompressor factories (level
91+
// honored; a missing dictionary fails loudly), Agent.destroy
92+
// (drops the per-agent reqwest pool), worker ref/unref (real
93+
// event-loop refcount), mongodb.findOne (parsed document),
94+
// exponential-backoff options (honored, incl. retry predicate).
95+
("#4917", 9),
8696
];
8797
let expected_map: BTreeMap<String, usize> =
8898
expected.iter().map(|(k, v)| (k.to_string(), *v)).collect();
@@ -127,8 +137,10 @@ fn keystone_apis_are_flagged() {
127137
let must_be_stub: &[(&str, &str)] = &[
128138
("repl", "start"),
129139
("inspector", "post"),
140+
// still partial after #4917: level honored, strategy/memLevel dropped
130141
("zlib", "createGzip"),
131-
("mongodb", "findOne"),
142+
("zlib", "createBrotliCompress"),
143+
("http", "keepSocketAlive"),
132144
];
133145
for (module, name) in must_be_stub {
134146
let found = iter_entries().any(|e| e.module == *module && e.name == *name && e.stub);
@@ -137,8 +149,18 @@ fn keystone_apis_are_flagged() {
137149

138150
// The inverse: APIs implemented for real must NOT stay flagged.
139151
// v8 heap snapshots emit a real GC-walk object graph since #4916.
140-
let must_not_be_stub: &[(&str, &str)] =
141-
&[("v8", "getHeapSnapshot"), ("v8", "writeHeapSnapshot")];
152+
// mongodb.findOne resolves a parsed document, backOff honors its
153+
// options, and worker ref/unref drive the event-loop refcount since
154+
// #4917.
155+
let must_not_be_stub: &[(&str, &str)] = &[
156+
("v8", "getHeapSnapshot"),
157+
("v8", "writeHeapSnapshot"),
158+
("mongodb", "findOne"),
159+
("exponential-backoff", "backOff"),
160+
("worker_threads", "ref"),
161+
("worker_threads", "unref"),
162+
("http", "destroy"),
163+
];
142164
for (module, name) in must_not_be_stub {
143165
let flagged = iter_entries().any(|e| e.module == *module && e.name == *name && e.stub);
144166
assert!(

crates/perry-codegen/src/lower_call/native_table/media.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[
382382
method: "deflateRawSync",
383383
class_filter: None,
384384
runtime: "js_zlib_deflate_raw_sync",
385-
args: &[NA_F64],
385+
args: &[NA_F64, NA_F64],
386386
ret: NR_PTR,
387387
},
388388
NativeModSig {

crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) {
596596
module.declare_function("js_zlib_gzip", VOID, &[DOUBLE, DOUBLE]);
597597
module.declare_function("js_zlib_inflate_sync", I64, &[I64]);
598598
module.declare_function("js_zlib_inflate", VOID, &[DOUBLE, DOUBLE]);
599-
module.declare_function("js_zlib_deflate_raw_sync", I64, &[DOUBLE]);
599+
module.declare_function("js_zlib_deflate_raw_sync", I64, &[DOUBLE, DOUBLE]);
600600
module.declare_function("js_zlib_deflate_raw", VOID, &[DOUBLE, DOUBLE]);
601601
module.declare_function("js_zlib_inflate_raw_sync", I64, &[DOUBLE]);
602602
module.declare_function("js_zlib_inflate_raw", VOID, &[DOUBLE, DOUBLE]);

crates/perry-ext-http/src/agent.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,11 +851,18 @@ fn json_value_to_string(v: &serde_json::Value) -> String {
851851
}
852852

853853
// ------------------------------------------------------------------
854-
// destroy / keepSocketAlive / reuseSocket — chainable no-ops
854+
// keepSocketAlive / reuseSocket — chainable no-ops (reqwest owns the
855+
// keep-alive pool, so there is no per-socket hook to forward to);
856+
// destroy is real (drops the cached client below).
855857
// ------------------------------------------------------------------
856858

857859
#[no_mangle]
858860
pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle {
861+
perry_runtime::stub_diag::perry_stub_warn(
862+
"http.Agent keepSocketAlive/reuseSocket",
863+
"reqwest owns the keep-alive pool; per-socket hooks are no-ops",
864+
Some("#4917"),
865+
);
859866
handle
860867
}
861868

crates/perry-ext-mysql2/src/lib.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -320,16 +320,56 @@ fn raw_row_to_js_array(row: &RawRowData) -> *mut ArrayHeader {
320320
arr
321321
}
322322

323+
/// Map sqlx's MySQL type *name* back to the wire-protocol numeric type ID
324+
/// (`enum_field_types`, what Node's mysql2 puts in `field.type`/`columnType`).
325+
/// Twin of `perry_stdlib::mysql2::types::mysql_type_id_from_name` (#4917) —
326+
/// this crate cannot depend on perry-stdlib, keep the two in sync.
327+
fn mysql_type_id_from_name(name: &str) -> f64 {
328+
let base = name.strip_suffix(" UNSIGNED").unwrap_or(name);
329+
let id: u8 = match base {
330+
"BOOLEAN" | "TINYINT" => 1,
331+
"SMALLINT" => 2,
332+
"INT" => 3,
333+
"FLOAT" => 4,
334+
"DOUBLE" => 5,
335+
"NULL" => 6,
336+
"TIMESTAMP" => 7,
337+
"BIGINT" => 8,
338+
"MEDIUMINT" => 9,
339+
"DATE" => 10,
340+
"TIME" => 11,
341+
"DATETIME" => 12,
342+
"YEAR" => 13,
343+
"BIT" => 16,
344+
"JSON" => 245,
345+
"DECIMAL" => 246,
346+
"ENUM" => 247,
347+
"SET" => 248,
348+
"TINYBLOB" | "TINYTEXT" => 249,
349+
"MEDIUMBLOB" | "MEDIUMTEXT" => 250,
350+
"LONGBLOB" | "LONGTEXT" => 251,
351+
"BLOB" | "TEXT" => 252,
352+
"VARCHAR" | "VARBINARY" => 253,
353+
"CHAR" | "BINARY" => 254,
354+
"GEOMETRY" => 255,
355+
_ => 0,
356+
};
357+
id as f64
358+
}
359+
323360
fn raw_column_to_field_packet(col: &RawColumnInfo) -> *mut ObjectHeader {
324-
let (packed, shape_id) = build_object_shape(&["name", "type", "length"]);
361+
let (packed, shape_id) = build_object_shape(&["name", "type", "columnType", "length"]);
325362
let obj =
326-
unsafe { js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32) };
363+
unsafe { js_object_alloc_with_shape(shape_id, 4, packed.as_ptr(), packed.len() as u32) };
327364
let name_str = alloc_string(&col.name);
328-
let type_str = alloc_string(&col.type_name);
365+
// #4917: `type`/`columnType` carry the numeric wire ID mysql2 exposes;
366+
// `length` stays 0 (sqlx 0.8 keeps the wire `max_size` pub(crate)).
367+
let type_id = mysql_type_id_from_name(&col.type_name);
329368
unsafe {
330369
js_object_set_field(obj, 0, JsValue::from_string_ptr(name_str.as_raw()));
331-
js_object_set_field(obj, 1, JsValue::from_string_ptr(type_str.as_raw()));
332-
js_object_set_field(obj, 2, JsValue::from_number(0.0));
370+
js_object_set_field(obj, 1, JsValue::from_number(type_id));
371+
js_object_set_field(obj, 2, JsValue::from_number(type_id));
372+
js_object_set_field(obj, 3, JsValue::from_number(0.0));
333373
}
334374
obj
335375
}

crates/perry-ext-pg/src/lib.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,40 @@ fn row_to_js_object(row: &PgRow) -> *mut ObjectHeader {
162162
obj
163163
}
164164

165-
/// Build a `FieldDef`-shaped object: `{ name, dataTypeID, tableID }`.
165+
/// Build a `FieldDef`-shaped object matching node-pg's `result.fields[i]`
166+
/// (#4917): `dataTypeID` is the numeric type OID, `tableID`/`columnID` come
167+
/// from the RowDescription (0 for expression columns, like Node).
168+
/// `dataTypeSize`/`dataTypeModifier` are not exposed by sqlx 0.8 and report
169+
/// the "unknown/variable" sentinel -1. Twin of
170+
/// `perry_stdlib::pg::types::column_to_field_def` — keep in sync.
166171
fn column_to_field_def(col: &PgColumn) -> *mut ObjectHeader {
167-
let (packed, shape_id) = build_object_shape(&["name", "dataTypeID", "tableID"]);
172+
let (packed, shape_id) = build_object_shape(&[
173+
"name",
174+
"tableID",
175+
"columnID",
176+
"dataTypeID",
177+
"dataTypeSize",
178+
"dataTypeModifier",
179+
"format",
180+
]);
168181
let obj =
169-
unsafe { js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32) };
182+
unsafe { js_object_alloc_with_shape(shape_id, 7, packed.as_ptr(), packed.len() as u32) };
170183
let name_str = alloc_string(col.name());
171-
let type_str = alloc_string(col.type_info().name());
184+
let table_id = col.relation_id().map(|oid| oid.0 as f64).unwrap_or(0.0);
185+
let column_id = col
186+
.relation_attribute_no()
187+
.map(|attno| attno as f64)
188+
.unwrap_or(0.0);
189+
let data_type_id = col.type_info().oid().map(|oid| oid.0 as f64).unwrap_or(0.0);
190+
let format_str = alloc_string("text");
172191
unsafe {
173192
js_object_set_field(obj, 0, JsValue::from_string_ptr(name_str.as_raw()));
174-
js_object_set_field(obj, 1, JsValue::from_string_ptr(type_str.as_raw()));
175-
js_object_set_field(obj, 2, JsValue::from_number(0.0));
193+
js_object_set_field(obj, 1, JsValue::from_number(table_id));
194+
js_object_set_field(obj, 2, JsValue::from_number(column_id));
195+
js_object_set_field(obj, 3, JsValue::from_number(data_type_id));
196+
js_object_set_field(obj, 4, JsValue::from_number(-1.0));
197+
js_object_set_field(obj, 5, JsValue::from_number(-1.0));
198+
js_object_set_field(obj, 6, JsValue::from_string_ptr(format_str.as_raw()));
176199
}
177200
obj
178201
}

crates/perry-runtime/src/node_submodules/zlib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,20 @@ pub extern "C" fn js_zlib_validate_options(opts: f64, min_window_bits: i32) {
219219
validate_option_range(ptr, b"strategy", "options.strategy", 0, 4);
220220
validate_option_chunk_size(ptr);
221221
validate_option_range(ptr, b"flush", "options.flush", 0, 5);
222+
223+
// #4917: `level` is honored and the ranged options above are validated,
224+
// but a preset `dictionary` is not threaded through flate2. Silently
225+
// dropping it would mis-compress against peers that expect it to apply,
226+
// so warn once instead.
227+
let dict = read_option_field(ptr, b"dictionary");
228+
let dv = crate::value::JSValue::from_bits(dict.to_bits());
229+
if !dv.is_undefined() && !dv.is_null() {
230+
crate::stub_diag::perry_stub_warn(
231+
"zlib options.dictionary",
232+
"the preset dictionary option is accepted but not applied",
233+
Some("#4917"),
234+
);
235+
}
222236
}
223237

224238
/// Validate the `buffer` argument to a one-shot zlib codec (`gzipSync`,

0 commit comments

Comments
 (0)