From c6b63074d44db35d006d18da89e5748cea7354f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 23:47:09 +0200 Subject: [PATCH 1/2] fix(hir,fetch): preserve Hono response state (#8968) --- .../perry-codegen/src/lower_call/builtin.rs | 4 ++ .../src/runtime_decls/strings_part2.rs | 3 + crates/perry-hir/src/lower/mod.rs | 4 +- crates/perry-hir/src/lower_patterns.rs | 13 +++- crates/perry-stdlib/src/fetch/dispatch.rs | 13 ++++ crates/perry-stdlib/src/fetch/headers.rs | 14 ++++ crates/perry-stdlib/src/fetch/mod.rs | 20 ++++++ .../perry-stdlib/src/fetch/response_ctor.rs | 26 ++++--- .../issue_8968_private_compound_assignment.rs | 64 +++++++++++++++++ .../tests/issue_8968_response_headers.rs | 70 +++++++++++++++++++ 10 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 crates/perry/tests/issue_8968_private_compound_assignment.rs create mode 100644 crates/perry/tests/issue_8968_response_headers.rs diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 41c766ed3f..abef1bbf5f 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -1056,6 +1056,10 @@ pub(super) fn lower_builtin_new<'a>( } "Response" => { // new Response(body?, init?) — init = { status?, statusText?, headers? } + // Clear BodyInit metadata before evaluating either argument: init + // evaluation can throw after the body was converted, in which case + // js_response_new never gets a chance to consume that metadata. + ctx.block().call(DOUBLE, "js_response_body_init_reset", &[]); // Route the body through js_response_body_init_ptr (not the plain // string coercion) so a ReadableStream body — e.g. Hono's // `new Response(res.body, res)` header re-wrap — is drained to its diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index c8d9417808..c3301947c6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -949,6 +949,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // Normalize a Request/Response subclass object (for example NextResponse) // to its native Fetch registry handle; bare handles pass through. module.declare_function("js_fetch_unwrap_handle", DOUBLE, &[DOUBLE]); + // Response BodyInit metadata is reset before argument evaluation so a + // throwing init expression cannot leak it into a later construction. + module.declare_function("js_response_body_init_reset", DOUBLE, &[]); // js_response_body_init_ptr(body_value_f64) -> string_ptr (i64): drains a // ReadableStream body to bytes, else falls back to string coercion. module.declare_function("js_response_body_init_ptr", I64, &[DOUBLE]); diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 6ce65947ac..7a2cc5d62c 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -42,7 +42,9 @@ mod expr_call; pub(crate) mod expr_function; pub(crate) use expr_function::capture_function_source; mod expr_member; -pub(crate) use expr_member::{private_storage_property, wrap_private_guard, PRIV_OP_WRITE}; +pub(crate) use expr_member::{ + private_storage_property, wrap_private_guard, PRIV_OP_READ, PRIV_OP_WRITE, +}; mod expr_misc; mod expr_new; mod expr_new_builtins; diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index 217044e333..204f860179 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -4,7 +4,9 @@ //! parameter destructuring, and other pattern-related utilities. use crate::ir::*; -use crate::lower::{lower_expr, LoweringContext}; +use crate::lower::{ + lower_expr, private_storage_property, wrap_private_guard, LoweringContext, PRIV_OP_READ, +}; use crate::lower_types::*; use crate::types::{LocalId, Type}; use anyhow::{anyhow, Result}; @@ -244,9 +246,14 @@ pub(crate) fn lower_assign_target_to_expr( Ok(Expr::IndexGet { object, index }) } ast::MemberProp::PrivateName(private) => { - let property = format!("#{}", private.name); + // Compound and logical assignments lower the read and write + // halves separately. Match ordinary private-member reads: + // guard the receiver and use the class-mangled storage key. + let private_name = format!("#{}", private.name); + let object = wrap_private_guard(ctx, object, &private_name, PRIV_OP_READ); + let property = private_storage_property(ctx, &private_name); Ok(Expr::PropertyGet { - byte_offset: 0, + byte_offset: member.span.lo.0, object, property, }) diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index dbda44fbf4..be438ff768 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -18,6 +18,14 @@ use super::*; use perry_runtime::{js_get_string_pointer_unified, js_jsvalue_to_string}; +/// Clear metadata left by an earlier Response whose init evaluation threw +/// before `js_response_new` could consume it. +#[no_mangle] +pub extern "C" fn js_response_body_init_reset() -> f64 { + reset_pending_fetch_body_init(); + f64::from_bits(TAG_UNDEFINED) +} + /// Coerce a `Response` body-init value to a `*const StringHeader` (returned as /// i64, mirroring `js_get_string_pointer_unified`). /// @@ -30,6 +38,11 @@ use perry_runtime::{js_get_string_pointer_unified, js_jsvalue_to_string}; /// (`c.json`/`c.text`) are unaffected. #[no_mangle] pub extern "C" fn js_response_body_init_ptr(value: f64) -> i64 { + set_pending_fetch_body_content_type( + JSValue::from_bits(value.to_bits()) + .is_any_string() + .then_some(BODY_CONTENT_TYPE_TEXT_PLAIN), + ); // A binary body — Buffer / Uint8Array / typed array / ArrayBuffer — must // copy its RAW bytes. Such a value is a BufferHeader/TypedArrayHeader // pointer, NOT a StringHeader; passing it straight to `string_from_header` diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index 9ef2b4cf82..792ed67c43 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -100,6 +100,20 @@ fn is_headers_init_iterable(value: f64) -> bool { || has_sync_iterator(value) } +/// Read a plain-object `HeadersInit` without changing registry ownership. +/// Response construction uses this when codegen passes a runtime header record +/// rather than a `Headers` registry handle. +pub(super) fn headers_store_from_record_value(init: f64) -> Option { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let rooted = scope.root_nanbox_f64(init); + let entries = read_headers_record_entries(rooted.get_nanbox_f64(), &scope)?; + let mut store = HeadersStore::default(); + for (key, value) in entries { + store.append(&key, &value); + } + Some(store) +} + fn read_headers_record_entries( value: f64, scope: &perry_runtime::gc::RuntimeHandleScope, diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 81bc085ce3..ef57a1fcfb 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -273,6 +273,26 @@ fn response_headers_snapshot(response: &FetchResponse) -> HeadersStore { thread_local! { static PENDING_FETCH_BODY_STREAM_ID: Cell = const { Cell::new(0) }; + // Codegen coerces BodyInit to a StringHeader before js_response_new, so + // preserve whether the original value was a string long enough for the + // Response constructor to install Fetch's default Content-Type. + static PENDING_FETCH_BODY_CONTENT_TYPE: Cell> = + const { Cell::new(None) }; +} + +pub(super) const BODY_CONTENT_TYPE_TEXT_PLAIN: &str = "text/plain;charset=UTF-8"; + +pub(super) fn set_pending_fetch_body_content_type(content_type: Option<&'static str>) { + PENDING_FETCH_BODY_CONTENT_TYPE.with(|pending| pending.set(content_type)); +} + +pub(super) fn reset_pending_fetch_body_init() { + PENDING_FETCH_BODY_STREAM_ID.with(|pending| pending.set(0)); + PENDING_FETCH_BODY_CONTENT_TYPE.with(|pending| pending.set(None)); +} + +fn take_pending_fetch_body_content_type() -> Option<&'static str> { + PENDING_FETCH_BODY_CONTENT_TYPE.with(|pending| pending.replace(None)) } fn take_pending_fetch_body_stream_id() -> Option { diff --git a/crates/perry-stdlib/src/fetch/response_ctor.rs b/crates/perry-stdlib/src/fetch/response_ctor.rs index 67453a62a7..5df66a6486 100644 --- a/crates/perry-stdlib/src/fetch/response_ctor.rs +++ b/crates/perry-stdlib/src/fetch/response_ctor.rs @@ -41,6 +41,9 @@ pub unsafe extern "C" fn js_response_new( headers_handle: f64, ) -> f64 { let body_stream_id = take_pending_fetch_body_stream_id(); + // Consume before validation so a throwing constructor cannot leak body + // metadata into the next Response construction on this thread. + let body_content_type = take_pending_fetch_body_content_type(); // Lossless raw-byte read so binary bodies survive byte-for-byte (#5435). let body_opt = dispatch::body_bytes_from_header(body_ptr); let body_present = body_opt.is_some() || body_stream_id.is_some(); @@ -77,16 +80,19 @@ pub unsafe extern "C" fn js_response_new( )); } let headers_id = handle_id(headers_handle); - let headers = if headers_id != 0 { - HEADERS_REGISTRY - .lock() - .unwrap() - .get(&headers_id) - .cloned() - .unwrap_or_default() - } else { - HeadersStore::default() - }; + let registered = (headers_id != 0) + .then(|| HEADERS_REGISTRY.lock().unwrap().get(&headers_id).cloned()) + .flatten(); + // Non-literal Response init objects can deliver a plain HeadersInit value + // here. Preserve those records instead of treating them as missing handles. + let mut headers = registered + .or_else(|| headers_store_from_record_value(headers_handle)) + .unwrap_or_default(); + if let Some(content_type) = body_content_type { + if headers.get("content-type").is_none() { + headers.set("content-type", content_type); + } + } // A Response owns a private Headers list. The constructor input may be an // existing Headers object, so retaining its registry id would make // mutations alias in both directions instead of copying the initializer. diff --git a/crates/perry/tests/issue_8968_private_compound_assignment.rs b/crates/perry/tests/issue_8968_private_compound_assignment.rs new file mode 100644 index 0000000000..f6ea7d2af7 --- /dev/null +++ b/crates/perry/tests/issue_8968_private_compound_assignment.rs @@ -0,0 +1,64 @@ +//! Regression for #8968: the read half of assignment to a private member used +//! the source spelling (`#x`) instead of the field's mangled storage key. + +use std::path::PathBuf; +use std::process::Command; + +fn run_source(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write source"); + let compile = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))) + .current_dir(dir.path()) + .args([ + "compile", + entry.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + ]) + .env("PERRY_NO_CACHE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(output).output().expect("run compiled program"); + assert!( + run.status.success(), + "program failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).trim().to_string() +} + +#[test] +fn private_assignment_reads_the_declared_member() { + let stdout = run_source( + r#"class Context { + #res: any; + #count = 10; + get res(): any { return this.#res ||= { status: 200 }; } + set res(value: any) { this.#res = value; } + bump(): number { this.#count += 2; return this.#count; } +} +const context = new Context(); +const finalized = { status: 404 }; +context.res = finalized; +console.log(context.res.status, context.res === finalized, context.bump()); + +class Accessor { + #raw = 7; + get #value(): number { return this.#raw; } + set #value(value: number) { this.#raw = value; } + bump(): number { this.#value += 1; return this.#raw; } +} +console.log(new Accessor().bump()); +"#, + ); + assert_eq!(stdout.lines().collect::>(), ["404 true 12", "8"]); +} diff --git a/crates/perry/tests/issue_8968_response_headers.rs b/crates/perry/tests/issue_8968_response_headers.rs new file mode 100644 index 0000000000..c1fe10894d --- /dev/null +++ b/crates/perry/tests/issue_8968_response_headers.rs @@ -0,0 +1,70 @@ +//! Regression for the Response header failures exposed by Hono in #8968. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn response_preserves_runtime_headers_and_string_content_type() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#"function show(label: string, response: Response): void { + console.log(label, response.status, JSON.stringify(response.headers.get("content-type"))); +} +const spread: any = { "Content-Type": "application/json", ...(undefined as any) }; +show("coalesce", new Response("x", { headers: (undefined as any) ?? spread })); +const init: any = { status: 404, headers: { "content-type": "application/json" } }; +show("runtime-init", new Response("x", init)); +show("headers", new Response("x", { headers: new Headers(spread) })); +show("default", new Response("x")); +show("explicit", new Response("x", { headers: { "content-type": "text/html" } })); +show("bytes", new Response(new Uint8Array([1, 2, 3]) as any)); +show("empty", new Response()); +function badInit(): any { throw new Error("boom"); } +try { new Response("must not leak", badInit()); } catch {} +show("after-throw", new Response()); +"#, + ) + .expect("write source"); + let compile = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))) + .current_dir(dir.path()) + .args([ + "compile", + entry.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + ]) + .env("PERRY_NO_CACHE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(output).output().expect("run compiled program"); + assert!( + run.status.success(), + "program failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout) + .lines() + .collect::>(), + [ + r#"coalesce 200 "application/json""#, + r#"runtime-init 404 "application/json""#, + r#"headers 200 "application/json""#, + r#"default 200 "text/plain;charset=UTF-8""#, + r#"explicit 200 "text/html""#, + "bytes 200 null", + "empty 200 null", + "after-throw 200 null", + ] + ); +} From 3fc71932e14d2eaed390d223f677960e9d8f1b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 23:58:24 +0200 Subject: [PATCH 2/2] chore(changelog): PR-key the fragment () --- changelog.d/8979-run-directory-entry.md | 5 +++ changelog.d/8980-private-guard-call-site.md | 22 +++++++++++ .../8981-dynamic-function-refusal-unwind.md | 3 ++ changelog.d/8982-hono-response-state.md | 5 +++ .../8983-feedback-gate-and-shape-field.md | 38 +++++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 changelog.d/8979-run-directory-entry.md create mode 100644 changelog.d/8980-private-guard-call-site.md create mode 100644 changelog.d/8981-dynamic-function-refusal-unwind.md create mode 100644 changelog.d/8982-hono-response-state.md create mode 100644 changelog.d/8983-feedback-gate-and-shape-field.md diff --git a/changelog.d/8979-run-directory-entry.md b/changelog.d/8979-run-directory-entry.md new file mode 100644 index 0000000000..bf426955b6 --- /dev/null +++ b/changelog.d/8979-run-directory-entry.md @@ -0,0 +1,5 @@ +`perry run ` now resolves the directory to its project entry instead of passing the directory itself into module collection. + +An explicit directory input is treated as a project root: its `perry.toml` entry is read relative to that directory, falling back to `/src/main.ts` then `/main.ts`. Previously `perry run .` read `perry.toml` from the current working directory and handed the directory straight to module collection, which fails on Windows. + +Fixes #8908. diff --git a/changelog.d/8980-private-guard-call-site.md b/changelog.d/8980-private-guard-call-site.md new file mode 100644 index 0000000000..5791531d9e --- /dev/null +++ b/changelog.d/8980-private-guard-call-site.md @@ -0,0 +1,22 @@ +The private-member guard moved to its call sites, taking a call off every +ordinary property read and write. + +#8970 made the private-member name test cheap but left the CALL. In a pure +property-read loop (`o[k]` with pre-built keys, no concat) that showed up as +`private_member_get_by_name` 11.4% plus `private_member_storage_name` 5.4% — +**16.8% of the loop, the largest single item** — essentially all of it call +overhead for keys that are rejected on their length before doing anything. + +The guard is now invoked at the three call sites (the generic read entry, the +class-field read miss, and the generic write), so an ordinary property +operation makes no call into the private-member path at all. Keys that pass +the guard take exactly the original path. + +Interleaved A/B, min-of-21 (under heavy co-tenant load, so read the ratios +rather than the absolutes): pure property read 26 → 22 ms (−15%), computed-key +read 51 → 45 ms (−12%), combined overwrite 50 → 46 ms (−8%), write unchanged. + +Output on a private-member exercise — instance fields, `static #instances`, +private methods, private getters, `#x in obj`, subclassing, and an ordinary key +literally named `#` — is byte-identical to before the +change. Computed-key differential vs node is byte-identical. Suite 2779 passed. diff --git a/changelog.d/8981-dynamic-function-refusal-unwind.md b/changelog.d/8981-dynamic-function-refusal-unwind.md new file mode 100644 index 0000000000..807a36cfcd --- /dev/null +++ b/changelog.d/8981-dynamic-function-refusal-unwind.md @@ -0,0 +1,3 @@ +### Fixed + +- Refusing a runtime-string `Function` in a `dyn-eval`-off AOT binary now throws a catchable `TypeError` instead of aborting at the runtime FFI boundary, allowing zod v4 and other capability-probing libraries to select their non-eval fallback. diff --git a/changelog.d/8982-hono-response-state.md b/changelog.d/8982-hono-response-state.md new file mode 100644 index 0000000000..40194cf4ec --- /dev/null +++ b/changelog.d/8982-hono-response-state.md @@ -0,0 +1,5 @@ +Fixed three defects that together broke Hono response state. + +- The read half of a compound or logical private-member assignment (`this.#x ||= v`) now lowers through the same brand guard and class-mangled storage key as an ordinary private read, instead of a differently-keyed path. +- A runtime `HeadersInit` record passed through a non-literal `Response` init object is preserved rather than dropped. +- The Fetch default `text/plain;charset=UTF-8` is installed for string bodies without overriding an explicit content type, and pending BodyInit metadata is cleared across reuse. diff --git a/changelog.d/8983-feedback-gate-and-shape-field.md b/changelog.d/8983-feedback-gate-and-shape-field.md new file mode 100644 index 0000000000..66a4acaecb --- /dev/null +++ b/changelog.d/8983-feedback-gate-and-shape-field.md @@ -0,0 +1,38 @@ +Two dead-work removals on the property read path. The computed-key read loop +now **matches node** (23 ms vs 23 ms on the same host), and the pure property +read drops 21 → 17 ms. + +**1. Typed-feedback observation is skipped when recording is off.** Recording +is off by default, and `guard_observe` and `record_fallback_call` both +early-return in that mode — but the property wrappers built the whole +`Observation` first, hashing the key and resolving the receiver's shape, purely +to hand it to functions that discard it. `js_typed_feedback_object_get_field_by_name_f64` +was 10% of an isolated property-read loop, nearly all of it that. The array +index wrappers have carried #5094's gate for exactly this reason, and #8951 +gave it to the fast store path; the property get/set wrappers never got it. + +Behaviour is unchanged in both modes: with recording off `guard_observe` +returns `contract_valid` unmodified and the fallback recorder is a no-op, so +the wrapper already reduced to precisely the underlying call it now makes +directly. + +**2. The slot bound stops copying a descriptor to read four bytes.** +`shape_descriptor_by_id` returns `ShapeDescriptor` **by value**, so +`object_live_slot_count` — consulted on essentially every property read and +write — lifted the whole ~48-byte record and kept only +`live_inline_slot_count`. It now reads that field through the table's record +using the same way-cache probe and the same epoch validation. +`shape_descriptor_by_id` was 10.1% of the same loop. + +Interleaved A/B, min-of-21, node on the same host in brackets: + +| loop | base | this PR | | +|---|---|---|---| +| pure property read | 21 ms (4) | **17 ms** | −19% | +| computed-key read | 27 ms (23) | **23 ms** | −15% — now at parity with node | +| combined overwrite | 46 ms (31) | **41 ms** | −11% | +| write only | 21 ms (23) | 21 ms | unchanged; already faster than node | + +Suite 2779 passed (including the 55 typed-feedback tests). Private-member +output is byte-identical to base; computed-key differential is byte-identical +to node.