Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/perry-runtime/src/object/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1112,6 +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);

// 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;
Expand Down
21 changes: 11 additions & 10 deletions crates/perry-runtime/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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,
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,
Expand Down
63 changes: 59 additions & 4 deletions crates/perry-runtime/src/process/env_misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -883,6 +886,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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -980,11 +996,50 @@ 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;
ipc::process_ipc_ensure_initialized();
thread_local! {
static CACHED_ENV: Cell<f64> = const { Cell::new(0.0) };
js_process_env_impl()
}

thread_local! {
static CACHED_ENV: std::cell::Cell<f64> = 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()
}

/// 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());
if cached != 0.0 {
return cached;
Expand Down
62 changes: 62 additions & 0 deletions test-files/test_gap_object_assign_process_env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// `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<string, string> = {
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);

// 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");
Loading