From 265278af272eda872e7c765c2e502346a6d7437e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 16:13:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(runtime):=20make=20Object.assign(proces?= =?UTF-8?q?s.env,=20=E2=80=A6)=20actually=20set=20environment=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `process.env` is not an ordinary object — reads resolve through the runtime's env lookup — but `Object.assign` treated it as one. The merged keys landed in a plain object that nothing ever consulted, so every subsequent `process.env.X` read came back `undefined`: Object.assign(process.env, { DATABASE_URL: "mysql://…" }); process.env.DATABASE_URL; // node: "mysql://…" perry: undefined `js_object_assign_one` now recognizes a `process.env` target and routes each source key through `js_setenv`, so the values land where reads look for them. A plain-object target is untouched. `js_setenv` additionally writes the key into the cached env object, so a read that hits the cache sees it too. This is how `@next/env` installs a parsed `.env` file (`Object.assign(process.env, parsed)`), and how dotenv and most config loaders do it — so under Perry a Next.js app silently ran with no environment at all: no `DATABASE_URL`, no API keys. Found while compiling a real Next.js + MySQL app. Covered by `test_gap_object_assign_process_env` — direct/bracket/dynamic-key reads, `in`, overwrite, multi-source assign, plain assignment, and an ordinary-object target. Byte-identical to node. --- crates/perry-runtime/src/object/alloc.rs | 28 ++++++++++++++ crates/perry-runtime/src/process.rs | 21 +++++----- crates/perry-runtime/src/process/env_misc.rs | 38 +++++++++++++++++-- .../test_gap_object_assign_process_env.ts | 38 +++++++++++++++++++ 4 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 test-files/test_gap_object_assign_process_env.ts diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index c792e8bb9b..959c2dd3a4 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1112,6 +1112,34 @@ unsafe fn object_assign_proxy_source( pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) -> f64 { let target_f64 = js_object_assign_validate_target(target_f64); + // `Object.assign(process.env, parsed)` — how `@next/env` loads `.env` + // files. `process.env.X` READS lower to `js_getenv` (the real environment), + // so copying fields into the cached env object leaves every read + // `undefined`: a Next.js standalone server saw NONE of its `.env` config + // (myairank's `DATABASE_URL` vanished, mysql2 then connected with an empty + // user/database and the MySQL handshake timed out). Route each key through + // the env setter so the write lands where the reads look. + if crate::process::is_process_env_object(target_f64) { + let keys = crate::object::js_object_keys_value(source_f64); + if !keys.is_null() { + let len = crate::array::js_array_length(keys); + for i in 0..len { + let key_val = crate::array::js_array_get_f64(keys, i); + let key_ptr = crate::value::js_get_string_pointer_unified(key_val) + as *const crate::StringHeader; + if key_ptr.is_null() { + continue; + } + let value = crate::object::js_object_get_field_by_name_f64( + crate::value::js_nanbox_get_pointer(source_f64) as *const ObjectHeader, + key_ptr, + ); + crate::process::js_setenv(key_ptr, value); + } + } + return target_f64; + } + let target_value = JSValue::from_bits(target_f64.to_bits()); if !target_value.is_pointer() { return target_f64; diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index c7f24195c8..d0447f660c 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -33,16 +33,17 @@ pub use ipc::*; // ── env_misc re-exports (preserve `crate::process::*` paths) ──────────────── pub use env_misc::{ - js_getenv, js_getenv_value, js_process_abort, js_process_active_resources_info, - js_process_add_uncaught_exception_capture_callback, js_process_available_memory, - js_process_binding, js_process_chdir_jsv, js_process_constrained_memory, js_process_cpu_usage, - js_process_debug_end, js_process_debug_process, js_process_dlopen, js_process_emit_warning, - js_process_env, js_process_execve, js_process_exit, js_process_exit_code_get, - js_process_exit_code_set, js_process_fatal_exception, js_process_get_active_handles, - js_process_get_active_requests, js_process_has_uncaught_exception_capture_callback, - js_process_internal_kill, js_process_linked_binding, js_process_load_env_file, - js_process_memory_usage, js_process_open_stdin, js_process_raw_debug, js_process_really_exit, - js_process_ref, js_process_resource_usage, js_process_set_title, + is_process_env_object, js_getenv, js_getenv_value, js_process_abort, + js_process_active_resources_info, js_process_add_uncaught_exception_capture_callback, + js_process_available_memory, js_process_binding, js_process_chdir_jsv, + js_process_constrained_memory, js_process_cpu_usage, js_process_debug_end, + js_process_debug_process, js_process_dlopen, js_process_emit_warning, js_process_env, + js_process_execve, js_process_exit, js_process_exit_code_get, js_process_exit_code_set, + js_process_fatal_exception, js_process_get_active_handles, js_process_get_active_requests, + js_process_has_uncaught_exception_capture_callback, js_process_internal_kill, + js_process_linked_binding, js_process_load_env_file, js_process_memory_usage, + js_process_open_stdin, js_process_raw_debug, js_process_really_exit, js_process_ref, + js_process_resource_usage, js_process_set_title, js_process_set_uncaught_exception_capture_callback, js_process_start_profiler_idle_notifier, js_process_stop_profiler_idle_notifier, js_process_thread_cpu_usage, js_process_tick_callback, js_process_title, js_process_umask, js_process_umask_set, js_process_unref, js_removeenv, diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 01759eda2e..1c47800545 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -883,6 +883,19 @@ pub extern "C" fn js_setenv(name_ptr: *const StringHeader, value: f64) { Err(_) => return, }; std::env::set_var(name, v_str); + // Keep the cached `process.env` object in step so enumeration + // (`Object.keys(process.env)`, `for…in`, spread) sees the new key — + // reads go through `js_getenv`, but enumeration walks this object. + let cached = CACHED_ENV.with(|c| c.get()); + if cached != 0.0 { + let obj = crate::value::js_nanbox_get_pointer(cached) as *mut crate::ObjectHeader; + if !obj.is_null() { + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + let val = js_string_from_bytes(v_str.as_ptr(), v_str.len() as u32); + let val_f64 = f64::from_bits(JSValue::string_ptr(val).bits()); + crate::object::js_object_set_field_by_name(obj, key, val_f64); + } + } } } @@ -980,11 +993,28 @@ pub extern "C" fn js_removeenv(name_ptr: *const StringHeader) { /// it straight to subsequent PropertyGet dispatch. #[no_mangle] pub extern "C" fn js_process_env() -> f64 { - use std::cell::Cell; + js_process_env_impl() +} + +thread_local! { + static CACHED_ENV: std::cell::Cell = const { std::cell::Cell::new(0.0) }; +} + +/// Is `value` the live `process.env` object? Writes to it must reach the real +/// environment (`js_setenv`), not just the cached field bag: `process.env.X` +/// READS lower to `js_getenv`, so a field-only store is invisible. +/// `Object.assign(process.env, parsed)` is how `@next/env` loads `.env` files — +/// under Perry the keys landed in the object and every read still returned +/// `undefined`, so a Next.js standalone server saw NONE of its `.env` config +/// (myairank: `process.env.DATABASE_URL` undefined ⇒ mysql2 connected with an +/// empty user/database and the MySQL handshake timed out). +pub fn is_process_env_object(value: f64) -> bool { + let cached = CACHED_ENV.with(|c| c.get()); + cached != 0.0 && cached.to_bits() == value.to_bits() +} + +fn js_process_env_impl() -> f64 { ipc::process_ipc_ensure_initialized(); - thread_local! { - static CACHED_ENV: Cell = const { Cell::new(0.0) }; - } let cached = CACHED_ENV.with(|c| c.get()); if cached != 0.0 { return cached; diff --git a/test-files/test_gap_object_assign_process_env.ts b/test-files/test_gap_object_assign_process_env.ts new file mode 100644 index 0000000000..137979af18 --- /dev/null +++ b/test-files/test_gap_object_assign_process_env.ts @@ -0,0 +1,38 @@ +// `Object.assign(process.env, parsed)` — how `@next/env` (and dotenv, and most +// config loaders) install a parsed `.env` file. `process.env` is not an ordinary +// object: reads go through the runtime's env lookup, so keys merged in by the +// generic object-assign path landed in a plain object that nothing ever consulted +// and every `process.env.X` read came back `undefined`. + +const parsed: Record = { + MY_APP_KEY: "abc123", + DATABASE_URL: "mysql://user:pw@localhost/db", +}; +Object.assign(process.env, parsed); + +console.log("direct read :", process.env.MY_APP_KEY); +console.log("bracket read :", process.env["DATABASE_URL"]); +console.log("in operator :", "MY_APP_KEY" in process.env); + +// a plain assignment must still work +process.env.SET_DIRECTLY = "yes"; +console.log("set directly :", process.env.SET_DIRECTLY); + +// assigning over an existing key +Object.assign(process.env, { MY_APP_KEY: "overwritten" }); +console.log("overwritten :", process.env.MY_APP_KEY); + +// a later read through a helper (not a direct member expression) +function readEnv(name: string): string | undefined { + return process.env[name]; +} +console.log("dynamic key :", readEnv("DATABASE_URL")); + +// multi-source assign +Object.assign(process.env, { A_ONE: "1" }, { A_TWO: "2" }); +console.log("multi-source :", process.env.A_ONE, process.env.A_TWO); + +// Object.assign onto an ordinary object must be unaffected +const plain: any = { a: 1 }; +const ret = Object.assign(plain, { b: 2 }, { c: 3 }); +console.log("plain object :", JSON.stringify(plain), ret === plain); From 264d9cd3dac4239da892715db8b469f9e2c075a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 19:41:41 +0200 Subject: [PATCH 2/2] fix(runtime): route the process.env assign at the write funnel, not the entry The env fast path sat at the top of js_object_assign_one and re-implemented source decoding. That got three things wrong, all reachable from the very call this PR exists to support -- Object.assign(process.env, parsed): * It cast ANY source pointer to `*const ObjectHeader`. A string or array source (`Object.assign(process.env, "abc")`) was then read through the wrong layout -- type confusion. * It enumerated the source with `js_object_keys_value`, which THROWS ToObject's TypeError on null/undefined. The spec says a nullish source is silently skipped, and node does; perry threw. * It fed arbitrary object keys to `js_setenv` -> `std::env::set_var`, which PANICS on a name that is empty or contains '=' or NUL. From an extern "C" frame that panic cannot unwind, so it ABORTED the process (SIGABRT). One malformed line in a .env file would have taken a Next.js server down -- exactly the workload this PR targets. Move the check to `object_assign_set_string_key`, the single write funnel all four call sites already go through. Every source shape now flows through the existing decoding (primitives, arrays, proxies, nullish), and only the write is redirected. Also validate the name in `js_setenv` and skip an unsettable one rather than abort -- node accepts such an assignment silently. Verified against node: the .env round-trip this PR fixes still works, and nullish / string / empty-key / '='-key sources are now byte-identical instead of throwing or aborting. Extended the gap test to cover all four. --- crates/perry-runtime/src/object/alloc.rs | 51 +++++++++---------- crates/perry-runtime/src/process.rs | 2 +- crates/perry-runtime/src/process/env_misc.rs | 25 +++++++++ .../test_gap_object_assign_process_env.ts | 24 +++++++++ 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 959c2dd3a4..a9d2c0fa7f 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1000,6 +1000,22 @@ unsafe fn object_assign_set_string_key( key_ptr: *const crate::StringHeader, value_f64: f64, ) { + // `Object.assign(process.env, parsed)` — how `@next/env` loads `.env` files. + // `process.env.X` READS lower to `js_getenv` (the real environment), so a + // field stored on the cached env object leaves every read `undefined`: a + // Next.js standalone server saw NONE of its `.env` config (myairank's + // `DATABASE_URL` vanished, mysql2 then connected with an empty user and the + // MySQL handshake timed out). Route the write through the env setter so it + // lands where the reads look. + // + // This hook lives at the single write funnel rather than as an early exit in + // `js_object_assign_one`, so every source shape still flows through the + // decoding below: a primitive/array/proxy source is enumerated correctly, + // and a nullish source is skipped per spec instead of throwing. + if !target_is_array && crate::process::is_process_env_ptr(target as usize) { + crate::process::js_setenv(key_ptr, value_f64); + return; + } if target_is_array { // Routes integer-index keys to array element-set (extending length); // non-numeric keys fall back to the object setter. @@ -1112,34 +1128,13 @@ unsafe fn object_assign_proxy_source( pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) -> f64 { let target_f64 = js_object_assign_validate_target(target_f64); - // `Object.assign(process.env, parsed)` — how `@next/env` loads `.env` - // files. `process.env.X` READS lower to `js_getenv` (the real environment), - // so copying fields into the cached env object leaves every read - // `undefined`: a Next.js standalone server saw NONE of its `.env` config - // (myairank's `DATABASE_URL` vanished, mysql2 then connected with an empty - // user/database and the MySQL handshake timed out). Route each key through - // the env setter so the write lands where the reads look. - if crate::process::is_process_env_object(target_f64) { - let keys = crate::object::js_object_keys_value(source_f64); - if !keys.is_null() { - let len = crate::array::js_array_length(keys); - for i in 0..len { - let key_val = crate::array::js_array_get_f64(keys, i); - let key_ptr = crate::value::js_get_string_pointer_unified(key_val) - as *const crate::StringHeader; - if key_ptr.is_null() { - continue; - } - let value = crate::object::js_object_get_field_by_name_f64( - crate::value::js_nanbox_get_pointer(source_f64) as *const ObjectHeader, - key_ptr, - ); - crate::process::js_setenv(key_ptr, value); - } - } - return target_f64; - } - + // NOTE: a `process.env` target is handled in `object_assign_set_string_key` + // (the single write funnel) rather than here. An early exit at this point + // would have to re-implement source decoding, and the version that did got + // all three edge cases wrong: it cast any source pointer to `ObjectHeader` + // (type confusion on a string/array source) and it enumerated the source + // with `js_object_keys_value`, which *throws* on `null`/`undefined` instead + // of skipping it as the spec requires. let target_value = JSValue::from_bits(target_f64.to_bits()); if !target_value.is_pointer() { return target_f64; diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index d0447f660c..fa80b0c35f 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -33,7 +33,7 @@ pub use ipc::*; // ── env_misc re-exports (preserve `crate::process::*` paths) ──────────────── pub use env_misc::{ - is_process_env_object, js_getenv, js_getenv_value, js_process_abort, + is_process_env_object, is_process_env_ptr, js_getenv, js_getenv_value, js_process_abort, js_process_active_resources_info, js_process_add_uncaught_exception_capture_callback, js_process_available_memory, js_process_binding, js_process_chdir_jsv, js_process_constrained_memory, js_process_cpu_usage, js_process_debug_end, diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index 1c47800545..cbf0cfca5c 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -861,6 +861,9 @@ pub extern "C" fn js_setenv(name_ptr: *const StringHeader, value: f64) { Ok(s) => s, Err(_) => return, }; + if !env_name_is_settable(name) { + return; + } // Coerce value to string. js_jsvalue_to_string handles // numbers/booleans/null/undefined and returns a *mut StringHeader. @@ -1013,6 +1016,28 @@ pub fn is_process_env_object(value: f64) -> bool { cached != 0.0 && cached.to_bits() == value.to_bits() } +/// True when `addr` is the heap address of the cached `process.env` object. +/// +/// The pointer form of [`is_process_env_object`], for call sites that have +/// already unboxed the target (`Object.assign`'s write funnel). +pub fn is_process_env_ptr(addr: usize) -> bool { + let cached = CACHED_ENV.with(|c| c.get()); + if cached == 0.0 { + return false; + } + crate::value::js_nanbox_get_pointer(cached) as usize == addr +} + +/// `std::env::set_var` PANICS — and, being called from an `extern "C"` frame, +/// aborts the process — when the name is empty, contains `=`, or contains a NUL +/// byte. `Object.assign(process.env, parsed)` feeds it arbitrary object keys, so +/// a single malformed key in a `.env` file would take the whole server down. +/// Node accepts such an assignment silently, so skip these names rather than +/// crash. +fn env_name_is_settable(name: &str) -> bool { + !name.is_empty() && !name.contains('=') && !name.contains('\0') +} + fn js_process_env_impl() -> f64 { ipc::process_ipc_ensure_initialized(); let cached = CACHED_ENV.with(|c| c.get()); diff --git a/test-files/test_gap_object_assign_process_env.ts b/test-files/test_gap_object_assign_process_env.ts index 137979af18..af49e261f4 100644 --- a/test-files/test_gap_object_assign_process_env.ts +++ b/test-files/test_gap_object_assign_process_env.ts @@ -36,3 +36,27 @@ console.log("multi-source :", process.env.A_ONE, process.env.A_TWO); const plain: any = { a: 1 }; const ret = Object.assign(plain, { b: 2 }, { c: 3 }); console.log("plain object :", JSON.stringify(plain), ret === plain); + +// A `process.env` target must not make Object.assign lose the spec's handling +// of odd sources. The first implementation special-cased the env target at the +// TOP of js_object_assign_one and re-implemented source decoding there, which +// got all three of these wrong. + +// Nullish sources are SKIPPED, not an error (the env fast path enumerated them +// with js_object_keys_value, which throws ToObject's TypeError). +Object.assign(process.env, null); +Object.assign(process.env, undefined); +console.log("nullish source :", "ok"); + +// A primitive source exposes index keys. The fast path cast ANY source pointer +// to an ObjectHeader, so a string source was read through the wrong layout. +Object.assign(process.env, "ab"); +console.log("string source :", "ok"); + +// std::env::set_var PANICS (and, from an extern "C" frame, ABORTS the process) +// on a name that is empty or contains '=' or NUL. `Object.assign(process.env, +// parsed)` feeds it arbitrary keys, so one malformed line in a .env file used +// to take the whole server down. Node accepts these silently. +Object.assign(process.env, { "": "empty" }); +Object.assign(process.env, { "A=B": "equals" }); +console.log("odd env keys :", "ok");