From 86f553b6c1652c6741922c9ca1b37e0e73d0269b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:35:25 +0200 Subject: [PATCH 01/20] fix(zlib): keep zip from selecting low-ratio backend (cherry picked from commit 8f3452d14de2c08b2c2f374bfaf0dc12ba49828d) --- Cargo.toml | 9 +++++- ...test_gap_10810_zlib_default_compression.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_10810_zlib_default_compression.ts diff --git a/Cargo.toml b/Cargo.toml index d94af89bea..3d5f4b78e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -486,7 +486,14 @@ base64 = "0.22" # RFC 6455 §4.2.2's `Sec-WebSocket-Accept` digest. Already in the lockfile # through perry-stdlib's optional `sha1`, so naming it here adds no package. sha1 = "0.11" -zip = "8" +# Keep zip's default codec set, but select its backend-neutral flate2 feature. +# `zip`'s `deflate` feature forces flate2's zlib-rs backend across the whole +# Cargo build; feature unification then makes node:zlib's default gzip output +# almost twice as large (#10810). +zip = { version = "8", default-features = false, features = [ + "aes-crypto", "bzip2", "deflate64", "deflate-flate2", "deflate-zopfli", + "lzma", "ppmd", "time", "zstd", "xz", +] } # zstd for transparent decompression of the npm-shipped `*.a.zst` archives. # Statically vendored (no system libzstd dependency on the user's machine). zstd = "0.13" diff --git a/test-files/test_gap_10810_zlib_default_compression.ts b/test-files/test_gap_10810_zlib_default_compression.ts new file mode 100644 index 0000000000..88c52d4185 --- /dev/null +++ b/test-files/test_gap_10810_zlib_default_compression.ts @@ -0,0 +1,29 @@ +// #10810 — building Perry's CLI must not select a lower-ratio flate2 backend +// for node:zlib through Cargo feature unification. The former backend produced +// roughly 4.2 KiB here; Node and the intended backend stay below 3 KiB. +import { gzip, gzipSync } from "node:zlib"; +import { promisify } from "node:util"; + +const payload = Buffer.alloc(512 * 1024); +for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + +function gzipCallback(data: Buffer): Promise { + return new Promise((resolve, reject) => { + gzip(data, (error: Error | null, output: Buffer) => + error ? reject(error) : resolve(output) + ); + }); +} + +const isCompact = (output: Buffer): boolean => output.length < 3_000; + +console.log("gzipSync default is compact:", isCompact(gzipSync(payload))); +console.log( + "gzip callback default is compact:", + isCompact(await gzipCallback(payload)) +); +const gzipPromise = promisify(gzip); +console.log( + "promisified gzip default is compact:", + isCompact((await gzipPromise(payload)) as Buffer) +); From 82d18ae9c7c062af08ff5c5b609270d29f632eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:36:09 +0200 Subject: [PATCH 02/20] docs: add PR 11020 changelog fragment (cherry picked from commit 971ab530e7414738073512f770ec56764ceca8ec) --- changelog.d/11020-zlib-default-compression.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/11020-zlib-default-compression.md diff --git a/changelog.d/11020-zlib-default-compression.md b/changelog.d/11020-zlib-default-compression.md new file mode 100644 index 0000000000..b92fcbedc5 --- /dev/null +++ b/changelog.d/11020-zlib-default-compression.md @@ -0,0 +1,8 @@ +Default gzip and deflate compression no longer degrade when Perry's CLI and +the external zlib wrapper are built together. The CLI's ZIP dependency enabled +flate2's `zlib-rs` backend through Cargo feature unification, which made the +issue's 4 MiB payload compress to 31,388 bytes instead of roughly 16 KiB. + +ZIP keeps its existing codec support while selecting the backend-neutral +flate2 feature, allowing Perry's normal Rust backend to serve `node:zlib`. +Regression coverage checks synchronous, callback, and promisified gzip calls. From 19015cf8900377c2eed4b1d32ea225de896d4e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:37:58 +0200 Subject: [PATCH 03/20] fix(zlib): scope zip backend selection to CLI (cherry picked from commit f00c8545fccba48a141d601c4b16c0ea1dbd1a5f) --- Cargo.toml | 9 +-------- crates/perry/Cargo.toml | 9 ++++++++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3d5f4b78e0..d94af89bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -486,14 +486,7 @@ base64 = "0.22" # RFC 6455 §4.2.2's `Sec-WebSocket-Accept` digest. Already in the lockfile # through perry-stdlib's optional `sha1`, so naming it here adds no package. sha1 = "0.11" -# Keep zip's default codec set, but select its backend-neutral flate2 feature. -# `zip`'s `deflate` feature forces flate2's zlib-rs backend across the whole -# Cargo build; feature unification then makes node:zlib's default gzip output -# almost twice as large (#10810). -zip = { version = "8", default-features = false, features = [ - "aes-crypto", "bzip2", "deflate64", "deflate-flate2", "deflate-zopfli", - "lzma", "ppmd", "time", "zstd", "xz", -] } +zip = "8" # zstd for transparent decompression of the npm-shipped `*.a.zst` archives. # Statically vendored (no system libzstd dependency on the user's machine). zstd = "0.13" diff --git a/crates/perry/Cargo.toml b/crates/perry/Cargo.toml index 7720c8e8ec..0c16f769e4 100644 --- a/crates/perry/Cargo.toml +++ b/crates/perry/Cargo.toml @@ -66,7 +66,14 @@ dirs.workspace = true flate2.workspace = true tar.workspace = true base64.workspace = true -zip.workspace = true +# Keep zip's default codec set, but select its backend-neutral flate2 feature. +# `zip`'s `deflate` feature forces flate2's zlib-rs backend across the whole +# Cargo build; feature unification then makes node:zlib's default gzip output +# almost twice as large (#10810). +zip = { version = "8", default-features = false, features = [ + "aes-crypto", "bzip2", "deflate64", "deflate-flate2", "deflate-zopfli", + "lzma", "ppmd", "time", "zstd", "xz", +] } tempfile.workspace = true libc.workspace = true zstd.workspace = true From eff5e0272874aa6b90b122321200bba711572b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:50:45 +0200 Subject: [PATCH 04/20] fix(runtime): preserve spread iterator throws in debug builds (cherry picked from commit 0f43f93d083d5876c0b7348a928110faa13e3fae) --- crates/perry-runtime/src/array/iterator.rs | 33 +++++++++++++++++++ .../tests/issue_10058_push_spread_scaling.rs | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 78ea691b39..9a48155987 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1241,8 +1241,27 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { throw_not_iterable(value()); } +// This helper runs the user-observable iterator protocol and can therefore +// throw through the generated caller. Debug/test archives transport that +// throw with Rust unwinding, so their outer ABI must permit it. Production +// uses Perry's raw exception transport and must retain the plain C boundary; +// see closure/dispatch/value_call.rs (#8479). +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_array_spread_append(dest: *mut ArrayHeader, source: f64) -> *mut ArrayHeader { + js_array_spread_append_impl(dest, source) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_array_spread_append( + dest: *mut ArrayHeader, + source: f64, +) -> *mut ArrayHeader { + js_array_spread_append_impl(dest, source) +} + +fn js_array_spread_append_impl(dest: *mut ArrayHeader, source: f64) -> *mut ArrayHeader { // Materializing an intercepted iterator can allocate and move the // destination. Keep it rooted across that protocol walk and re-read it // before appending. Ordinary dense arrays need no temporary: the same @@ -1557,8 +1576,22 @@ fn settled_promise_value(value: f64) -> Option { /// Used by spread on generators, Array.from on generators, etc. /// Calls `.next()` in a loop until `.done` is true, collecting `.value` entries. +// `.next()` is arbitrary user code. Match the conditional ABI on the dynamic +// call bridges so a debug/test archive can carry a catchable throw across this +// exported helper without rustc's abort-on-unwind guard. +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { + js_iterator_to_array_impl(iter_f64) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { + js_iterator_to_array_impl(iter_f64) +} + +fn js_iterator_to_array_impl(iter_f64: f64) -> *mut ArrayHeader { use crate::closure; use crate::object::{js_object_get_field_by_name, ObjectHeader}; use crate::string::js_string_from_bytes; diff --git a/crates/perry/tests/issue_10058_push_spread_scaling.rs b/crates/perry/tests/issue_10058_push_spread_scaling.rs index f14502549a..80a376cac1 100644 --- a/crates/perry/tests/issue_10058_push_spread_scaling.rs +++ b/crates/perry/tests/issue_10058_push_spread_scaling.rs @@ -153,7 +153,8 @@ fn spread_push_is_iterator_correct_and_gc_safe_on_reused_destinations() { assert_success("Node oracle", &node); for moving_gc in [false, true] { let perry = run(&binary, moving_gc); - assert_success("compiled fixture", &perry); + let mode = if moving_gc { "moving GC" } else { "plain" }; + assert_success(&format!("compiled fixture ({mode})"), &perry); assert_eq!( perry.stdout, node.stdout, From b45dc542f23157e2903314a9a767ae7dbc857f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:51:24 +0200 Subject: [PATCH 05/20] docs(changelog): note spread iterator unwind fix (cherry picked from commit 2d7848f36104f41834f5c7882ce432bbd0dc7d40) --- changelog.d/11022-spread-iterator-debug-unwind.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/11022-spread-iterator-debug-unwind.md diff --git a/changelog.d/11022-spread-iterator-debug-unwind.md b/changelog.d/11022-spread-iterator-debug-unwind.md new file mode 100644 index 0000000000..a23dcc0c3e --- /dev/null +++ b/changelog.d/11022-spread-iterator-debug-unwind.md @@ -0,0 +1,3 @@ +### Fixed + +- Preserve exceptions thrown by custom iterators during array push spread when using debug or test runtime archives, so JavaScript `catch` handlers receive them instead of the process aborting (#11010). From 0c208130f9cabcf65a3c403a7655beed8534b20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:25:30 +0200 Subject: [PATCH 06/20] fix(streams): lower namespace ReadableStream.from (cherry picked from commit 1e8661b664bbd7f1125039e16e302ebbf686c771) --- .../src/destructuring/var_decl/native_new.rs | 46 ++++--------- .../src/destructuring/var_decl/type_infer.rs | 3 + .../lower/expr_call/static_and_instance.rs | 24 +++++++ crates/perry-hir/src/lower_types.rs | 63 +++++++++++++++++ .../tests/readable_stream_from_lowering.rs | 69 +++++++++++++++++++ .../test_gap_10568_readable_stream_from.ts | 9 +++ 6 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 test-files/test_gap_10568_readable_stream_from.ts diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 142778d870..d34dc93d98 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -147,46 +147,24 @@ pub(crate) fn register_native_from_new_and_calls( } } - // #1645: `const rs = ReadableStream.from(iterable)` — the `.from` + // #1645/#10568: `const rs = ReadableStream.from(iterable)` — including + // the namespace-import spelling `(streamWeb.ReadableStream as any).from`. // Call result is typed Any, so register the binding as a // ReadableStream native instance (mirroring `new ReadableStream`'s // typing). Without this, `rs.getReader()` / `for await (const c of // rs)` fall to generic dispatch on the numeric stream handle and // fail. The Call itself is routed to `js_readable_stream_from_iterable` // in codegen (expr/calls.rs). - if let Some(init_expr) = &decl.init { - if let ast::Expr::Call(call) = init_expr.as_ref() { - if let ast::Callee::Expr(callee) = &call.callee { - if let ast::Expr::Member(m) = callee.as_ref() { - if let ast::MemberProp::Ident(prop) = &m.prop { - if prop.sym.as_ref() == "from" { - let mut obj_inner: &ast::Expr = m.obj.as_ref(); - loop { - obj_inner = match obj_inner { - ast::Expr::TsAs(x) => &x.expr, - ast::Expr::TsNonNull(x) => &x.expr, - ast::Expr::TsSatisfies(x) => &x.expr, - ast::Expr::TsTypeAssertion(x) => &x.expr, - ast::Expr::TsConstAssertion(x) => &x.expr, - ast::Expr::Paren(x) => &x.expr, - _ => break, - }; - } - if matches!( - obj_inner, - ast::Expr::Ident(i) if i.sym.as_ref() == "ReadableStream" - ) { - ctx.register_native_instance( - name.to_string(), - "readable_stream".to_string(), - "ReadableStream".to_string(), - ); - } - } - } - } - } - } + if decl + .init + .as_deref() + .is_some_and(|init| crate::lower_types::is_web_readable_stream_from_call(ctx, init)) + { + ctx.register_native_instance( + name.to_string(), + "readable_stream".to_string(), + "ReadableStream".to_string(), + ); } // Check if this is an awaited native class instantiation (e.g., await new Redis()) diff --git a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs index 952495e3bc..bd462df364 100644 --- a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs +++ b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs @@ -203,6 +203,9 @@ pub(crate) fn infer_decl_type( // dispatches via the native-instance registry, not this declared type. if matches!(ty, Type::Any) { if let Some(init_expr) = &decl.init { + if crate::lower_types::is_web_readable_stream_from_call(ctx, init_expr) { + ty = Type::Named("ReadableStream".to_string()); + } if let ast::Expr::Call(call) = init_expr.as_ref() { if let ast::Callee::Expr(callee) = &call.callee { if let ast::Expr::Member(m) = callee.as_ref() { diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index bf9f783866..e50e0a5ba9 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -71,6 +71,30 @@ pub(super) fn try_static_method_and_instance( // handle it. Refs test262 language/arguments-object // cls-*-static-*-spread-operator. let static_call_has_spread = call.args.iter().any(|a| a.spread.is_some()); + + // `import * as web from "node:stream/web"; (web.ReadableStream as + // any).from(xs)` has a nested namespace receiver. Route it through the + // same native factory as the named-import form before the generic + // module.Class.staticMethod arm sees it as `stream/web.ReadableStream`. + if !static_call_has_spread { + if let ast::Expr::Member(member) = expr { + if matches!(&member.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "from") + && crate::lower_types::is_web_readable_stream_constructor_ref( + ctx, + member.obj.as_ref(), + ) + { + return Ok(Ok(Expr::NativeMethodCall { + module: "readable_stream".to_string(), + class_name: Some("ReadableStream".to_string()), + object: None, + method: "from".to_string(), + args, + })); + } + } + } + // Check for static method calls (e.g., Counter.increment()) if let ast::Expr::Member(member) = expr { if let ast::Expr::Ident(obj_ident) = unwrap_ts_wrappers(member.obj.as_ref()) { diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 8a85fe7512..c648c1e408 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -1137,6 +1137,69 @@ pub(crate) fn is_node_readable_static_factory_call( && is_node_readable_constructor_ref(ctx, member.obj.as_ref()) } +fn is_web_readable_stream_module_alias(ctx: &LoweringContext, name: &str) -> bool { + matches!( + ctx.lookup_native_module(name), + Some(("stream/web" | "node:stream/web", None)) + ) || matches!( + ctx.namespace_import_sources.get(name).map(String::as_str), + Some("stream/web" | "node:stream/web") + ) +} + +pub(crate) fn is_web_readable_stream_constructor_ref( + ctx: &LoweringContext, + expr: &ast::Expr, +) -> bool { + match expr { + ast::Expr::Ident(ident) => { + let name = ident.sym.as_ref(); + matches!( + ctx.lookup_native_module(name), + Some(("stream/web" | "node:stream/web", Some("ReadableStream"))) + ) || (name == "ReadableStream" && !ctx.shadows_unqualified_global(name)) + } + ast::Expr::Member(member) => { + let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + else { + return false; + }; + prop.sym.as_ref() == "ReadableStream" + && is_web_readable_stream_module_alias(ctx, obj.sym.as_ref()) + } + ast::Expr::Paren(paren) => is_web_readable_stream_constructor_ref(ctx, &paren.expr), + ast::Expr::TsAs(ts_as) => is_web_readable_stream_constructor_ref(ctx, &ts_as.expr), + ast::Expr::TsTypeAssertion(ts_assert) => { + is_web_readable_stream_constructor_ref(ctx, &ts_assert.expr) + } + ast::Expr::TsNonNull(non_null) => { + is_web_readable_stream_constructor_ref(ctx, &non_null.expr) + } + ast::Expr::TsConstAssertion(const_assert) => { + is_web_readable_stream_constructor_ref(ctx, &const_assert.expr) + } + ast::Expr::TsSatisfies(satisfies) => { + is_web_readable_stream_constructor_ref(ctx, &satisfies.expr) + } + _ => false, + } +} + +pub(crate) fn is_web_readable_stream_from_call(ctx: &LoweringContext, expr: &ast::Expr) -> bool { + let ast::Expr::Call(call) = expr else { + return false; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return false; + }; + let ast::Expr::Member(member) = callee.as_ref() else { + return false; + }; + matches!(&member.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "from") + && is_web_readable_stream_constructor_ref(ctx, member.obj.as_ref()) +} + fn expr_may_have_typed_receiver(expr: &ast::Expr, ctx: &LoweringContext) -> bool { match expr { ast::Expr::Lit(ast::Lit::Str(_)) => true, diff --git a/crates/perry-hir/tests/readable_stream_from_lowering.rs b/crates/perry-hir/tests/readable_stream_from_lowering.rs index bf93ac6a7e..724ed37691 100644 --- a/crates/perry-hir/tests/readable_stream_from_lowering.rs +++ b/crates/perry-hir/tests/readable_stream_from_lowering.rs @@ -58,3 +58,72 @@ fn readable_stream_from_static_factory_lowers_to_native_factory() { other => panic!("expected ReadableStream.from NativeMethodCall, got: {other:#?}"), } } + +#[test] +fn namespace_readable_stream_from_lowers_to_native_factory_and_reader() { + let module = lower( + r#" + import * as streamWeb from "node:stream/web"; + const rs: any = (streamWeb.ReadableStream as any).from(["a"]); + const reader = rs.getReader(); + const result = reader.read(); + "#, + ); + + let lets: Vec<(&str, &Expr)> = module + .init + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + name, + init: Some(expr), + .. + } => Some((name.as_str(), expr)), + _ => None, + }) + .collect(); + + assert!(matches!( + lets.as_slice(), + [ + ( + "rs", + Expr::NativeMethodCall { + module, + class_name: Some(class_name), + object: None, + method, + .. + } + ), + ( + "reader", + Expr::NativeMethodCall { + module: reader_module, + class_name: Some(reader_class), + object: Some(_), + method: reader_method, + .. + } + ), + ( + "result", + Expr::NativeMethodCall { + module: read_module, + class_name: Some(read_class), + object: Some(_), + method: read_method, + .. + } + ) + ] if module == "readable_stream" + && class_name == "ReadableStream" + && method == "from" + && reader_module == "readable_stream" + && reader_class == "ReadableStream" + && reader_method == "getReader" + && read_module == "readable_stream_reader" + && read_class == "ReadableStreamDefaultReader" + && read_method == "read" + )); +} diff --git a/test-files/test_gap_10568_readable_stream_from.ts b/test-files/test_gap_10568_readable_stream_from.ts new file mode 100644 index 0000000000..a82f8ccfd0 --- /dev/null +++ b/test-files/test_gap_10568_readable_stream_from.ts @@ -0,0 +1,9 @@ +import * as streamWeb from "node:stream/web"; + +const stream: any = (streamWeb.ReadableStream as any).from(["x", "y", "z"]); +const reader = stream.getReader(); + +for (let index = 0; index < 4; index++) { + const result: any = await reader.read(); + console.log(index, result.done, result.value, JSON.stringify(Object.keys(result))); +} From c0cf2060855037d82af4429294fecd705341378f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:30:13 +0200 Subject: [PATCH 07/20] docs: add changelog for #11026 (cherry picked from commit 5666a351e9423713e5dba7f8477f924958fa1510) --- changelog.d/11026-readable-stream-from-namespace.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/11026-readable-stream-from-namespace.md diff --git a/changelog.d/11026-readable-stream-from-namespace.md b/changelog.d/11026-readable-stream-from-namespace.md new file mode 100644 index 0000000000..606250f9f1 --- /dev/null +++ b/changelog.d/11026-readable-stream-from-namespace.md @@ -0,0 +1,7 @@ +### Fixed + +- `ReadableStream.from()` now works through a `node:stream/web` namespace + import, including TypeScript-cast forms such as + `(streamWeb.ReadableStream as any).from(items)`. The returned stream and + reader retain their native types, so `read()` yields `{ done, value }` + objects and iterable drain loops terminate. From 9c9d8283c17d7b6ec4b22e8ab7a8e3ce9640d705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:42:44 +0200 Subject: [PATCH 08/20] fix(stream): honor PassThrough subclass transforms (cherry picked from commit 721cd4ea5b664bd3e3281be3a2f888cf520a2840) --- crates/perry-codegen/src/codegen/helpers.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 5 +- .../perry-codegen/src/expr/this_super_call.rs | 3 ++ .../perry-codegen/src/expr/write_barrier.rs | 1 + crates/perry-codegen/src/lower_call/new.rs | 2 + .../src/lower_call/new_helpers.rs | 1 + .../stdlib_ffi/streams_events.rs | 5 ++ crates/perry-hir/src/lower/tests.rs | 1 + .../tests/issue_10745_passthrough_heritage.rs | 28 ++++++++++ crates/perry-hir/src/lower_decl/class_decl.rs | 17 ++++-- .../src/lower_decl/class_decl/from_ast.rs | 2 +- .../src/node_stream_constructors.rs | 9 ++-- .../src/node_stream_constructors/builders.rs | 18 +++++++ .../src/node_stream_keepalive.rs | 4 ++ .../src/node_stream_state_tests.rs | 33 ++++++++++++ .../src/object/global_this/fetch_globals.rs | 11 ++-- .../test_gap_10745_passthrough_subclass.ts | 54 +++++++++++++++++++ 17 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs create mode 100644 test-files/test_gap_10745_passthrough_subclass.ts diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 584ba953f8..a1553d9dab 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -927,6 +927,7 @@ pub(super) fn node_stream_parent_kind( "Readable" => return Some("readable"), "Duplex" => return Some("duplex"), "Transform" => return Some("transform"), + "PassThrough" => return Some("passthrough"), _ => {} } cur = classes diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 0e77806ec2..94665e9bbf 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -900,6 +900,7 @@ pub(super) fn compile_method( Some("Writable") => Some("js_node_stream_writable_subclass_init"), Some("Duplex") => Some("js_node_stream_duplex_subclass_init"), Some("Transform") => Some("js_node_stream_transform_subclass_init"), + Some("PassThrough") => Some("js_node_stream_passthrough_subclass_init"), _ => None, }; let mut effective_parent: Option<&str> = if builtin_parent_runtime.is_some() { @@ -936,7 +937,8 @@ pub(super) fn compile_method( // inline path for dynamic-parent classes. if let Some(pname) = effective_parent.filter(|_| dynamic_parent_owner.is_none()) { let pname_owned = pname.to_string(); - let node_stream_kind = if pname_owned == "Readable" { + let node_stream_kind = if matches!(pname_owned.as_str(), "Readable" | "PassThrough") + { node_stream_parent_kind(ctx.classes, class) } else { None @@ -958,6 +960,7 @@ pub(super) fn compile_method( "readable" => "js_node_stream_readable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!("node stream parent kind {}", kind), }; ctx.block().call( diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index f0d5af3d44..0c4d74d0d7 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -623,6 +623,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | "Writable" | "Duplex" | "Transform" + | "PassThrough" | "ReadableStream" | "WritableStream" | "TransformStream" @@ -785,6 +786,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Writable" => Some("writable"), "Duplex" => Some("duplex"), "Transform" => Some("transform"), + "PassThrough" => Some("passthrough"), _ => None, }; if let Some(kind) = node_stream_kind { @@ -851,6 +853,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Writable" => Some("writable"), "Duplex" => Some("duplex"), "Transform" => Some("transform"), + "PassThrough" => Some("passthrough"), _ => None, }; if let Some(kind) = node_stream_kind { diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index a1b6c65c82..2036188faf 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -1202,6 +1202,7 @@ pub(crate) fn lower_node_stream_super_init( "writable" => "js_node_stream_writable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!( "lower_node_stream_super_init: unexpected Node stream kind {}", kind diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ab8ed03fdf..2b641a2f1a 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1092,6 +1092,7 @@ fn lower_new_impl_inner<'a>( Some("Writable") => Some("js_node_stream_writable_subclass_init"), Some("Duplex") => Some("js_node_stream_duplex_subclass_init"), Some("Transform") => Some("js_node_stream_transform_subclass_init"), + Some("PassThrough") => Some("js_node_stream_passthrough_subclass_init"), _ => None, } } else { @@ -1390,6 +1391,7 @@ fn lower_new_impl_inner<'a>( "readable" => "js_node_stream_readable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!("node stream parent kind {}", kind), }; ctx.block().call( diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 3ed72efe24..043124c67a 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -620,6 +620,7 @@ pub(super) fn node_stream_parent_kind( "Readable" => return Some("readable"), "Duplex" => return Some("duplex"), "Transform" => return Some("transform"), + "PassThrough" => return Some("passthrough"), _ => {} } if ctx.imported_class_ctors.contains_key(name) { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index 5681b17671..da2ebbaec7 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -47,6 +47,11 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { &[DOUBLE, DOUBLE], ); module.declare_function("js_node_stream_passthrough_new", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_node_stream_passthrough_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE], + ); module.declare_function("js_node_stream_readable_from", DOUBLE, &[DOUBLE]); module.declare_function( "js_node_stream_readable_from_options", diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 7ad4e18935..75adeb6b7b 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1986,5 +1986,6 @@ mod subclass_ctor_inherited_method; mod ui_widget_add_child; mod issue_10623_require_destructured_native_super; +mod issue_10745_passthrough_heritage; mod hoisted_sibling_in_later_closure; diff --git a/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs b/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs new file mode 100644 index 0000000000..ebf11535ed --- /dev/null +++ b/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs @@ -0,0 +1,28 @@ +//! #10745: `PassThrough` is a classic `node:stream` native parent just like +//! `Transform`. Both class-lowering paths must retain that identity so codegen +//! can initialize the derived object in place and honor its `_transform`. + +#[test] +fn passthrough_import_alias_is_a_native_parent_for_decls_and_expressions() { + let source = r#" + import { PassThrough as PT } from "node:stream"; + class Decl extends PT { _transform(chunk, enc, cb) { cb(null, chunk); } } + const Expr = class extends PT { _transform(chunk, enc, cb) { cb(null, chunk); } }; + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + + for name in ["Decl", "Expr"] { + let class = hir + .classes + .iter() + .find(|class| class.name == name) + .unwrap_or_else(|| panic!("{name} is lowered")); + assert_eq!(class.extends_name.as_deref(), Some("PassThrough")); + assert_eq!( + class.native_extends, + Some(("node_stream".to_string(), "PassThrough".to_string())) + ); + assert!(class.extends_expr.is_none()); + } +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 56c34a7975..05de5e433c 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -12,7 +12,10 @@ use crate::lower_types::*; /// imports are registered under the local binding while preserving this export. fn canonical_native_parent_name<'a>(ctx: &'a LoweringContext, name: &str) -> Option<&'a str> { match ctx.lookup_native_module(name) { - Some(("stream", Some(class @ ("Readable" | "Writable" | "Duplex" | "Transform")))) + Some(( + "stream", + Some(class @ ("Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough")), + )) | Some(("events", Some(class @ ("EventEmitter" | "EventEmitterAsyncResource")))) | Some(("async_hooks", Some(class @ ("AsyncLocalStorage" | "AsyncResource")))) | Some(("ws", Some(class @ "WebSocketServer"))) @@ -30,10 +33,16 @@ fn canonical_native_parent_name<'a>(ctx: &'a LoweringContext, name: &str) -> Opt /// minified local binding such as `Readable as ut`. fn is_genuine_node_stream_parent(ctx: &LoweringContext, name: &str) -> bool { match ctx.lookup_native_module(name) { - Some(("stream", Some("Readable" | "Writable" | "Duplex" | "Transform"))) => true, + Some(( + "stream", + Some("Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough"), + )) => true, // Preserve the historical name-based treatment of a namespace/default // binding whose local name itself is a classic stream constructor. - Some(("stream", None)) => matches!(name, "Readable" | "Writable" | "Duplex" | "Transform"), + Some(("stream", None)) => matches!( + name, + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" + ), _ => false, } } @@ -236,7 +245,7 @@ pub fn lower_class_decl( // so a userland stream-shim binding (readable-stream's // `Transform`, winston) falls through to the dynamic // `extends_expr` parent path and runs its real constructor. - "Readable" | "Writable" | "Duplex" | "Transform" + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" if is_genuine_node_stream_parent(ctx, &parent_name) => { Some(("node_stream".to_string(), canonical_parent_name.clone())) diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs index cf9f617a4b..6b3c554583 100644 --- a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -117,7 +117,7 @@ pub(crate) fn lower_class_from_ast( // `is_genuine_node_stream_parent` so a userland stream-shim // binding (readable-stream's `Transform`) falls through to the // dynamic `extends_expr` parent path. - "Readable" | "Writable" | "Duplex" | "Transform" + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" if is_genuine_node_stream_parent(ctx, &parent_name) => { Some(("node_stream".to_string(), canonical_parent_name.clone())) diff --git a/crates/perry-runtime/src/node_stream_constructors.rs b/crates/perry-runtime/src/node_stream_constructors.rs index 1dae81e4f5..50bf98b33a 100644 --- a/crates/perry-runtime/src/node_stream_constructors.rs +++ b/crates/perry-runtime/src/node_stream_constructors.rs @@ -363,10 +363,11 @@ pub use builders::{ js_array_subclass_init, js_event_emitter_async_resource_subclass_init, js_event_emitter_subclass_init, js_node_stream_duplex_new, js_node_stream_duplex_subclass_init, js_node_stream_legacy_subclass_init, js_node_stream_passthrough_new, - js_node_stream_readable_from, js_node_stream_readable_from_options, - js_node_stream_readable_new, js_node_stream_readable_subclass_init, - js_node_stream_transform_new, js_node_stream_transform_subclass_init, - js_node_stream_writable_new, js_node_stream_writable_subclass_init, + js_node_stream_passthrough_subclass_init, js_node_stream_readable_from, + js_node_stream_readable_from_options, js_node_stream_readable_new, + js_node_stream_readable_subclass_init, js_node_stream_transform_new, + js_node_stream_transform_subclass_init, js_node_stream_writable_new, + js_node_stream_writable_subclass_init, }; pub use introspection::{ diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 0f9a5bff6a..5f2cbc7a08 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -566,6 +566,24 @@ pub extern "C" fn js_node_stream_passthrough_new(opts: f64) -> f64 { passthrough } +/// Initialize `class X extends PassThrough` without replacing the derived +/// instance. A subclass-provided `_transform` wins; otherwise retain +/// PassThrough's identity transform instead of falling into Transform's +/// missing-method error. +#[no_mangle] +pub extern "C" fn js_node_stream_passthrough_subclass_init(this: f64, opts: f64) -> f64 { + let passthrough = js_node_stream_transform_subclass_init(this, opts); + if transform_hidden_callback(passthrough).is_none() { + set_hidden_value( + passthrough, + hidden_transform_passthrough_key(), + f64::from_bits(TAG_TRUE), + ); + } + init_constructor(passthrough, "PassThrough"); + passthrough +} + /// `Readable.from(iterable)` — Node's static factory. Returns a /// Readable object and retains simple iterable chunks so /// `node:stream/consumers` can drain the current stub stream surface. diff --git a/crates/perry-runtime/src/node_stream_keepalive.rs b/crates/perry-runtime/src/node_stream_keepalive.rs index 9c1654d74f..b168bc121e 100644 --- a/crates/perry-runtime/src/node_stream_keepalive.rs +++ b/crates/perry-runtime/src/node_stream_keepalive.rs @@ -196,6 +196,10 @@ static KEEP_NS_TRANSFORM_NEW: extern "C" fn(f64) -> f64 = super::js_node_stream_ static KEEP_NS_PASSTHROUGH_NEW: extern "C" fn(f64) -> f64 = super::js_node_stream_passthrough_new; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] +static KEEP_NS_PASSTHROUGH_SUBCLASS_INIT: extern "C" fn(f64, f64) -> f64 = + super::js_node_stream_passthrough_subclass_init; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] static KEEP_NS_READABLE_FROM: extern "C" fn(f64) -> f64 = super::js_node_stream_readable_from; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] diff --git a/crates/perry-runtime/src/node_stream_state_tests.rs b/crates/perry-runtime/src/node_stream_state_tests.rs index 4cc4fb0547..1e61ec2241 100644 --- a/crates/perry-runtime/src/node_stream_state_tests.rs +++ b/crates/perry-runtime/src/node_stream_state_tests.rs @@ -116,6 +116,39 @@ fn stream_object_mode_flags_default_false_and_follow_options() { ); } +#[test] +fn passthrough_subclass_uses_override_or_identity_transform() { + crate::closure::js_register_closure_arity(super::tests::noop_listener as *const u8, 0); + let callback = + box_pointer(js_closure_alloc(super::tests::noop_listener as *const u8, 0) as *const u8); + + let overridden_obj = crate::object::js_object_alloc(0, 1); + js_object_set_field_by_name(overridden_obj, hidden_key(b"_transform"), callback); + let overridden = js_node_stream_passthrough_subclass_init( + box_pointer(overridden_obj as *const u8), + f64::from_bits(TAG_UNDEFINED), + ); + assert_eq!( + transform_hidden_callback(overridden).map(f64::to_bits), + Some(callback.to_bits()) + ); + assert!(!has_truthy_hidden( + overridden, + hidden_transform_passthrough_key() + )); + + let default_obj = crate::object::js_object_alloc(0, 0); + let default = js_node_stream_passthrough_subclass_init( + box_pointer(default_obj as *const u8), + f64::from_bits(TAG_UNDEFINED), + ); + assert!(transform_hidden_callback(default).is_none()); + assert!(has_truthy_hidden( + default, + hidden_transform_passthrough_key() + )); +} + #[test] fn stream_dynamic_instanceof_follows_node_stream_inheritance() { let readable = crate::object::bound_native_callable_export_value("stream", "Readable"); diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 745c2b8d87..7ae27ab868 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -790,14 +790,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // `lower_node_stream_super_init`), so every heritage shape installs the // override onto `this` identically. // - // `PassThrough` is deliberately NOT handled here: HIR never recognizes - // it as a node:stream native parent at all, even via a bare import - // (`canonical_native_parent_name` lists Readable/Writable/Duplex/ - // Transform but not PassThrough), so the hidden `_transform` field this - // shim reads is never pre-seeded for ANY `PassThrough` heritage shape — - // that's a separate, deeper HIR-level gap needing its own fix; adding an - // arm here alone was confirmed (empirically) to change nothing. - // // #10798: `Stream` (the legacy `node:stream` base that `Readable` and // friends themselves derive from) is a DIFFERENT shape than // `PassThrough`: it carries no hidden per-instance state at all — in @@ -840,6 +832,9 @@ pub unsafe extern "C" fn js_fetch_or_value_super( "Transform" => Some(crate::node_stream::js_node_stream_transform_subclass_init( this_box, opts, )), + "PassThrough" => Some( + crate::node_stream::js_node_stream_passthrough_subclass_init(this_box, opts), + ), "Stream" => Some(crate::node_stream::js_node_stream_legacy_subclass_init( this_box, )), diff --git a/test-files/test_gap_10745_passthrough_subclass.ts b/test-files/test_gap_10745_passthrough_subclass.ts new file mode 100644 index 0000000000..d4d645338b --- /dev/null +++ b/test-files/test_gap_10745_passthrough_subclass.ts @@ -0,0 +1,54 @@ +import { PassThrough, PassThrough as PT } from "node:stream"; + +class Direct extends PassThrough { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "direct:" + String(chunk).toUpperCase()); + } +} + +class ImportedAlias extends PT { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "import-alias:" + String(chunk).toUpperCase()); + } +} + +const LocalAlias = PassThrough; +const ClassExpression = class extends LocalAlias { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "class-expr:" + String(chunk).toUpperCase()); + } +}; + +class Middle extends PassThrough {} +class Indirect extends Middle { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "indirect:" + String(chunk).toUpperCase()); + } +} + +class DefaultPassThrough extends PassThrough {} + +function run(name: string, Constructor: any): Promise { + return new Promise((resolve) => { + const stream = new Constructor(); + let output = ""; + stream.on("data", (chunk: any) => (output += String(chunk))); + stream.on("error", (error: any) => { + console.log(name, "error", error.code || error.message); + resolve(); + }); + stream.on("end", () => { + console.log(name, JSON.stringify(output)); + resolve(); + }); + stream.end("ab"); + }); +} + +(async () => { + await run("direct", Direct); + await run("import-alias", ImportedAlias); + await run("class-expr", ClassExpression); + await run("indirect", Indirect); + await run("default", DefaultPassThrough); +})(); From ec4c8a05e021892891bf57c71eb429139dbc4411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:43:41 +0200 Subject: [PATCH 09/20] docs: add changelog for PR 11028 (cherry picked from commit 6cebdbfa2e5974758208226f7f26e5cb12d567f9) --- changelog.d/11028-passthrough-subclass.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11028-passthrough-subclass.md diff --git a/changelog.d/11028-passthrough-subclass.md b/changelog.d/11028-passthrough-subclass.md new file mode 100644 index 0000000000..1f39b18791 --- /dev/null +++ b/changelog.d/11028-passthrough-subclass.md @@ -0,0 +1,5 @@ +### Fixed + +- Honor `_transform` overrides on classes derived from `node:stream`'s + `PassThrough`, including aliased, dynamic, and indirect inheritance forms, + while preserving the default identity transform. From 1c62a0b62f4d51bfde7cf81e5f532dc1a1d99e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:07:09 +0200 Subject: [PATCH 10/20] fix(fetch): preserve shorthand Headers option (cherry picked from commit f0495231c9f119b2092f053b402683e1a0f49b63) --- .../perry-hir/src/lower/expr_call/globals.rs | 3 ++ .../tests/fetch_dynamic_headers_lowering.rs | 21 +++++++++++ .../test_gap_11024_fetch_shorthand_headers.ts | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 test-files/test_gap_11024_fetch_shorthand_headers.ts diff --git a/crates/perry-hir/src/lower/expr_call/globals.rs b/crates/perry-hir/src/lower/expr_call/globals.rs index 2551653daa..a05d4883d0 100644 --- a/crates/perry-hir/src/lower/expr_call/globals.rs +++ b/crates/perry-hir/src/lower/expr_call/globals.rs @@ -567,6 +567,9 @@ pub(super) fn try_global_builtins( match key.as_str() { "method" => method = value, "body" => body = value, + "headers" => { + headers_dynamic = Some(Box::new(value)) + } "signal" => signal = Some(Box::new(value)), _ => {} } diff --git a/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs b/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs index 4933a306c8..db2239be5f 100644 --- a/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs +++ b/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs @@ -80,6 +80,27 @@ fn variable_headers_are_captured_as_dynamic() { ); } +#[test] +fn shorthand_headers_are_captured_as_dynamic() { + let module = lower_src( + r#" + const headers = new Headers({ Authorization: "Bearer x" }); + fetch("http://x/", { method: "POST", headers, body: "b" }); + "#, + ) + .expect("fetch with shorthand headers should lower"); + + let (static_pairs, has_dynamic) = find_fetch(&module); + assert_eq!( + static_pairs, 0, + "a shorthand headers value has no static pairs" + ); + assert!( + has_dynamic, + "shorthand headers must be captured in headers_dynamic (#11024)" + ); +} + #[test] fn spread_literal_headers_are_captured_as_dynamic() { // `{ ...h }` is an object literal, but its spread prop cannot be enumerated diff --git a/test-files/test_gap_11024_fetch_shorthand_headers.ts b/test-files/test_gap_11024_fetch_shorthand_headers.ts new file mode 100644 index 0000000000..9438a6edfe --- /dev/null +++ b/test-files/test_gap_11024_fetch_shorthand_headers.ts @@ -0,0 +1,35 @@ +import http from "node:http"; + +const server = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(String(req.headers["x-keep"] || "")); +}); + +async function main(): Promise { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as any; + const base = "http://127.0.0.1:" + address.port; + const headers = new Headers(); + headers.set("x-keep", "yes"); + + const explicit = await fetch(base + "/explicit", { + method: "PUT", + body: "a", + headers: headers, + }); + console.log("explicit", await explicit.text()); + + const shorthand = await fetch(base + "/shorthand", { + method: "PUT", + body: "b", + headers, + }); + console.log("shorthand", await shorthand.text()); + + await new Promise((resolve) => server.close(() => resolve())); +} + +main(); From 0bf56e5835292c6817653ca09aced85f1b8007c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:08:03 +0200 Subject: [PATCH 11/20] docs: add changelog for PR 11031 (cherry picked from commit cf839655aa6fbb5f5f40217c2be68dfdf46d83c4) --- changelog.d/11031-fetch-shorthand-headers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11031-fetch-shorthand-headers.md diff --git a/changelog.d/11031-fetch-shorthand-headers.md b/changelog.d/11031-fetch-shorthand-headers.md new file mode 100644 index 0000000000..1a7f9b734e --- /dev/null +++ b/changelog.d/11031-fetch-shorthand-headers.md @@ -0,0 +1,5 @@ +### Fixed + +- Preserve `Headers` instances passed through the shorthand `fetch(url, { + headers })` option so their entries are sent like the explicit + `headers: headers` form. From e37c95046ee68149ccc143b4ad58cdcd2a9f2a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:55:11 +0200 Subject: [PATCH 12/20] fix(hir): a class receiver behind a Union still folds to Array.prototype (#10796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_user_class_instance` (local_array_methods.rs), `class_typed`, and the push-specific `is_user_class_receiver` (array_only_methods.rs) all decide whether recv.method(...) should fold to the dense Array fast path (Expr::ArrayFind/ArrayMap/ArrayPush/...) by matching Type::Named/ Type::Generic directly. A receiver typed as a Union containing a class (`Foo | undefined`, cheerio's `Cheerio | undefined`) fell through to `_ => false` in all three places and read as "not a class instance", so a method name shared with Array.prototype (find, map, filter, forEach, reduce, push, ...) folded to the array intrinsic and called the user's argument as a callback/misread the object header as an ArrayHeader. cheerio hit this on its single most common operation: `load.ts`'s `searchContext.find(search)`, where `searchContext: Cheerio | undefined` and `find` is cheerio's own CSS-selector method (mixed onto `Cheerio.prototype` at runtime) — not `Array.prototype.find`. Every `cheerio.load(html)("selector")` call threw `TypeError: string "..." is not a function`. Fix: each guard now recurses through Type::Union (including nested unions, which type_alias_resolve.rs's resolve_type_inner can produce) using the same per-variant test it already applied to a bare receiver. Real cheerio 1.2.0 now compiles and runs end-to-end, byte-identical to Node 26.5.1. A targeted sweep of the 130 existing gap tests touching array/class/collection dispatch shows no regressions (129 pass, 1 pre-existing node_fail unrelated to this change). (cherry picked from commit 7f8147c665bc949564b0c56b9342a7f5c62fb379) --- .../src/lower/expr_call/array_only_methods.rs | 152 ++++++++--- .../lower/expr_call/local_array_methods.rs | 250 ++++++++++++++---- ...p_10796_union_class_find_not_array_fold.ts | 35 +++ ...ion_generic_class_array_overlap_methods.ts | 65 +++++ 4 files changed, 424 insertions(+), 78 deletions(-) create mode 100644 test-files/test_gap_10796_union_class_find_not_array_fold.ts create mode 100644 test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 09e4ee8597..0c9aa57a76 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -28,6 +28,48 @@ fn unwrap_transparent_expr(expr: &ast::Expr) -> &ast::Expr { } } +/// #10796: is `ty` a `Named`/`Generic` (i.e. class-shaped, non-`Array`) type +/// — looking *through* `Union`, at any nesting depth, the way `class_typed` +/// below wants. Before this recursed, a receiver typed as a `Union` +/// containing a class (`Foo | undefined`, cheerio's `Cheerio | +/// undefined`) fell through `class_typed`'s plain `matches!(t, Type::Named(_) +/// | Type::Generic { .. })` and read as "not class-typed", so a method name +/// shared with `Array.prototype` (`find`, `map`, `filter`, …) folded to the +/// array fast path even for a genuine class instance. This is the same +/// defect as `local_array_methods.rs`'s `is_user_class_instance` (#10796) — +/// present here too because this file keeps its own, independent +/// class-vs-array classification rather than sharing that one. +fn is_named_or_generic_non_array(ty: &Type) -> bool { + match ty { + Type::Named(_) | Type::Generic { .. } => !matches!(ty, Type::Array(_)), + Type::Union(variants) => variants.iter().any(is_named_or_generic_non_array), + _ => false, + } +} + +/// #10796: does `ty` denote a receiver that may own its own `push` (a class +/// instance, an interface-typed value, or an object type literal) — looking +/// *through* `Union`, at any nesting depth, the way the `"push"` arm's +/// `is_user_class_receiver` below wants. Same defect and same fix shape as +/// `is_named_or_generic_non_array` just above: a `Foo | undefined` receiver +/// fell through the plain `match ty { Type::Named(_) => …, Type::Generic { +/// .. } => …, _ => false }` and read as "not class-typed", so `.push(x)` on +/// it folded to the array fast path (`js_array_push`), which reads the +/// class instance's `ObjectHeader` as an `ArrayHeader` and never runs the +/// user's `push` method. +fn is_push_owning_class_type(ty: &Type, ctx: &LoweringContext) -> bool { + match ty { + Type::Named(name) => ctx.lookup_class(name).is_some() || ctx.is_interface_type(name), + Type::Generic { base, .. } => { + let builtin = ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; + !builtin.contains(&base.as_str()) && ctx.lookup_class(base).is_some() + } + Type::Object(_) => true, // object type literal with push property + Type::Union(variants) => variants.iter().any(|v| is_push_owning_class_type(v, ctx)), + _ => false, + } +} + fn is_stream_class_ref(expr: &ast::Expr) -> bool { let expr = unwrap_transparent_expr(expr); let name = match expr { @@ -441,10 +483,7 @@ pub(super) fn try_array_only_methods( } let class_typed = ty .as_ref() - .map(|t| { - matches!(t, Type::Named(_) | Type::Generic { .. }) - && !matches!(t, Type::Array(_)) - }) + .map(|t| is_named_or_generic_non_array(t)) .unwrap_or(false); let unknown_recv = matches!(ty, None | Some(Type::Any) | Some(Type::Unknown)); @@ -1275,36 +1314,22 @@ pub(super) fn try_array_only_methods( // GUARD: Skip if the receiver is a user-defined class instance // (e.g. Stack.push()), or an object type literal (e.g. // { push: (v) => void, ... }), so its method dispatches correctly. + // A class instance OR an interface-typed value is the + // receiver's OWN object and may own a `push` method, so + // never fold to the array intrinsic. Interfaces aren't + // classes (`lookup_class` misses them), so the previous + // `lookup_class(name).is_some()` folded an interface + // receiver's `push` to the array fast path — reading the + // object header as an ArrayHeader and dropping the call + // (follow-up to #5139, which fixed only `any` receivers). + // `is_push_owning_class_type` also looks through `Union` + // (#10796), so `Foo | undefined` is caught the same way. let is_user_class_receiver = match member.obj.as_ref() { ast::Expr::This(_) => true, - ast::Expr::Ident(ident) => { - ctx.lookup_local_type(ident.sym.as_ref()) - .map(|ty| { - match ty { - // A class instance OR an interface-typed value is the - // receiver's OWN object and may own a `push` method, so - // never fold to the array intrinsic. Interfaces aren't - // classes (`lookup_class` misses them), so the previous - // `lookup_class(name).is_some()` folded an interface - // receiver's `push` to the array fast path — reading the - // object header as an ArrayHeader and dropping the call - // (follow-up to #5139, which fixed only `any` receivers). - Type::Named(name) => { - ctx.lookup_class(name).is_some() - || ctx.is_interface_type(name) - } - Type::Generic { base, .. } => { - let builtin = - ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; - !builtin.contains(&base.as_str()) - && ctx.lookup_class(base).is_some() - } - Type::Object(_) => true, // object type literal with push property - _ => false, - } - }) - .unwrap_or(false) - } + ast::Expr::Ident(ident) => ctx + .lookup_local_type(ident.sym.as_ref()) + .map(|ty| is_push_owning_class_type(ty, ctx)) + .unwrap_or(false), ast::Expr::New(_) => true, // new ClassName().push() _ => false, }; @@ -1381,3 +1406,66 @@ pub(super) fn try_array_only_methods( Ok(Err(args)) } + +#[cfg(test)] +mod tests { + use super::*; + + // #10796: both class-vs-array guards in this file must see a class + // *through* a `Union` — `Foo | undefined` is exactly as class-shaped as + // a bare `Foo` for the purpose of declining the array fast path. + + #[test] + fn named_or_generic_non_array_sees_through_union() { + assert!(is_named_or_generic_non_array(&Type::Named( + "Foo".to_string() + ))); + assert!(is_named_or_generic_non_array(&Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + })); + // `Foo | undefined` — before the fix this fell through to `false`. + assert!(is_named_or_generic_non_array(&Type::Union(vec![ + Type::Named("Foo".to_string()), + Type::Void, + ]))); + // Nested union: `type_alias_resolve.rs` can produce these. + assert!(is_named_or_generic_non_array(&Type::Union(vec![ + Type::Union(vec![Type::Named("Foo".to_string()), Type::Number]), + Type::Void, + ]))); + // Negative controls: real arrays and non-class unions stay `false`. + assert!(!is_named_or_generic_non_array(&Type::Array(Box::new( + Type::Number + )))); + assert!(!is_named_or_generic_non_array(&Type::Union(vec![ + Type::String, + Type::Number, + ]))); + } + + #[test] + fn push_owning_class_type_sees_through_union() { + let mut ctx = LoweringContext::new("array-only-methods-union-test.ts"); + let id = ctx.fresh_class(); + ctx.register_class("Foo".to_string(), id); + + assert!(is_push_owning_class_type( + &Type::Named("Foo".to_string()), + &ctx + )); + // `Foo | undefined` — before the fix this fell through to `false`, + // so `f.push(x)` on an optional-typed `Foo` folded to the array + // intrinsic instead of dispatching to `Foo`'s own `push`. + assert!(is_push_owning_class_type( + &Type::Union(vec![Type::Named("Foo".to_string()), Type::Void]), + &ctx + )); + // An unregistered name behind a union must still decline (`false`), + // same as a bare unregistered `Named` would. + assert!(!is_push_owning_class_type( + &Type::Union(vec![Type::Named("NotAClass".to_string()), Type::Void]), + &ctx + )); + } +} diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index 529c2e8a1d..a9c29ec867 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -57,6 +57,100 @@ fn receiver_is_non_array_builtin_wrapper(recv_ty: Option<&Type>) -> bool { ) } +/// #10796: is `ty` a statically-known user or imported class/interface +/// instance — the test `is_user_class_instance` (below, in +/// `try_local_array_methods`) applies, extended to look *through* +/// `Type::Union`. +/// +/// A receiver typed as a union that includes a class (`Foo | undefined`, +/// `Cheerio | undefined`, …) is exactly as class-shaped as a bare +/// `Foo`/`Cheerio` receiver: if ANY member is class-shaped, a +/// method call on it must still be able to reach that member's own method +/// rather than being folded to the array fast path. Before this existed, +/// the `Named`/`Generic` match arms had no `Union` arm, so `Union` fell to +/// `_ => false` — a receiver typed `Cheerio | undefined` (cheerio's +/// `searchContext` in `load.ts`) read as "not a user class instance", +/// `is_known_not_string` then read the union as array-ish, and +/// `searchContext.find(selector)` (a CSS-selector method mixed onto +/// `Cheerio.prototype` at runtime, sharing a name with `Array.prototype`) +/// folded to `Expr::ArrayFind`, which calls its argument as a *callback* — +/// `TypeError: string "..." is not a function`. +/// +/// Recurses into nested `Union`s too: `type_alias_resolve.rs`'s +/// `resolve_type_inner` can produce `Union([Union([...]), ...])` when one +/// union member is itself an alias to a union type (a resolved member is +/// pushed as-is, not flattened into the parent's variant list), so a single +/// `.any()` over the top-level variants is not enough — the `Union` arm +/// below calls back into this function for each variant, so nesting at any +/// depth is handled rather than assumed away. +fn type_is_class_instance( + ty: &Type, + ctx: &LoweringContext, + builtin_generic_bases: &[&str], +) -> bool { + // Imported classes don't show up in `lookup_class`; treat any + // uppercase imported identifier as a candidate class so the array + // fast-path doesn't swallow `coll.find(filter)` etc. + let is_imported_class_name = |n: &str| -> bool { + if let Some(c) = n.chars().next() { + if c.is_uppercase() && ctx.lookup_imported_func(n).is_some() { + return true; + } + } + false + }; + match ty { + // A class instance OR an interface-typed value is the receiver's + // own object — its method must be dispatched, not the array fast + // path. Interfaces aren't classes (so `lookup_class` misses them); + // without `is_interface_type`, an interface-typed receiver with + // e.g. an own `push` folded to `Expr::ArrayPush`, read the object + // header as an ArrayHeader, and silently dropped the call + // (follow-up to #5139, which fixed only `any`-typed receivers). + Type::Named(name) => { + ctx.lookup_class(name).is_some() + || ctx.is_interface_type(name) + || is_imported_class_name(name) + // A `function Q() {…}` used as a constructor (`new Q()`) + // types its instances `Named("Q")`, but it is not a class + // decl, so `lookup_class` misses it. Its methods live on + // `Q.prototype` (registered via + // `Expr::RegisterFunctionPrototypeMethod`), and when one of + // them shares an Array name — `Q.prototype.push`, the shape + // denque uses for mysql2's command queue — the array fast + // path folded `q.push(x)` to `Expr::ArrayPush`, read the + // instance's ObjectHeader as an ArrayHeader (silently + // corrupting it) and never ran the method. + || ctx.functions_index.contains_key(name.as_str()) + } + Type::Generic { base, .. } => { + !builtin_generic_bases.contains(&base.as_str()) + && (ctx.lookup_class(base).is_some() || is_imported_class_name(base)) + } + Type::Union(variants) => variants + .iter() + .any(|v| type_is_class_instance(v, ctx, builtin_generic_bases)), + _ => false, + } +} + +/// #10796: is `ty` a `Named`/`Generic` (i.e. class-shaped, non-`Array`) type +/// — looking *through* `Union`, at any nesting depth. A narrower, `ctx`-free +/// sibling of `type_is_class_instance` above: this one doesn't consult the +/// class registry, it just asks "does this look like a class rather than an +/// array", which is what the per-method-name match below (inside the array +/// block) wants as its own belt-and-suspenders check. Duplicated in +/// `array_only_methods.rs` as `is_named_or_generic_non_array` — both are +/// six lines and `ctx`-free, so a shared home would cost more in +/// cross-module plumbing than it saves. +fn is_named_or_generic_non_array(ty: &Type) -> bool { + match ty { + Type::Named(_) | Type::Generic { .. } => !matches!(ty, Type::Array(_)), + Type::Union(variants) => variants.iter().any(is_named_or_generic_non_array), + _ => false, + } +} + pub(super) fn try_local_array_methods( ctx: &mut LoweringContext, call: &ast::CallExpr, @@ -141,48 +235,20 @@ pub(super) fn try_local_array_methods( // to the class method, not runtime js_array_push. Map/Set/Promise are // handled by explicit checks within the array block below. let builtin_generic_bases = ["Map", "Set", "WeakMap", "WeakSet", "Promise"]; - // Imported classes don't show up in `lookup_class`; treat any - // uppercase imported identifier as a candidate class so the - // array fast-path doesn't swallow `coll.find(filter)` etc. - let is_imported_class_name = |n: &str| -> bool { - if let Some(c) = n.chars().next() { - if c.is_uppercase() && ctx.lookup_imported_func(n).is_some() { - return true; - } - } - false - }; - let is_user_class_instance = match type_info { - // A class instance OR an interface-typed value is the - // receiver's own object — its method must be dispatched, not - // the array fast path. Interfaces aren't classes (so - // `lookup_class` misses them); without `is_interface_type`, - // an interface-typed receiver with e.g. an own `push` folded - // to `Expr::ArrayPush`, read the object header as an - // ArrayHeader, and silently dropped the call (follow-up to - // #5139, which fixed only `any`-typed receivers). - Some(Type::Named(name)) => { - ctx.lookup_class(name).is_some() - || ctx.is_interface_type(name) - || is_imported_class_name(name) - // A `function Q() {…}` used as a constructor (`new Q()`) - // types its instances `Named("Q")`, but it is not a class - // decl, so `lookup_class` misses it. Its methods live on - // `Q.prototype` (registered via - // `Expr::RegisterFunctionPrototypeMethod`), and when one of - // them shares an Array name — `Q.prototype.push`, the shape - // denque uses for mysql2's command queue — the array fast - // path folded `q.push(x)` to `Expr::ArrayPush`, read the - // instance's ObjectHeader as an ArrayHeader (silently - // corrupting it) and never ran the method. - || ctx.functions_index.contains_key(name.as_str()) - } - Some(Type::Generic { base, .. }) => { - !builtin_generic_bases.contains(&base.as_str()) - && (ctx.lookup_class(base).is_some() || is_imported_class_name(base)) - } - _ => false, - }; + // #10796: `type_is_class_instance` carries the `Named`/ + // `Generic` checks (plus a `Union` arm, recursed so nested + // unions are covered too — see its doc comment) that used to + // live inline here as a `match type_info { ... _ => false }`. + // A bare `match` on `type_info: Option<&Type>` only ever saw + // `Named`/`Generic` directly; a receiver typed as a `Union` + // containing a class (`Foo | undefined`, cheerio's + // `Cheerio | undefined`) fell to `_ => false` and + // was treated as "not a class instance", letting a method + // name shared with `Array.prototype` (`find`, `map`, …) fold + // to the array fast path on a real class instance. + let is_user_class_instance = type_info + .map(|ty| type_is_class_instance(ty, ctx, &builtin_generic_bases)) + .unwrap_or(false); // When the receiver type is Any and the method name is one // commonly defined on user classes too (e.g. mongo's // `Collection.find(filter)`), skip the array fast-path so the @@ -606,10 +672,7 @@ pub(super) fn try_local_array_methods( let is_class_instance = !is_typed_array && recv_ty .as_ref() - .map(|ty| { - matches!(ty, Type::Named(_) | Type::Generic { .. }) - && !matches!(ty, Type::Array(_)) - }) + .map(|ty| is_named_or_generic_non_array(ty)) .unwrap_or(false); // Issue #514: gate `.at()` ArrayAt // emission on a statically-known @@ -1186,4 +1249,99 @@ mod tests { named("NumberLike").as_ref() )); } + + // #10796: `type_is_class_instance` must see a class *through* a `Union`, + // at any nesting depth — the guard this backs (`is_user_class_instance` + // in `try_local_array_methods`) is what stops a class's own + // `find`/`map`/`filter`/… method from folding to the `Array.prototype` + // fast path. A `LoweringContext` with a registered class stands in for + // a real module lowering; `builtin_generic_bases` mirrors the literal + // used at the real call site. + fn test_ctx_with_class(name: &str) -> LoweringContext { + let mut ctx = LoweringContext::new("union-class-instance-test.ts"); + let id = ctx.fresh_class(); + ctx.register_class(name.to_string(), id); + ctx + } + + const NO_BUILTIN_GENERIC_BASES: &[&str] = &["Map", "Set", "WeakMap", "WeakSet", "Promise"]; + + #[test] + fn bare_named_class_is_class_instance() { + let ctx = test_ctx_with_class("Foo"); + assert!(type_is_class_instance( + &Type::Named("Foo".to_string()), + &ctx, + NO_BUILTIN_GENERIC_BASES, + )); + } + + #[test] + fn bare_generic_class_is_class_instance() { + // The real-world trigger: `Cheerio` — a generic instance of + // an imported/registered class. + let ctx = test_ctx_with_class("Cheerio"); + assert!(type_is_class_instance( + &Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + }, + &ctx, + NO_BUILTIN_GENERIC_BASES, + )); + } + + #[test] + fn union_with_named_class_member_is_class_instance() { + // `Foo | undefined` — e.g. `function make(): Foo | undefined`. + // Before #10796's fix, `Type::Union` fell through the match's + // `_ => false` arm and this returned `false`. + let ctx = test_ctx_with_class("Foo"); + let ty = Type::Union(vec![Type::Named("Foo".to_string()), Type::Void]); + assert!(type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } + + #[test] + fn union_with_generic_class_member_is_class_instance() { + // cheerio's real shape: `searchContext: Cheerio | undefined` + // in `load.ts`, whose `.find(selector)` call is a CSS-selector + // method mixed onto `Cheerio.prototype` at runtime — not + // `Array.prototype.find`. A `Named`-only fix would miss this arm + // and leave cheerio broken. + let ctx = test_ctx_with_class("Cheerio"); + let ty = Type::Union(vec![ + Type::Generic { + base: "Cheerio".to_string(), + type_args: vec![Type::Named("AnyNode".to_string())], + }, + Type::Void, + ]); + assert!(type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } + + #[test] + fn nested_union_with_class_member_is_class_instance() { + // `type_alias_resolve.rs`'s `resolve_type_inner` can push a resolved + // union member as-is (not flattened) when that member is itself an + // alias to a union type, producing `Union([Union([...]), ...])`. The + // `Union` arm must recurse, not just `.any()` one level deep. + let ctx = test_ctx_with_class("Foo"); + let inner = Type::Union(vec![Type::Named("Foo".to_string()), Type::Number]); + let outer = Type::Union(vec![inner, Type::Void]); + assert!(type_is_class_instance( + &outer, + &ctx, + NO_BUILTIN_GENERIC_BASES + )); + } + + #[test] + fn union_without_a_class_member_is_not_a_class_instance() { + // Negative control: a union of genuinely non-class types must stay + // `false`, so e.g. `string | number` doesn't spuriously skip the + // array fast path. + let ctx = test_ctx_with_class("Foo"); + let ty = Type::Union(vec![Type::String, Type::Number]); + assert!(!type_is_class_instance(&ty, &ctx, NO_BUILTIN_GENERIC_BASES)); + } } diff --git a/test-files/test_gap_10796_union_class_find_not_array_fold.ts b/test-files/test_gap_10796_union_class_find_not_array_fold.ts new file mode 100644 index 0000000000..5c9af1958f --- /dev/null +++ b/test-files/test_gap_10796_union_class_find_not_array_fold.ts @@ -0,0 +1,35 @@ +// #10796: a method call on a receiver typed as a `Union` containing a class +// (e.g. `Foo | undefined`) must dispatch to the class's own method when the +// method name collides with an `Array.prototype` name — not fold to the +// array fast path. +// +// Root cause: `crates/perry-hir/src/lower/expr_call/local_array_methods.rs`'s +// `is_user_class_instance` guard (the thing that stops a user class's own +// `find`/`map`/`filter`/… method from being rewritten to `Expr::ArrayFind` +// et al., since the runtime dispatch of the array fast path calls its +// argument as a *callback*) only matched `Type::Named`/`Type::Generic` +// directly. A receiver whose static type is `Type::Union([Type::Named(...), +// Type::Void])` fell through the match's `_ => false` arm, so the union type +// read as "not a user class instance", and `f.find(x)` below folded to +// `Expr::ArrayFind`, which called the string argument `x` as a per-element +// predicate — `TypeError: string "ul#fruits" is not a function`. +// +// This is exactly the shape cheerio's `load.ts` hits: `searchContext: +// Cheerio | undefined`, whose `.find(selector)` is a CSS-selector +// method mixed onto `Cheerio.prototype` at runtime (`Object.assign( +// Cheerio.prototype, ..., Traversing, ...)`), not `Array.prototype.find`. +class Foo { + find(x: string): string { + return "custom-find:" + x; + } +} + +function make(flag: boolean): Foo | undefined { + return flag ? new Foo() : undefined; +} + +const f: Foo | undefined = make(true); +if (!f) { + throw new Error("unreachable"); +} +console.log(f.find("ul#fruits")); diff --git a/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts b/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts new file mode 100644 index 0000000000..61b2d9d0fe --- /dev/null +++ b/test-files/test_gap_10796_union_generic_class_array_overlap_methods.ts @@ -0,0 +1,65 @@ +// #10796: the same guard covers the whole "shares a name with +// Array.prototype" method set — find, findIndex, findLast, findLastIndex, +// map, filter, some, every, forEach, reduce, reduceRight, join, plus the +// mutators push/pop/shift/unshift — not just `find`. This fixture exercises +// a representative few of them (find, map, filter, forEach, reduce, push) +// on ONE receiver so a fix verified only on `find` can't pass here while +// leaving the others silently folding to the array fast path. +// +// It also pins the real-world trigger: the receiver's static type is a +// `Union` containing a *generic* class instance (`Box | undefined`), +// matching cheerio's `searchContext: Cheerio | undefined` in +// `load.ts` — a `Type::Named`-only fix would pass a simpler +// `Foo | undefined` test while leaving `Cheerio | undefined` +// (and so cheerio itself) still misrouting through `Type::Generic` inside +// the union. +// +// `Box` is shaped like cheerio's `Cheerio` on purpose (`length` + +// a numeric index signature — "array-like" is exactly the shape that makes +// the array fast path plausible in the first place). +class Box { + length = 0; + [index: number]: T; + label: string; + + constructor(label: string) { + this.label = label; + } + + find(selector: string): string { + return `${this.label}.find(${selector})`; + } + map(selector: string): string { + return `${this.label}.map(${selector})`; + } + filter(selector: string): string { + return `${this.label}.filter(${selector})`; + } + forEach(selector: string): string { + return `${this.label}.forEach(${selector})`; + } + reduce(selector: string): string { + return `${this.label}.reduce(${selector})`; + } + push(selector: string): string { + return `${this.label}.push(${selector})`; + } +} + +function make(flag: boolean): Box | undefined { + return flag ? new Box("box") : undefined; +} + +const b: Box | undefined = make(true); +if (!b) { + throw new Error("unreachable"); +} + +const results: string[] = []; +results.push(b.find("a")); +results.push(b.map("b")); +results.push(b.filter("c")); +results.push(b.forEach("d")); +results.push(b.reduce("e")); +results.push(b.push("f")); +console.log(results.join(" | ")); From 395524a9cf2c3a5676ae9c6bc0cb5317b6b027df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:55:50 +0200 Subject: [PATCH 13/20] changelog: add fragment for #11035 (#10796) (cherry picked from commit 418a9e9ca15f07f3c1620a5b186fd305b82e36ef) --- .../11035-union-class-array-method-guard.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changelog.d/11035-union-class-array-method-guard.md diff --git a/changelog.d/11035-union-class-array-method-guard.md b/changelog.d/11035-union-class-array-method-guard.md new file mode 100644 index 0000000000..55d7cb6d68 --- /dev/null +++ b/changelog.d/11035-union-class-array-method-guard.md @@ -0,0 +1,24 @@ +Fixed a miscompile where a class receiver typed as a `Union` (`Foo | +undefined`, or a generic like cheerio's `Cheerio | undefined`) +had a method call folded to the dense `Array.prototype` fast path whenever +the method name collided with a real Array method (`find`, `map`, +`filter`, `some`, `every`, `forEach`, `reduce`, `reduceRight`, `join`, +`findIndex`, `findLast`, `findLastIndex`, `push`). Three separate guards +in `crates/perry-hir/src/lower/expr_call/{local_array_methods,array_only_methods}.rs` +matched `Type::Named`/`Type::Generic` directly but had no `Type::Union` +arm, so a union-typed class receiver read as "not a class instance" and +the fold went ahead — calling the user's argument as an `Array.prototype` +callback, or reading the class instance's `ObjectHeader` as an +`ArrayHeader`. + +cheerio (`cheerio.load(html)("selector")`) hit this on its most basic +operation: `load.ts`'s `searchContext.find(search)`, where `searchContext: +Cheerio | undefined` and `find` is cheerio's own CSS-selector +method, mixed onto `Cheerio.prototype` at runtime — every call threw +`TypeError: string "..." is not a function`. `cheerio@1.2.0` now compiles +and runs end-to-end, byte-identical to Node. + +All three guards now recurse through `Type::Union` (nested unions +included) using the same per-variant test they already applied to a bare +receiver, matching the idiom the surrounding code already used in six +other places in `local_array_methods.rs`. #10796 From d98373da48d8fe305bb431fe5700d30309e2aba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 05:10:23 +0200 Subject: [PATCH 14/20] fix(http): give websocket upgrades to JS listeners (cherry picked from commit d106cdb8d82d4023457228cd9b12423f8a67de92) --- .../perry-ext-http/src/server/raw_upgrade.rs | 65 ++++++++++++++----- crates/perry-ext-http/src/server/server.rs | 10 +-- crates/perry-ext-http/src/server/upgrade.rs | 49 ++++++++++---- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/crates/perry-ext-http/src/server/raw_upgrade.rs b/crates/perry-ext-http/src/server/raw_upgrade.rs index b1f4c34ee0..049d08fe88 100644 --- a/crates/perry-ext-http/src/server/raw_upgrade.rs +++ b/crates/perry-ext-http/src/server/raw_upgrade.rs @@ -10,22 +10,24 @@ //! the listener's handwritten 101 would both reach the client, and the //! unconsumed body bytes (`head`) were lost. //! -//! This module adds the Node-exact path for *keyless* Upgrade requests -//! (no `Sec-WebSocket-Key` — i.e. not a real WebSocket client handshake): +//! This module adds the Node-exact path for Upgrade requests claimed by an +//! `'upgrade'` listener: //! //! 1. When the server has `'upgrade'` listeners, the accept task peeks the //! request head off the TCP stream *before* handing anything to hyper. -//! 2. If the head carries `Connection: …upgrade…` + an `Upgrade:` header and -//! no `Sec-WebSocket-Key`, the stream is handed to perry-ext-net +//! 2. If the head carries `Connection: …upgrade…` + an `Upgrade:` header, the +//! stream is handed to perry-ext-net //! (`adopt_upgraded_tcp_stream`) so JS sees a standard `net.Socket` //! surface, and the `'upgrade'` listeners fire with the unconsumed bytes //! after the head as `head`. -//! 3. Anything else (no Upgrade header, real WS handshakes, oversized or -//! truncated heads) is replayed to hyper byte-for-byte through -//! `PrefixedStream`, preserving today's behavior. +//! 3. Anything else (no Upgrade header, oversized or truncated heads) is +//! replayed to hyper byte-for-byte through `PrefixedStream`, preserving +//! today's behavior. //! -//! Real WebSocket handshakes (key present) deliberately keep the -//! tungstenite path so `new WebSocketServer({ server })` keeps working. +//! Native attached WebSocket servers have no JS `'upgrade'` listener and keep +//! the internal WebSocket path. A listener, including the one installed by +//! the public `ws` package, owns the handshake and must receive the untouched +//! socket even when `Sec-WebSocket-Key` is present. use std::collections::HashMap; use std::net::SocketAddr; @@ -116,6 +118,18 @@ fn find_head_end(buf: &[u8]) -> Option { buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) } +fn is_upgrade_head(headers: &HashMap) -> bool { + let connection_upgrade = headers + .get("connection") + .map(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + }) + .unwrap_or(false); + connection_upgrade && headers.contains_key("upgrade") +} + /// Peek the request head and dispatch a raw `'upgrade'` if it qualifies. /// Only called when the server has `'upgrade'` listeners. pub(crate) async fn peek_and_maybe_dispatch_raw_upgrade( @@ -147,13 +161,7 @@ pub(crate) async fn peek_and_maybe_dispatch_raw_upgrade( return PeekResult::Passthrough(PrefixedStream::new(buf, stream)); }; - let connection_upgrade = headers_lower - .get("connection") - .map(|v| v.to_ascii_lowercase().contains("upgrade")) - .unwrap_or(false); - let has_upgrade = headers_lower.contains_key("upgrade"); - let has_ws_key = headers_lower.contains_key("sec-websocket-key"); - if !connection_upgrade || !has_upgrade || has_ws_key { + if !is_upgrade_head(&headers_lower) { return PeekResult::Passthrough(PrefixedStream::new(buf, stream)); } @@ -229,3 +237,28 @@ fn parse_head( } Some((method, url, headers_lower, raw_headers)) } + +#[cfg(test)] +mod tests { + use super::{is_upgrade_head, parse_head}; + + fn parsed_headers(head: &[u8]) -> std::collections::HashMap { + parse_head(head).expect("valid request head").2 + } + + #[test] + fn websocket_handshake_belongs_to_the_upgrade_listener() { + let headers = parsed_headers( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive, Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + + assert!(is_upgrade_head(&headers)); + } + + #[test] + fn ordinary_request_stays_on_the_http_path() { + let headers = parsed_headers(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + assert!(!is_upgrade_head(&headers)); + } +} diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 84c036b210..acc9cdd38b 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -664,8 +664,8 @@ fn serve_http_connection( } tokio::spawn(async move { // #4973 — when `'upgrade'` listeners exist, peek the - // request head before hyper writes anything: a keyless - // Upgrade request must reach JS as a raw net.Socket + // request head before hyper writes anything: an Upgrade + // request claimed by JS must reach it as a raw net.Socket // with NO response on the wire (Node semantics). Other // connections replay the peeked bytes to hyper. let has_upgrade_listeners = get_handle::(server_handle) @@ -1278,9 +1278,9 @@ async fn handle_request( // `'request'` when the server has no `'upgrade'` listeners — the // unconditional branch used to hijack it into a bogus 101; (b) only a // real WebSocket handshake (`Sec-WebSocket-Key` present) belongs on the - // tungstenite path — keyless Upgrade requests are served Node-style by - // the raw peek path in raw_upgrade.rs and only reach hyper when no - // listener was attached at accept time. + // native path only for an attached native WebSocket server. JS `'upgrade'` + // listeners own their handshake and are served Node-style by the raw peek + // path in raw_upgrade.rs. if crate::server::upgrade::is_websocket_upgrade(&req) { let has_upgrade_listeners = get_handle::(server_handle) .map(|server| server_has_event_listener(server, "upgrade")) diff --git a/crates/perry-ext-http/src/server/upgrade.rs b/crates/perry-ext-http/src/server/upgrade.rs index 4c32e11059..c8b4c49dd0 100644 --- a/crates/perry-ext-http/src/server/upgrade.rs +++ b/crates/perry-ext-http/src/server/upgrade.rs @@ -17,20 +17,18 @@ //! standard `ws_id` that the rest of perry-ext-ws's surface //! consumes. //! 4. The `'upgrade'` listeners on the HTTP server are fired with -//! `(im_f64, ws_id_f64, head_str_f64)`. `ws_id_f64` is the same +//! `(im_f64, ws_id_f64, head_buffer_f64)`. `ws_id_f64` is the same //! integer id as standalone `WebSocketServer({port})` connections, //! so user code can interact with it through `ws.on('message',…)`, //! `ws.send(…)`, `ws.close(…)` unchanged. //! //! Attached WebSocket servers are native observers registered by perry-ext-ws. -use perry_ffi::{alloc_string, get_handle_mut, JsClosure, RawClosureHeader}; +use perry_ffi::{get_handle_mut, JsClosure, RawClosureHeader}; use crate::server::request::handle_to_pointer_f64; use crate::server::server::HttpServer; -use crate::server::types::{ - js_promise_run_microtasks, POINTER_TAG, PTR_MASK, STRING_TAG, TAG_UNDEFINED, -}; +use crate::server::types::{js_promise_run_microtasks, POINTER_TAG, PTR_MASK}; /// Test whether a request looks like a WebSocket upgrade — checks /// `Connection: Upgrade` (case-insensitive contains) and @@ -51,6 +49,11 @@ pub(crate) fn is_websocket_upgrade(req: &hyper::Request) connection_ok && upgrade_ok } +fn upgrade_head_arg(head_data: &[u8]) -> f64 { + let head = perry_ffi::alloc_buffer(head_data); + f64::from_bits(POINTER_TAG | (head as u64 & PTR_MASK)) +} + /// Fire the `'upgrade'` event listeners with `(im, wsId, head)`. /// Called from the main-thread event loop after the upgrade pending /// has been dispatched. @@ -79,14 +82,10 @@ pub(crate) fn fire_upgrade_listeners( // (1.0_f64) would have bits 0x3FF0_…, which `unbox_to_i64` // AND-masks to 0, missing the WS_CONNECTIONS lookup entirely. let ws_id_f64 = f64::from_bits(POINTER_TAG | (ws_id as u64 & PTR_MASK)); - let head_str = if head_data.is_empty() { - f64::from_bits(TAG_UNDEFINED) - } else { - let s = String::from_utf8_lossy(&head_data).into_owned(); - let header = alloc_string(&s); - f64::from_bits(STRING_TAG | (header.as_raw() as u64 & PTR_MASK)) - }; - let head_str = scope.root_nanbox(head_str); + // Node always supplies a Buffer, including for a zero-length head. Public + // `ws` reads `head.length` before deciding whether to call `unshift`, and + // upgrade bytes are arbitrary protocol data rather than UTF-8 text. + let head_arg = scope.root_nanbox(upgrade_head_arg(&head_data)); for cb in listeners { if cb.get() == 0 { @@ -96,7 +95,7 @@ pub(crate) fn fire_upgrade_listeners( let raw = cb.get() as *const RawClosureHeader; let closure = JsClosure::from_raw(raw); if !closure.is_null() { - let _ = closure.call3(req_f64, ws_id_f64, head_str.get()); + let _ = closure.call3(req_f64, ws_id_f64, head_arg.get()); } js_promise_run_microtasks(); } @@ -112,6 +111,28 @@ fn _force_link() -> u64 { POINTER_TAG | (PTR_MASK & 0) } +#[cfg(test)] +mod tests { + use super::{upgrade_head_arg, POINTER_TAG, PTR_MASK}; + + fn head_bytes(data: &[u8]) -> &'static [u8] { + let arg = upgrade_head_arg(data); + assert_eq!(arg.to_bits() & !PTR_MASK, POINTER_TAG); + let ptr = (arg.to_bits() & PTR_MASK) as *const perry_ffi::BufferHeader; + perry_ffi::read_buffer_bytes(ptr).expect("upgrade head buffer") + } + + #[test] + fn empty_upgrade_head_is_an_empty_buffer() { + assert_eq!(head_bytes(&[]), &[] as &[u8]); + } + + #[test] + fn upgrade_head_preserves_binary_bytes() { + assert_eq!(head_bytes(&[0xff, 0x00, 0x80]), &[0xff, 0x00, 0x80]); + } +} + /// Read owned address metadata without allocating JS objects or introducing a /// reverse dependency from ws to HTTP. pub(crate) fn attached_address(handle: i64) -> Option<(String, u16)> { From 7cc19de43ba2d0a1b279bb24a55546a35e907ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 06:02:43 +0200 Subject: [PATCH 15/20] docs(changelog): note websocket upgrade listener fix (cherry picked from commit 9733ef00d56175916eeb715c2d63c12204ef2d49) --- changelog.d/11084-http-websocket-upgrade-listener.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11084-http-websocket-upgrade-listener.md diff --git a/changelog.d/11084-http-websocket-upgrade-listener.md b/changelog.d/11084-http-websocket-upgrade-listener.md new file mode 100644 index 0000000000..c51d8aa203 --- /dev/null +++ b/changelog.d/11084-http-websocket-upgrade-listener.md @@ -0,0 +1 @@ +Fixed HTTP WebSocket upgrades claimed by JavaScript `upgrade` listeners. Perry now hands public packages such as `ws` the untouched `net.Socket` and a binary `Buffer` for the upgrade head, including when that head is empty, so `WebSocketServer` can complete its own handshake and emit `connection`. From 4f137d39ba686cee65a1b5c2b257c28c5a8f6410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 05:29:44 +0200 Subject: [PATCH 16/20] fix(tooling): run_lint_gates refuses to run since the cargo-xwin step landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c038156e98` added a `lint` step that installs cargo-xwin from a pinned, sha256-verified release asset. Its nine `run` lines are `asset=`, `url=`, `curl`, `sha256sum --check`, `mkdir`, `tar` and an append to `$GITHUB_PATH` — none of which `is_gate_command` recognises, correctly, because none of them asserts anything. The step therefore yielded zero commands and the extractor failed the whole run: run_lint_gates: extraction error: step 'Install cargo-xwin for Windows type-check' has a run: block but yielded zero commands That takes out the entire local lint replay, not one gate, and `SKIP_COMPILE_GATES=1` does not help because the step is inside `lint` itself. The script is what every agent and reviewer is told to run before pushing, so while it is broken people either skip it or hand-pick gates — the exact failure it was written to prevent. A setup step is a real category: it installs a tool the later gates use and asserts nothing, so there is nothing to replay locally. It is now declared in a `setup_only` registry next to `ci_only`, and exempted only while it stays a setup step. Both failure directions are enforced and self-tested: a renamed or deleted step makes its entry stale and fails, and a setup step that grows a real gate command fails with "remove its setup_only entry so the gate is replayed locally" rather than silently hiding that gate. Verified on a clean checkout of main: before, `--list` errors; after, it extracts 90 lint commands from 54 run steps plus 6 compile commands, and `--self-test` passes including the two new fixtures. (cherry picked from commit 06651ea08e1bb671c7072fe62a8ede57ee7ac93b) --- .../11080-run-lint-gates-setup-steps.md | 3 + scripts/run_lint_gates.sh | 61 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 changelog.d/11080-run-lint-gates-setup-steps.md diff --git a/changelog.d/11080-run-lint-gates-setup-steps.md b/changelog.d/11080-run-lint-gates-setup-steps.md new file mode 100644 index 0000000000..cfce86b321 --- /dev/null +++ b/changelog.d/11080-run-lint-gates-setup-steps.md @@ -0,0 +1,3 @@ +### Fixed + +- `scripts/run_lint_gates.sh` runs again. `c038156e98` added a `lint` step that installs cargo-xwin from a verified release asset; its nine `run` lines are all downloads and PATH edits, so the extractor yielded zero commands for the step and refused to run at all — taking the whole local lint replay with it, for every gate, not just that one. Setup-only steps are now named in a `setup_only` registry alongside the existing `ci_only` one, and the exemption is checked in both directions: a renamed or removed step fails, and a setup step that later grows a real gate command fails rather than hiding it. Both directions are covered by `--self-test`. (#11080) diff --git a/scripts/run_lint_gates.sh b/scripts/run_lint_gates.sh index cda802593f..79bb861a1c 100755 --- a/scripts/run_lint_gates.sh +++ b/scripts/run_lint_gates.sh @@ -71,6 +71,26 @@ if [[ "${1:-}" == "--self-test" ]]; then exit 1 fi + # A setup-only step must stay EXEMPT-AND-CHECKED, not become a hiding place. + if _self_renamed="$(RUN_LINT_GATES_FIXTURE=setup-only-renamed bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: a stale setup_only entry exited zero" >&2 + exit 1 + fi + if [[ "$_self_renamed" != *"yielded zero commands"* ]]; then + echo "run_lint_gates self-test FAILED: renamed setup step did not report an empty step" >&2 + printf '%s\n' "$_self_renamed" >&2 + exit 1 + fi + if _self_grew="$(RUN_LINT_GATES_FIXTURE=setup-only-grew-a-gate bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: a setup_only step that grew a gate exited zero" >&2 + exit 1 + fi + if [[ "$_self_grew" != *"now yields gate command(s)"* ]]; then + echo "run_lint_gates self-test FAILED: grown setup step did not report a hidden gate" >&2 + printf '%s\n' "$_self_grew" >&2 + exit 1 + fi + if ! _self_compile="$(bash "$0" --list 2>&1)"; then echo "run_lint_gates self-test FAILED: real workflow extraction failed" >&2 printf '%s\n' "$_self_compile" >&2 @@ -155,6 +175,16 @@ elif fixture == "warnings-extra-command": if step.get("name") == "rustc warnings (host-compatible, all targets)": step["run"] += "\ncargo check -p perry-runtime --lib\n" break +elif fixture == "setup-only-renamed": + for step in workflow["jobs"]["lint"]["steps"]: + if step.get("name") == "Install cargo-xwin for Windows type-check": + step["name"] = "Install cargo-xwin (renamed)" + break +elif fixture == "setup-only-grew-a-gate": + for step in workflow["jobs"]["lint"]["steps"]: + if step.get("name") == "Install cargo-xwin for Windows type-check": + step["run"] += "\npython3 scripts/check_file_size.sh\n" + break elif fixture: sys.stderr.write(f"run_lint_gates: unknown self-test fixture: {fixture}\n") sys.exit(3) @@ -176,6 +206,20 @@ ci_only = { "reason": "needs the CI plan's shard count", }, } +# Steps that legitimately contain NO gate command: they install or fetch a +# tool the later gates use. They assert nothing, so there is nothing to replay +# locally, but they still have a `run:` block and would otherwise be read as an +# extraction failure. Named explicitly, with the same discipline as `ci_only`: +# a new setup step cannot silently become a third entry, and an entry that +# stops matching (step renamed, or it grows a real gate command) FAILS, so this +# list cannot rot into a way of hiding a gate. +setup_only = { + "Install cargo-xwin for Windows type-check": ( + "downloads a pinned, sha256-verified release asset and extends PATH; " + "installs the tool the Windows type-check gate then runs" + ), +} +matched_setup = set() matched_skips = set() records = [] errors = [] @@ -247,13 +291,28 @@ for index, step in enumerate(steps, start=1): step_records.append(("run", step_name, line, "")) if not step_records: - errors.append(f"step '{step_name}' has a run: block but yielded zero commands") + if step_name in setup_only: + matched_setup.add(step_name) + else: + errors.append(f"step '{step_name}' has a run: block but yielded zero commands") + elif step_name in setup_only: + # It grew a real command: the entry is now hiding a gate. + errors.append( + f"setup-only step '{step_name}' now yields gate command(s); " + "remove its setup_only entry so the gate is replayed locally" + ) records.extend(step_records) if not fixture: for key, rule in ci_only.items(): if key not in matched_skips: errors.append(f"explicit CI-only skip '{rule['step']}' no longer matches the workflow") + for step_name in setup_only: + if step_name not in matched_setup: + errors.append( + f"setup-only step '{step_name}' no longer matches the workflow; " + "delete its setup_only entry" + ) compile_records = [] for job_name in ("warnings", "check"): From 5f07f11fa4d8880525eef2f1eed28aebf8a78b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 05:36:07 +0200 Subject: [PATCH 17/20] fix(tooling): the string-payload ratchet scanned nothing under a dot-directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collect_inventory` filtered on `path.parts` — the ABSOLUTE path — so any checkout living under a dot-prefixed directory skipped every file. Agent worktrees live at `.claude/worktrees/agent-/`, so for a growing share of the people who run this gate it scanned 0 files and found 0 sites. That is not a quiet failure. Finding nothing makes every baseline row read "baseline 349, found 0", i.e. "all of these were converted", and the failure text then says: Run: python3 scripts/string_payload_access_inventory.py --write-baseline Doing what the error says would commit an all-zero baseline. The ratchet would be satisfied forever and could never catch a regression again, and the diff would look like a legitimate "record the progress" commit in review. Three changes: - filter on the path RELATIVE to the repo root (`rel.parts`), which is the thing the filter was always meant to test; - refuse to report a verdict after scanning zero files, with an explicit "do NOT run --write-baseline" — a scanner that looked at nothing must not be able to produce a clean bill of health (CLAUDE.md's fourth way a gate cannot fail); - self-test the dot-directory case. The existing fixture plants a synthetic crate in a tempdir and asserts files_scanned == 1, which is the right shape but cannot catch this, because `/var/folders/...` has no dot component. The new fixture plants the same tree under `.agentdir/` and asserts both the file count and the findings. Measured before/after in a dot-named directory: before, "found 0" for every row plus the --write-baseline instruction; after, 4056 files scanned, 393 inline offsets and 14 reader helpers, exit 0. CI was never affected — runners check out to /home/runner/work/perry/perry. Diagnosis by the turnloop lane, which hit it in an agent worktree. (cherry picked from commit 6c5b4f5cdfcbfc5f50848b78d7aab9d84dd826f6) --- .../11082-string-payload-scanner-dotdir.md | 3 ++ scripts/string_payload_access_inventory.py | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 changelog.d/11082-string-payload-scanner-dotdir.md diff --git a/changelog.d/11082-string-payload-scanner-dotdir.md b/changelog.d/11082-string-payload-scanner-dotdir.md new file mode 100644 index 0000000000..4eddf03aa8 --- /dev/null +++ b/changelog.d/11082-string-payload-scanner-dotdir.md @@ -0,0 +1,3 @@ +### Fixed + +- `scripts/string_payload_access_inventory.py` scanned **zero files** when run from a checkout under any dot-prefixed directory — which is where every agent worktree lives (`.claude/worktrees/agent-/`). The skip filter tested `path.parts`, the ABSOLUTE path, so `.claude` matched `part.startswith(".")` and every source file was skipped. The gate then reported each baseline row as `found 0` and printed `Run: … --write-baseline`; following that instruction would have written an all-zero baseline and left the ratchet permanently satisfied. The filter now tests the path relative to the repo root, a scan of zero files fails loudly instead of returning a verdict, and `--self-test` covers a checkout under a dot-named parent (a tempdir alone could not catch this, since `/var/folders/…` has no dot component). CI was never affected: runners check out to `/home/runner/work/perry/perry`. (#11082) diff --git a/scripts/string_payload_access_inventory.py b/scripts/string_payload_access_inventory.py index f0056fc867..1aa1140ad5 100755 --- a/scripts/string_payload_access_inventory.py +++ b/scripts/string_payload_access_inventory.py @@ -212,8 +212,15 @@ def collect_inventory(root: Path = REPO_ROOT) -> tuple[list[Finding], int]: for crate_dir in crate_dirs(root): crate = crate_dir.name for path in sorted(crate_dir.rglob("*.rs")): - rel_path = path.relative_to(root).as_posix() - if any(part.startswith(".") or part == "target" for part in path.parts): + rel = path.relative_to(root) + rel_path = rel.as_posix() + # Filter on the path RELATIVE to the repo root. `path.parts` is + # absolute, so a checkout living under any dot-prefixed directory + # -- `.claude/worktrees/agent-/` is where agents run -- matched + # `part.startswith(".")` on every file and skipped the entire + # workspace. The scan then found nothing and the gate reported each + # baseline row as "found 0", i.e. "everything was converted". + if any(part.startswith(".") or part == "target" for part in rel.parts): continue files_scanned += 1 text = path.read_text(encoding="utf-8") @@ -359,6 +366,30 @@ def expect(condition: bool, message: str) -> None: source.write_text(planted, encoding="utf-8") discovered, files_scanned = collect_inventory(temp_root) expect(files_scanned == 1, "synthetic crate source was not scanned exactly once") + + # The same tree, one level under a DOT-PREFIXED directory. Agents run + # from `.claude/worktrees/agent-/`, and the filter used to test the + # ABSOLUTE path, so every file was skipped and the scan silently + # returned nothing. A tempdir alone cannot catch this: `/var/folders/...` + # has no dot component. + dot_root = temp_root / ".agentdir" / "checkout" + dot_crate = dot_root / "crates" / "synthetic-crate" + (dot_crate / "src").mkdir(parents=True) + (dot_crate / "Cargo.toml").write_text( + '[package]\nname = "synthetic-crate"\nversion = "0.0.0"\n', + encoding="utf-8", + ) + (dot_crate / "src" / "lib.rs").write_text(planted, encoding="utf-8") + dot_found, dot_scanned = collect_inventory(dot_root) + expect( + dot_scanned == 1, + "a checkout under a dot-prefixed directory scanned no files " + "(the filter is testing the absolute path again)", + ) + expect( + counts_for(dot_found) == counts_for(findings), + "a checkout under a dot-prefixed directory lost findings", + ) expect( counts_for(discovered) == counts_for(findings), "filesystem inventory disagreed with direct source scanning", @@ -403,6 +434,17 @@ def main(argv: list[str] | None = None) -> int: return run_self_tests() findings, files_scanned = collect_inventory() + # A scan of zero files is not a clean tree, it is a broken scan. Without + # this, every baseline row reads "found 0" and the failure text invites + # `--write-baseline`, which would zero the ratchet and satisfy it forever. + if files_scanned == 0: + print( + "string-payload access inventory: SCANNED NO FILES -- this is a broken " + "scan, not a converted tree. Do NOT run --write-baseline. Check that " + "crates/ exists under the repo root being scanned.", + file=sys.stderr, + ) + return 1 actual = counts_for(findings) if args.write_baseline: write_baseline(args.baseline, actual) From 4e877c34e1226c85bba80b8acc8a11fb18732297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 05:27:39 +0200 Subject: [PATCH 18/20] fix(runtime): abort instead of silently degrading when Loop::new fails Both production `AgentLoop::new` call sites in `event_pump/agent_loop.rs` -- first creation (`ensure_loop_with`) and the Wait->Net profile upgrade (`upgrade_profile`) -- routed a failed `turnloop::Loop::new` into `LoopState::Declined`, pinning that thread to the legacy tokio park for the rest of its life. `STATE` is `perry_thread_local!` and neither `net_available()` nor `eligible()` ever retries a `Declined` state, so a single transient failure was permanent. The only evidence was a `[perry-loop] driver=legacy` line printed *only* under `PERRY_LOOP_STATS`, so in production an fd-limit bug presented as an unexplained per-thread throughput and RSS regression. No caller could recover either: every caller's fallback *is* that degradation. Both arms now call a new `#[cold] loop_creation_failed()`, which prints a `[PERRY ABORT]` line naming the agent, the profile, the turnloop `ErrorKind`, the OS errno and the compiled-in backend, then aborts. `abort` rather than panic matches the runtime's existing fatal convention: perry-runtime ships `panic = "abort"` but is built `panic = "unwind"` under `cargo test`, and a panic on a `perry/thread` or `worker_threads` agent kills only that thread -- swallowable in exactly the place this bug lives. The message names three causes and the errno that discriminates them: descriptor exhaustion (EMFILE/ENFILE), a sandbox denying one of the backend's syscalls (EPERM/EACCES), and a host with no turnloop backend. The sandbox case is the behavioural risk: turnloop's epoll backend probes `epoll_pwait2` and treats only ENOSYS as "old kernel, fall back to timerfd", so a seccomp filter answering EPERM makes `Loop::new` fail deterministically on every attempt. A sandboxed Linux/Android/HarmonyOS process that previously degraded silently now aborts at its first park. That is the intended trade -- a loud, actionable failure instead of an invisible one. The third cause cannot happen at run time, so it is gated at compile time instead of with a `cfg` fallback: `turnloop::Loop` is `Driver`, and `backend::Platform` exists only under `turnloop_backend = kqueue | epoll | iocp | wasi_p2 | wasi_p3 | web`. turnloop's `build.rs` maps every other target to "unsupported", where the crate does not compile. A `cfg` arm keeping the legacy park for such a host would be a branch that can never be taken, so `agent_loop.rs` carries `const _: () = assert!(!backend_name_is(b"unsupported"), ...)` instead: if turnloop ever gains a stub backend, the build fails on the affected target rather than a user's program aborting at run time. HarmonyOS is not the exception it looks like: Perry builds it as `{aarch64,x86_64}-unknown-linux-ohos`, whose rustc cfg is `target_os = "linux"` + `target_env = "ohos"`, so turnloop selects epoll there exactly as for any other Linux target. Also documents the one cause `LoopState::Declined` still has (the P1 coexistence rule, decided by `claim_route()` before any loop is built) and adds `every_profile_is_constructible_on_a_supported_host`, which asserts the fatal path's subject rather than its absence: a real backend is compiled in, and both `wait_config()` and `net_config()` are accepted by `Driver::new`. (cherry picked from commit 890dcb1cfed330df7728094a82ea553d5325dc70) --- changelog.d/11080-turnloop-loop-new-fatal.md | 29 ++++ .../src/event_pump/agent_loop.rs | 153 ++++++++++++++---- .../src/event_pump/agent_loop_tests.rs | 34 ++++ docs/turnloop/p9-report.md | 2 +- 4 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 changelog.d/11080-turnloop-loop-new-fatal.md diff --git a/changelog.d/11080-turnloop-loop-new-fatal.md b/changelog.d/11080-turnloop-loop-new-fatal.md new file mode 100644 index 0000000000..ef79eb4229 --- /dev/null +++ b/changelog.d/11080-turnloop-loop-new-fatal.md @@ -0,0 +1,29 @@ +### Changed + +- A failed `turnloop::Loop::new` is now fatal instead of silently degrading that thread to the legacy tokio park. Both production `AgentLoop::new` call sites in `crates/perry-runtime/src/event_pump/agent_loop.rs` — first creation (`ensure_loop_with`) and the Wait→Net profile upgrade (`upgrade_profile`) — route their `Err` arm into a new `#[cold] loop_creation_failed()`, which prints a `[PERRY ABORT]` line naming the agent, the profile, the turnloop `ErrorKind`, the OS errno and the compiled-in backend, then `std::process::abort()`s. + + Why the old fallback was worse than stopping: `STATE` is `perry_thread_local!`, and neither `net_available()` nor `eligible()` ever retries a `Declined` state — only `Unset` calls `claim_route()`. So a single transient failure (a descriptor ceiling crossed once) pinned that thread to the legacy transport for the rest of its life. The only evidence was a `[perry-loop] driver=legacy` line that is printed *only* under `PERRY_LOOP_STATS`, so in production an fd-limit bug presented as an unexplained per-thread throughput and RSS regression. No caller could recover either: every caller's fallback *is* that degradation. + + The message names three causes and, crucially, says which errno discriminates them: descriptor exhaustion (EMFILE/ENFILE — raise `ulimit -n` / `LimitNOFILE=`), a sandbox denying one of the backend's syscalls (EPERM/EACCES), and a host with no turnloop backend (which cannot happen; see below). + + The sandbox case is not hypothetical and is the behavioural risk in this change. turnloop's epoll backend opens an epoll fd and an eventfd at construction and probes `epoll_pwait2`, treating **only ENOSYS** as "old kernel, fall back to timerfd"; a seccomp filter that answers EPERM makes `Loop::new` fail on every attempt, deterministically. On a sandboxed Linux/Android/HarmonyOS app process that previously degraded silently to the tokio park and worked, Perry now aborts at the first park. That is the intended trade — a loud, actionable failure instead of an invisible one — but it is a behaviour change on a shipping target that no CI job covers. + +- `abort` rather than a panic or a warning, matching the runtime's existing fatal convention (`object/shapes.rs`, `closure/dispatch/errors.rs`, `object/field_get_set/field_ops.rs`): perry-runtime ships `panic = "abort"` but is built `panic = "unwind"` under `cargo test`, and a panic on a `perry/thread` or `worker_threads` agent kills only that thread — i.e. a panic is swallowable in exactly the place this bug lives. A warning that lets the program continue is the silent degradation with extra output. + +### Added + +- A compile-time platform gate, in place of a runtime `cfg` fallback. turnloop ships no no-op backend: `turnloop::Loop` is `Driver`, and `backend::Platform` is defined only under `turnloop_backend = kqueue | epoll | iocp | wasi_p2 | wasi_p3 | web`; turnloop's `build.rs` maps every other target to `"unsupported"`, where the crate does not compile. So "an unsupported host" cannot be a *runtime* cause in a binary that exists, and a `cfg` arm keeping the legacy park for one would be a branch that can never be taken. Instead `agent_loop.rs` carries `const _: () = assert!(!backend_name_is(b"unsupported"), …)`: if turnloop ever gains a stub backend, the *build* fails on the affected target instead of a user's program aborting at run time. Where that lands is worth saying, because no CI job cross-compiles `perry-runtime` for `*-linux-ohos` (`harmonyos-smoke` runs `perry-codegen-arkts` host tests only): the ohos build that would trip it is the one `perry compile --target harmonyos` drives via `optimized_libs/driver.rs`, on the packager's machine. + + HarmonyOS is explicitly not the exception it looks like: Perry builds it as `{aarch64,x86_64}-unknown-linux-ohos`, whose rustc cfg is `target_os = "linux"` + `target_env = "ohos"` (verified with `rustc --print cfg --target aarch64-unknown-linux-ohos`; it is also why every HarmonyOS `cfg` in `perry-runtime` spells `target_env = "ohos"`), so turnloop's `build.rs` selects the epoll backend there exactly as for any other Linux target. Every other target Perry compiles for is apple / linux / android / windows, and wasm32 excludes the dependency outright — so no compiled target can reach an "unsupported host" arm, and a `cfg` carve-out for one would be a branch that can never be taken. + +- `every_profile_is_constructible_on_a_supported_host` asserts the fatal path's subject rather than its absence: a real backend is compiled in, and both `wait_config()` and `net_config()` are accepted by `Driver::new`. `net_config()`'s ceilings have moved twice, and `Driver::new` rejects a `Config` whose `events_per_turn * 3 + max_operations + max_handles` overflows. + +### Documentation + +- `LoopState::Declined` now documents the one cause it still has — the P1 coexistence rule, decided by `claim_route()` *before* any loop is built. The two causes were already structurally separated; making one fatal is what makes that separation load-bearing, so `a_second_thread_of_the_same_agent_is_declined` records that a routing regression would now take down the test binary rather than pass. +- `docs/turnloop/p9-report.md`'s "every remaining decline, by cause" table: the `Loop::new` fails row no longer describes a decline. + +### Known follow-up (not changed here) + +- "A host where `Loop::new` failed" is cited as a live decline cause in prose that is now stale: `scripts/tokio_inventory.json` (14 `reached_when`/`blocker` strings; for the two `perry-ext-ioredis` edges it is called "the only remaining case", which would make those edges unreachable), plus `perry-stdlib/src/lib.rs:117`, `perry-stdlib/src/turnloop_client/mod.rs:41/148/580`. Deliberately left alone: `tokio_inventory.py` gates the manifest-edge *set* only and explicitly does not gate that free text, the turnloop migration has lanes in flight over the same file, and `Declined::NoLoop` stays reachable anyway through `LoopState::ShutDown`. It belongs to whoever next audits that ledger. + diff --git a/crates/perry-runtime/src/event_pump/agent_loop.rs b/crates/perry-runtime/src/event_pump/agent_loop.rs index 5fd9219363..b77cd7406f 100644 --- a/crates/perry-runtime/src/event_pump/agent_loop.rs +++ b/crates/perry-runtime/src/event_pump/agent_loop.rs @@ -293,8 +293,15 @@ enum LoopState { Claimed, /// This thread owns its agent's route slot AND its loop. Owner, - /// Not eligible: another thread already owns this agent's loop, or loop - /// creation failed. Parks use the legacy path. + /// Not eligible: another thread already owns this agent's loop. Parks use + /// the legacy path. + /// + /// This is the P1 coexistence rule and nothing else. It is decided by + /// [`claim_route`] *before* any loop is built, so a thread that reaches + /// `AgentLoop::new` has already won its agent's slot and a failure there + /// is not a decline — it is [`loop_creation_failed`], which aborts. The + /// only other writers are the two `claimed_flag()` arms below, which are + /// unreachable by construction and carry a `debug_assert!` saying so. Declined, /// `shutdown_current_thread` ran; parks use the legacy path from now on. ShutDown, @@ -398,8 +405,7 @@ fn claimed_flag() -> Option> { CLAIM.with(|slot| slot.borrow().as_ref().map(|c| c.in_turn.clone())) } -/// Give up this thread's route slot: at an explicit shutdown, or when loop -/// creation failed and the thread will never own one. +/// Give up this thread's route slot at an explicit shutdown. fn release_route() { CLAIM.with(|slot| *slot.borrow_mut() = None); } @@ -476,6 +482,106 @@ pub(super) fn eligible() -> bool { } } +/// `turnloop::BACKEND_NAME == name`, answerable in const context. +const fn backend_name_is(name: &[u8]) -> bool { + let actual = turnloop::BACKEND_NAME.as_bytes(); + if actual.len() != name.len() { + return false; + } + let mut i = 0; + while i < actual.len() { + if actual[i] != name[i] { + return false; + } + i += 1; + } + true +} + +/// Compile-time proof that this build HAS a turnloop backend. That is the +/// whole platform gate behind [`loop_creation_failed`] being fatal. +/// +/// turnloop ships no fallback backend: `turnloop::Loop` is +/// `Driver`, and `backend::Platform` exists only under +/// `turnloop_backend = kqueue | epoll | iocp | wasi_p2 | wasi_p3 | web` +/// (turnloop's `build.rs` maps every other target to `"unsupported"`). A host +/// turnloop cannot serve therefore fails to COMPILE here — it never reaches +/// `Loop::new` to fail at run time. So "an unsupported host" is not a runtime +/// cause in any binary that exists, and making the failure fatal needs no +/// `cfg` arm keeping a legacy park for one. +/// +/// HarmonyOS is worth naming because it looks like the exception and is not. +/// Perry builds it as `{aarch64,x86_64}-unknown-linux-ohos`, whose rustc cfg is +/// `target_os = "linux"` + `target_env = "ohos"` — which is why every HarmonyOS +/// `cfg` in this crate spells `target_env = "ohos"` — so turnloop's `build.rs` +/// selects the epoll backend there exactly as for any other Linux target. +/// +/// What this assertion does NOT say is that `Loop::new` cannot fail on ohos. +/// It can, for the same reasons it can on any Linux: `Epoll::new` opens an +/// epoll fd and an eventfd, and probes `epoll_pwait2`, treating only ENOSYS as +/// "old kernel". A sandbox that answers EPERM instead fails every call, on +/// every device. That is a real environment fault and belongs in +/// [`loop_creation_failed`]'s message (it names it, and the errno tells it +/// apart from a descriptor ceiling) — but it is not a missing backend, so it +/// is not a reason to keep a silent legacy fallback. +/// +/// If turnloop ever gains a no-op backend for unsupported hosts, this stops +/// holding. The assertion then fails the *build* on the affected target +/// instead of letting a user's program abort at run time, and the legacy +/// fallback should be restored here under a `cfg` as a deliberate choice. Note +/// where that lands: no CI job cross-compiles this crate for `*-linux-ohos` — +/// `harmonyos-smoke` only runs `perry-codegen-arkts` host tests — so the ohos +/// build that would trip it is the one `perry compile --target harmonyos` +/// drives (`perry/src/commands/compile/optimized_libs/driver.rs`), on the +/// machine of whoever is packaging the app. +const _: () = assert!( + !backend_name_is(b"unsupported"), + "turnloop reports no backend for this target: perry-runtime must keep the legacy park here \ + rather than let loop_creation_failed abort a user's program" +); + +/// `turnloop::Loop::new` failed on a thread that had already won its agent's +/// route. Fatal, deliberately. +/// +/// This used to set [`LoopState::Declined`] and keep the legacy tokio park. +/// But `STATE` is `perry_thread_local!` and neither [`net_available`] nor +/// [`eligible`] ever retries a decline, so ONE transient failure pinned that +/// thread to the legacy transport for the rest of its life — an fd-ceiling bug +/// presenting as an unexplained throughput and RSS regression on a single +/// thread, and only under `PERRY_LOOP_STATS`, which nobody sets in production. +/// Nor can a caller recover: every caller's fallback *is* that degradation. +/// +/// `abort` rather than a panic or a warning. perry-runtime ships +/// `panic = "abort"` but is built `panic = "unwind"` under `cargo test`, and a +/// panic on a `perry/thread` or `worker_threads` agent kills only that thread — +/// so a panic is swallowable exactly where this bug lives. A printed warning +/// that lets the program continue is the silent degradation with extra output. +#[cold] +#[inline(never)] +fn loop_creation_failed(profile: Profile, agent: AgentId, error: turnloop::Error) -> ! { + eprintln!( + "[PERRY ABORT] turnloop Loop::new failed for agent {agent} at the {profile:?} profile: \ + {error} (kind={:?} os_error={:?} backend={}). Perry's event loop cannot be created on \ + this thread, and `os_error` above is what tells the causes apart. (1) FILE DESCRIPTOR \ + EXHAUSTION — EMFILE (24) or ENFILE (23). Every agent loop needs a kqueue/epoll/IOCP \ + descriptor of its own, plus an eventfd on epoll, so a process that has run out cannot \ + open another; raise the limit (`ulimit -n`, or `LimitNOFILE=` in a systemd unit) and \ + re-run. (2) A SANDBOX DENYING A SYSCALL — EPERM (1) or EACCES (13). The epoll backend \ + probes `epoll_pwait2` at construction and only treats ENOSYS as 'old kernel, use \ + timerfd'; a seccomp filter that answers EPERM instead makes this fail on every attempt, \ + deterministically. Relevant on sandboxed Linux/Android/HarmonyOS app processes: check the \ + policy for epoll_pwait2, eventfd2 and timerfd_create. (3) AN UNSUPPORTED HOST — \ + `backend=unsupported` above would say so; perry-runtime does not compile in that state, \ + so it cannot be this unless turnloop has gained a no-op backend. Perry used to degrade \ + this thread to the legacy tokio park instead, which turned every one of these into an \ + invisible per-thread throughput and memory regression; it is fatal now.", + error.kind, + error.os, + turnloop::BACKEND_NAME, + ); + std::process::abort() +} + /// Create this thread's loop on first use. Returns whether the thread owns one. pub(super) fn ensure_loop() -> bool { ensure_loop_with(Profile::Wait) @@ -506,17 +612,13 @@ pub(super) fn ensure_loop_with(profile: Profile) -> bool { STATE.with(|s| s.set(LoopState::Declined)); return false; }; - let agent = match AgentLoop::new(profile, crate::agent::current_agent(), in_turn) { + let id = crate::agent::current_agent(); + let agent = match AgentLoop::new(profile, id, in_turn) { Ok(agent) => agent, - Err(_) => { - // Descriptor exhaustion or an unsupported host. Keep the legacy - // park rather than failing the program; the stats line says so. - // Release the slot: this thread will never own a loop, and holding - // it would deny a sibling thread of the same agent the chance. - release_route(); - STATE.with(|s| s.set(LoopState::Declined)); - return false; - } + // Descriptor exhaustion, or a sandbox refusing one of the backend's + // syscalls. Fatal: see `loop_creation_failed` for why a silent fall + // back to the legacy park is worse than stopping. + Err(error) => loop_creation_failed(profile, id, error), }; publish_route(&agent); AGENT_LOOP.with(|slot| *slot.borrow_mut() = Some(agent)); @@ -526,9 +628,10 @@ pub(super) fn ensure_loop_with(profile: Profile) -> bool { /// Rebuild this thread's loop at a larger profile, if it is not there yet. /// -/// Returns false only if the rebuild failed, in which case the old loop is -/// gone and the thread falls back to the legacy park — the same outcome as a -/// loop that never got created, and the stats line still says so. +/// The rebuild itself cannot fail softly: it drops the old loop first, so a +/// failure would leave the thread with no loop at all, and that is exactly the +/// silent degradation [`loop_creation_failed`] now aborts on. The only `false` +/// left is the unreachable missing-claim arm below. fn upgrade_profile(profile: Profile) -> bool { let needs_upgrade = AGENT_LOOP.with(|slot| { slot.borrow() @@ -564,17 +667,10 @@ fn upgrade_profile(profile: Profile) -> bool { }; // `AgentLoop::drop` cleared the endpoint but kept the slot; install the // replacement's into the same slot. - let mut agent = match AgentLoop::new( - profile, - owner.unwrap_or_else(crate::agent::current_agent), - in_turn, - ) { + let id = owner.unwrap_or_else(crate::agent::current_agent); + let mut agent = match AgentLoop::new(profile, id, in_turn) { Ok(agent) => agent, - Err(_) => { - release_route(); - STATE.with(|s| s.set(LoopState::Declined)); - return false; - } + Err(error) => loop_creation_failed(profile, id, error), }; if let Some(stats) = carried { agent.stats = stats; @@ -1068,6 +1164,9 @@ pub fn shutdown_current_thread() { if stats_enabled() { match (&agent, previous) { (Some(agent), _) => print_stats(id, agent.stats), + // Since loop-creation failure aborts, `Declined` can only mean the + // P1 coexistence rule: another thread owns this agent's loop and + // this one pumped on the legacy path all along. That is normal. (None, LoopState::Declined) => eprintln!("[perry-loop] driver=legacy agent={id}"), // A worker agent that never parked and never submitted is the // ordinary case for `parallelMap` over 64 cores. Saying so once per diff --git a/crates/perry-runtime/src/event_pump/agent_loop_tests.rs b/crates/perry-runtime/src/event_pump/agent_loop_tests.rs index 32b7006ca1..42cf9df845 100644 --- a/crates/perry-runtime/src/event_pump/agent_loop_tests.rs +++ b/crates/perry-runtime/src/event_pump/agent_loop_tests.rs @@ -28,6 +28,34 @@ fn stats() -> LoopStats { loop_statistics().expect("this thread owns a loop") } +/// The subject of the fatal `Loop::new` path: on a host with a real turnloop +/// backend, EVERY profile this runtime asks for is actually constructible. +/// +/// `loop_creation_failed` aborts the process, so "nothing threw" is not a +/// verdict here — the point is that a healthy process never reaches it, which +/// requires both halves: a backend exists, and both `Config`s are accepted. +/// `net_config()`'s ceilings have moved twice (perry#10351 handles, the 19 MB +/// `WorkPort` ring), and `Driver::new` rejects a `Config` whose +/// `events_per_turn * 3 + max_operations + max_handles` overflows. +#[test] +fn every_profile_is_constructible_on_a_supported_host() { + assert_ne!( + turnloop::BACKEND_NAME, + "unsupported", + "a host with no turnloop backend must not reach the fatal Loop::new path" + ); + for profile in [Profile::Wait, Profile::Net] { + let agent = AgentLoop::new( + profile, + crate::agent::current_agent(), + Arc::new(AtomicBool::new(false)), + ) + .unwrap_or_else(|error| panic!("{profile:?} profile is not constructible: {error}")); + assert_eq!(agent.profile, profile); + drop(agent); + } +} + /// Claim the primary agent's route on this thread, waiting out a route held by /// a test thread that is still finishing. fn take_primary_route() { @@ -373,6 +401,12 @@ fn sibling_worker_agents_do_not_share_a_loop() { /// legacy park. This is the Android shape — `perry-native` runs the JS and /// owns the loop, the UI thread pumps on its behalf — and it must stay /// exactly one owner per agent. +/// +/// It is also what keeps the two causes of `LoopState::Declined` separated now +/// that one of them aborts: this decline is decided by `claim_route`, so the +/// pump thread must never reach `AgentLoop::new`. `loop_statistics().is_none()` +/// is that assertion — and if the routing regressed, `loop_creation_failed` +/// would take down the whole test binary rather than let it pass. #[test] fn a_second_thread_of_the_same_agent_is_declined() { let _g = serial(); diff --git a/docs/turnloop/p9-report.md b/docs/turnloop/p9-report.md index 7a69764106..ff1ba8a2b6 100644 --- a/docs/turnloop/p9-report.md +++ b/docs/turnloop/p9-report.md @@ -200,7 +200,7 @@ Every remaining decline, by cause: | still declines | cause | who closes it | |---|---|---| | any surface, in the `tokio-wait-driver` A/B arm | there is no loop at all, by construction | nobody — it is the baseline | -| any surface, when `Loop::new` fails | descriptor exhaustion, an unsupported host | nobody — it is the fallback that keeps a program running | +| ~~any surface, when `Loop::new` fails~~ — **no longer a decline**: it aborts (`agent_loop::loop_creation_failed`) | descriptor exhaustion. "An unsupported host" is not a runtime cause: turnloop has no no-op backend, so such a target fails to compile | closed — the fallback pinned one thread to tokio for its whole life and said so only under `PERRY_LOOP_STATS` | | any surface, on a second thread acting for an agent another thread owns | exactly one thread owns an agent's loop (Android's UI pump) | nobody — it is the rule, and it preserves Android | | `net`/`tls` after `socket.upgradeToTLS` | P1 kept the tokio socket so the TLS upgrade keeps working | a `turnloop-tls` client path | | `http.createServer` in a **cluster worker** | the `SO_REUSEPORT` bind is not reachable through `ListenOpts` | PerryTS/turnloop#49 | From 6f67dc6465227140a8f226091b5f197ec3538be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 08:00:53 +0200 Subject: [PATCH 19/20] test-parity: record test_gap_fetch_expect_continue_header as passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11031 fixes the shorthand-`Headers` lowering this test pins, so the gap suite reports it as an IMPROVEMENT (parity_fail -> pass) and the snapshot gate fails on that as it does on a regression. A test absent from `tests` is expected to pass (the snapshot's own schema text), so the fix is to delete the entry, and its `known_failures.json` triage row with it — a stale row there is what #797 exists to prevent. Hand-edited rather than regenerated: the snapshot is a Linux-only shared baseline and regenerating it from a macOS run would rewrite every row. The CI shard-4 report on this head is the evidence: IMPROVEMENTS — these now pass: - test_gap_fetch_expect_continue_header: parity_fail -> pass with Crashed: 0 and no regressions. --- test-parity/gap_snapshot.json | 7 ------- test-parity/known_failures.json | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/test-parity/gap_snapshot.json b/test-parity/gap_snapshot.json index e2af8de4e5..a78a624c12 100644 --- a/test-parity/gap_snapshot.json +++ b/test-parity/gap_snapshot.json @@ -24,13 +24,6 @@ "category": "bug-open", "reason": "process SIGINT trace hook gap; standing per #5917 diff." }, - "test_gap_fetch_expect_continue_header": { - "status": "parity_fail", - "issue": "11024", - "added": "2026-09-22", - "category": "module-inventory", - "reason": "Case 2 of 5. A SHORTHAND object property (`{ ..., headers }`) loses the `Headers` handle before `js_fetch_headers_to_json`, so the instance is stringified generically to `{}` and every header is dropped. The forbidden-header check added by #10354 (`fetch::forbidden_header_failure`) therefore never sees `expect` on that path, the PUT goes out, and node rejects with NotSupportedError / UND_ERR_NOT_SUPPORTED where perry returns 200. Explicit `headers: headers` is correct and the other four cases pass; the two differ ONLY by the shorthand — #11024 carries the 12-line repro. SCOPE: not Expect-specific — ANY `fetch(url, { headers })` written with shorthand and a `Headers` instance sends NO headers at all, including ordinary ones; object literals and explicit `headers: h` are unaffected. ATTRIBUTION as measured: `forbidden_header_failure` has 0 references on origin/main and 3 on #10354, so the CHECK is new here; codegen/src/expr/logical_collections.rs and stdlib/src/fetch/headers.rs are UNTOUCHED by #10354 — strong evidence the lowering defect predates it, but not proof; a two-arm probe (main vs this head, same fixture, only compiler+runtime varying) is measuring it and #11024 carries the verdict." - }, "test_gap_http2_alpn_secure": { "status": "parity_fail", "issue": "10327", diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index abd4978f1e..f6b8c04c8f 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -76,16 +76,6 @@ "category": "bug-stale", "reason": "RE-TRIAGE: tracking issue #2514 is CLOSED but this still fails (audited 2026-08-07, #7582) — needs a new issue. process SIGINT trace hook gap; standing per the #5917 diff." }, - "test_gap_fetch_expect_continue_header": { - "issue": "11024", - "added": "2026-09-22", - "category": "module-inventory", - "reason": "Case 2 of 5. A SHORTHAND object property (`{ ..., headers }`) loses the `Headers` handle before `js_fetch_headers_to_json`, so the instance is stringified generically to `{}` and every header is dropped. The forbidden-header check added by #10354 (`fetch::forbidden_header_failure`) therefore never sees `expect` on that path, the PUT goes out, and node rejects with NotSupportedError / UND_ERR_NOT_SUPPORTED where perry returns 200. Explicit `headers: headers` is correct and the other four cases pass; the two differ ONLY by the shorthand — #11024 carries the 12-line repro. SCOPE: not Expect-specific — ANY `fetch(url, { headers })` written with shorthand and a `Headers` instance sends NO headers at all, including ordinary ones; object literals and explicit `headers: h` are unaffected. ATTRIBUTION as measured: `forbidden_header_failure` has 0 references on origin/main and 3 on #10354, so the CHECK is new here; codegen/src/expr/logical_collections.rs and stdlib/src/fetch/headers.rs are UNTOUCHED by #10354 — strong evidence the lowering defect predates it, but not proof; a two-arm probe (main vs this head, same fixture, only compiler+runtime varying) is measuring it and #11024 carries the verdict.", - "platforms": [ - "linux", - "macos" - ] - }, "test_gap_http2_alpn_secure": { "issue": "10327", "added": "2026-09-16", From 015b693861af4710f3ce391fda3288d774dd74a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 08:02:29 +0200 Subject: [PATCH 20/20] chore: release merge train 260 as v0.5.1643 --- CLAUDE.md | 2 +- Cargo.lock | 132 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 68 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3aa04f3492..31c230586f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1642 +**Current Version:** 0.5.1643 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index febeac1207..8a5f65517d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "base64 0.22.1", @@ -5508,7 +5508,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-dispatch", "serde", @@ -5516,7 +5516,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "cc", "libc", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "aho-corasick", "anyhow", @@ -5542,7 +5542,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-hir", @@ -5550,7 +5550,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-hir", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-dispatch", @@ -5567,7 +5567,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-hir", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "base64 0.22.1", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-hir", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "async-trait", "clap", @@ -5619,14 +5619,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", ] [[package]] name = "perry-db-turnloop" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "perry-tls-turnloop", @@ -5634,7 +5634,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "serde", "serde_json", @@ -5642,7 +5642,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1642" +version = "0.5.1643" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "clap", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "block2", "objc2", @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "argon2", "perry-ffi", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "bcrypt", "perry-ffi", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "rusqlite", @@ -5703,7 +5703,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "scraper", @@ -5711,7 +5711,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "rust_decimal", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "perry-runtime", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "bytes", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "lazy_static", "perry-db-turnloop", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "bson", "futures-util", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "bytes", "perry-ffi", @@ -5811,7 +5811,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "turnloop-smtp", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "notify", "perry-ffi", @@ -5832,7 +5832,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "printpdf", @@ -5840,7 +5840,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "fast_image_resize", "image", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "lazy_static", "perry-ffi", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-ffi", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "perry-runtime", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "lazy_static", "perry-ffi", @@ -5904,7 +5904,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "brotli", "flate2", @@ -5914,7 +5914,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-http-client" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "perry-tls-session", @@ -5957,7 +5957,7 @@ dependencies = [ [[package]] name = "perry-http-server" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "http", "httpdate", @@ -5967,11 +5967,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1642" +version = "0.5.1643" [[package]] name = "perry-parser" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "perry-diagnostics", @@ -5984,7 +5984,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perex", "regex", @@ -5992,7 +5992,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "ahash", "base64 0.22.1", @@ -6051,14 +6051,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6140,21 +6140,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-tls-session" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "turnloop-tls", ] [[package]] name = "perry-tls-turnloop" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-ffi", "perry-tls-session", @@ -6163,14 +6163,14 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "dirs", "perry-ffi", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "jni", @@ -6195,7 +6195,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "rand 0.10.2", "serde", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "async-channel", "async-executor", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "block2", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "block2", @@ -6264,7 +6264,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1642" +version = "0.5.1643" [[package]] name = "perry-ui-test" @@ -6275,11 +6275,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1642" +version = "0.5.1643" [[package]] name = "perry-ui-tvos" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "block2", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "block2", @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "block2", "libc", @@ -6327,7 +6327,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "libc", @@ -6346,7 +6346,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "base64 0.22.1", "libc", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "anyhow", "base64 0.22.1", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1642" +version = "0.5.1643" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d94af89bea..179af9a2b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,7 +320,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1642" +version = "0.5.1643" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"