From be736786b3fbbc14a410c018db6be10aa66a3747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 23 May 2026 13:30:40 +0200 Subject: [PATCH] feat(process): make process.title settable (#1401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #1346 `process.title` returned argv[0] as a read-only string, but the issue noted the setter path was missing — `process.title = X` silently fell through to the GlobalGet sentinel, so subsequent reads still returned argv[0] and the test for "settable" failed. Wire it up: - New `Expr::ProcessTitle` HIR variant (getter) and `Expr::ProcessSetTitle(Box)` (setter). expr_member.rs routes `process.title` reads (bare + via globalThis) to the getter; expr_assign.rs detects the `process.title = X` shape on the LHS and lowers to the setter. - Codegen: getter calls `js_process_title`, setter calls `js_process_set_title(value)`. type_analysis tags `Expr::ProcessTitle` as `Type::String` so chained `.startsWith` / template-literal use takes the fast path. - Runtime: thread-local `PROCESS_TITLE` cell stores the assigned value; the getter returns it (falling back to argv[0] when unset). On Linux the setter also calls `prctl(PR_SET_NAME)` so `/proc//comm` updates. macOS has no per-process equivalent — Node has the same best-effort behavior; the assignment is still observable via reads. - Parity case `node-suite/process/title/title-set` (pre-existing, from #1346 bring-up) now passes. --- crates/perry-codegen-js/src/emit/exprs.rs | 8 +++ .../src/emit/expr/url_process_path.rs | 4 +- .../src/collectors/escape_objects.rs | 1 + crates/perry-codegen/src/expr/misc_methods.rs | 9 ++++ crates/perry-codegen/src/expr/mod.rs | 2 + .../src/runtime_decls/strings.rs | 2 + crates/perry-codegen/src/type_analysis.rs | 3 ++ crates/perry-hir/src/audit.rs | 2 + crates/perry-hir/src/ir/expr.rs | 8 +-- crates/perry-hir/src/lower/expr_assign.rs | 12 +++++ crates/perry-hir/src/lower/expr_member.rs | 10 +++- .../src/monomorph/substitute_expr.rs | 4 ++ crates/perry-hir/src/stable_hash/expr.rs | 2 + crates/perry-hir/src/walker/expr_mut.rs | 2 + crates/perry-hir/src/walker/expr_ref.rs | 2 + crates/perry-runtime/src/process.rs | 53 +++++++++++++++++++ 16 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/perry-codegen-js/src/emit/exprs.rs b/crates/perry-codegen-js/src/emit/exprs.rs index fadf973042..4faa0ca249 100644 --- a/crates/perry-codegen-js/src/emit/exprs.rs +++ b/crates/perry-codegen-js/src/emit/exprs.rs @@ -543,6 +543,14 @@ impl JsEmitter { } self.output.push_str(") : [0, 0])"); } + Expr::ProcessTitle => { + self.output.push_str("(typeof process !== 'undefined' ? process.title : '')"); + } + Expr::ProcessSetTitle(value) => { + self.output.push_str("(typeof process !== 'undefined' ? (process.title = "); + self.emit_expr(value); + self.output.push_str(") : undefined)"); + } Expr::ProcessNextTick { callback, args } => { self.output.push_str("(typeof process !== 'undefined' ? process.nextTick("); self.emit_expr(callback); diff --git a/crates/perry-codegen-wasm/src/emit/expr/url_process_path.rs b/crates/perry-codegen-wasm/src/emit/expr/url_process_path.rs index 84f6540a70..b04c82d80f 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/url_process_path.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/url_process_path.rs @@ -103,6 +103,7 @@ impl<'a> FuncEmitCtx<'a> { | Expr::ProcessVersions | Expr::ProcessHrtimeBigint | Expr::ProcessHrtime(_) + | Expr::ProcessTitle | Expr::ProcessStdin | Expr::ProcessStdout | Expr::ProcessStderr @@ -129,7 +130,8 @@ impl<'a> FuncEmitCtx<'a> { | Expr::ProcessAbort | Expr::ProcessUmask(_) | Expr::ProcessEmitWarning(_) - | Expr::ProcessCpuUsage(_) => { + | Expr::ProcessCpuUsage(_) + | Expr::ProcessSetTitle(_) => { func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); } Expr::EnvGet(_) | Expr::EnvGetDynamic(_) => { diff --git a/crates/perry-codegen/src/collectors/escape_objects.rs b/crates/perry-codegen/src/collectors/escape_objects.rs index f0b5a05036..1527ed00aa 100644 --- a/crates/perry-codegen/src/collectors/escape_objects.rs +++ b/crates/perry-codegen/src/collectors/escape_objects.rs @@ -436,6 +436,7 @@ pub fn check_object_literal_escapes_in_expr( | Expr::ProcessPid | Expr::ProcessPpid | Expr::ProcessVersion | Expr::ProcessVersions | Expr::ProcessHrtimeBigint | Expr::ProcessHrtime(_) + | Expr::ProcessTitle | Expr::ProcessStdin | Expr::ProcessStdout | Expr::ProcessStderr | Expr::ProcessEnv | Expr::GlobalThisExpr diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index d53add522b..53f27df12c 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -129,6 +129,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .call(DOUBLE, "js_process_hrtime", &[(DOUBLE, &prior_val)])) } + // -------- process.title getter/setter (#1401) -------- + Expr::ProcessTitle => Ok(ctx.block().call(DOUBLE, "js_process_title", &[])), + Expr::ProcessSetTitle(value) => { + let v = lower_expr(ctx, value)?; + ctx.block() + .call_void("js_process_set_title", &[(DOUBLE, &v)]); + Ok(v) + } + // -------- RegExpExecIndex — reads thread-local from the last exec() call -------- Expr::RegExpExecIndex => Ok(ctx.block().call(DOUBLE, "js_regexp_exec_get_index", &[])), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 82ffec2dd2..b6a32514f4 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1044,6 +1044,8 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::PathIsAbsolute(..) | Expr::ProcessHrtimeBigint | Expr::ProcessHrtime(..) + | Expr::ProcessTitle + | Expr::ProcessSetTitle(..) | Expr::RegExpExecIndex | Expr::CryptoRandomUUID | Expr::CryptoRandomBytes(..) diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 9770de09c7..3a52ddc8e0 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -470,6 +470,8 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_process_env", DOUBLE, &[]); module.declare_function("js_process_hrtime_bigint", DOUBLE, &[]); module.declare_function("js_process_hrtime", DOUBLE, &[DOUBLE]); + module.declare_function("js_process_title", DOUBLE, &[]); + module.declare_function("js_process_set_title", VOID, &[DOUBLE]); module.declare_function("js_process_chdir", VOID, &[I64]); module.declare_function("js_process_kill", VOID, &[DOUBLE, DOUBLE]); module.declare_function("js_process_exit", VOID, &[DOUBLE]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 30f609b33c..281c27b341 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -127,6 +127,7 @@ pub(crate) fn refine_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option, e: &Expr) -> bool { } | Expr::ProcessVersion | Expr::ProcessCwd + | Expr::ProcessTitle | Expr::OsArch | Expr::OsType | Expr::OsPlatform @@ -1004,6 +1006,7 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // through to the generic native method dispatch and returns undefined. Expr::ProcessVersion | Expr::ProcessCwd + | Expr::ProcessTitle | Expr::OsArch | Expr::OsType | Expr::OsPlatform diff --git a/crates/perry-hir/src/audit.rs b/crates/perry-hir/src/audit.rs index dcc73381d3..fd748aba29 100644 --- a/crates/perry-hir/src/audit.rs +++ b/crates/perry-hir/src/audit.rs @@ -285,6 +285,8 @@ fn specialized_stdlib_call(expr: &Expr) -> Option<(&'static str, &'static str)> Expr::ProcessResourceUsage => ("process", "resourceUsage"), Expr::ProcessActiveResourcesInfo => ("process", "getActiveResourcesInfo"), Expr::ProcessHrtime(_) => ("process", "hrtime"), + Expr::ProcessTitle => ("process", "title"), + Expr::ProcessSetTitle(_) => ("process", "title"), Expr::ProcessStdinIsTTY => ("process", "stdin.isTTY"), Expr::ProcessStdoutIsTTY => ("process", "stdout.isTTY"), Expr::ProcessStderrIsTTY => ("process", "stderr.isTTY"), diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index c0a1a1124a..599dd3e8c1 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -469,10 +469,10 @@ pub enum Expr { ProcessCpuUsage(Option>), // process.cpuUsage(prior?) -> { user, system } µs (diff if prior given) ProcessResourceUsage, // process.resourceUsage() -> {userCPUTime, maxRSS, ...} (#1376) ProcessActiveResourcesInfo, // process.getActiveResourcesInfo() -> string[] (#1376) - // process.stdin -> stub object { write: fn } - ProcessStdin, - // process.stdout -> stub object { write: fn } - ProcessStdout, + ProcessTitle, // process.title getter (#1401) + ProcessSetTitle(Box), // process.title = X setter (#1401) + ProcessStdin, // process.stdin -> stub object { write: fn } + ProcessStdout, // process.stdout -> stub object { write: fn } // process.stderr -> stub object { write: fn } ProcessStderr, // process.stdin.setRawMode(enabled) -> stdin (#347 Phase 2) diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 04a0f5a247..73c5ee111f 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -626,6 +626,18 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) proto: value, }); } + // #1401: process.title = X — route through a runtime + // cell so subsequent reads see the new value. Without + // this, the assignment lands on the GlobalGet sentinel + // that the title getter never consults, so reads still + // return argv[0]. + if property == "title" { + if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() { + if obj_ident.sym.as_ref() == "process" { + return Ok(Expr::ProcessSetTitle(value)); + } + } + } Ok(Expr::PropertySet { object, property, diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index c3c633d2b1..ef3585c3c8 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -234,12 +234,17 @@ pub(super) fn lower_member(ctx: &mut LoweringContext, member: &ast::MemberExpr) // to argv[0] too until something assigns `.title`). // Settable `process.title` is tracked separately // (#1401); the shape-only read is what closes #1346. - "argv0" | "execPath" | "title" => { + "argv0" | "execPath" => { return Ok(Expr::IndexGet { object: Box::new(Expr::ProcessArgv), index: Box::new(Expr::Number(0.0)), }); } + "title" => { + // #1401: title is settable; route through a + // runtime cell that falls back to argv[0]. + return Ok(Expr::ProcessTitle); + } _ => {} } } @@ -326,12 +331,13 @@ pub(super) fn lower_member(ctx: &mut LoweringContext, member: &ast::MemberExpr) } "allowedNodeEnvironmentFlags" => return Ok(Expr::SetNew), "report" => return Ok(process_report_literal()), - "argv0" | "execPath" | "title" => { + "argv0" | "execPath" => { return Ok(Expr::IndexGet { object: Box::new(Expr::ProcessArgv), index: Box::new(Expr::Number(0.0)), }); } + "title" => return Ok(Expr::ProcessTitle), _ => {} } } diff --git a/crates/perry-hir/src/monomorph/substitute_expr.rs b/crates/perry-hir/src/monomorph/substitute_expr.rs index 9767598261..70391f8ac5 100644 --- a/crates/perry-hir/src/monomorph/substitute_expr.rs +++ b/crates/perry-hir/src/monomorph/substitute_expr.rs @@ -305,6 +305,10 @@ pub(crate) fn substitute_expr(expr: &Expr, substitutions: &HashMap .as_ref() .map(|e| Box::new(substitute_expr(e, substitutions))), ), + Expr::ProcessTitle => Expr::ProcessTitle, + Expr::ProcessSetTitle(v) => { + Expr::ProcessSetTitle(Box::new(substitute_expr(v, substitutions))) + } // File system Expr::FsReadFileSync(path) => { diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index e4666a0114..df7c403295 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -98,6 +98,8 @@ impl SH for Expr { Expr::ProcessResourceUsage => tag(h, 11232), Expr::ProcessActiveResourcesInfo => tag(h, 11233), Expr::ProcessHrtime(e) => { tag(h, 11234); e.hash(h); } + Expr::ProcessTitle => tag(h, 11235), + Expr::ProcessSetTitle(e) => { tag(h, 11236); e.as_ref().hash(h); } Expr::ProcessStdin => tag(h, 70), Expr::ProcessStdout => tag(h, 71), Expr::ProcessStderr => tag(h, 72), diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index 793a6ecdeb..9ff203dc24 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -50,6 +50,7 @@ where | Expr::ProcessPosixCredential(_) | Expr::ProcessResourceUsage | Expr::ProcessActiveResourcesInfo + | Expr::ProcessTitle | Expr::ProcessStdinIsTTY | Expr::ProcessStdoutIsTTY | Expr::ProcessStderrIsTTY @@ -1068,6 +1069,7 @@ where f(v); } } + Expr::ProcessSetTitle(v) => f(v), // ─── Child process ─────────────────────────────────────────────── Expr::ChildProcessExecSync { command, options } => { diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 4710bf2a67..f2a30897ba 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -51,6 +51,7 @@ where | Expr::ProcessPosixCredential(_) | Expr::ProcessResourceUsage | Expr::ProcessActiveResourcesInfo + | Expr::ProcessTitle | Expr::ProcessStdinIsTTY | Expr::ProcessStdoutIsTTY | Expr::ProcessStderrIsTTY @@ -1049,6 +1050,7 @@ where f(v); } } + Expr::ProcessSetTitle(v) => f(v), Expr::ChildProcessExecSync { command, options } => { f(command); if let Some(o) = options { diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 93e66f2b9b..d3557e1b9b 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -47,6 +47,59 @@ pub extern "C" fn js_process_abort() { std::process::abort(); } +/// Thread-local cell holding the process title set via `process.title = X` +/// (#1401). `None` means "not assigned yet, fall back to argv[0]". The +/// setter records the value here; on Linux it also calls `prctl(PR_SET_NAME)` +/// so `/proc//comm` reflects the new value. macOS has no per-process +/// analog — the assignment is still observable via subsequent `process.title` +/// reads, matching Node's best-effort semantics. +thread_local! { + static PROCESS_TITLE: std::cell::RefCell> = const { + std::cell::RefCell::new(None) + }; +} + +/// process.title -> string. Returns the value set via the setter, or +/// falls back to argv[0]. +#[no_mangle] +pub extern "C" fn js_process_title() -> f64 { + use crate::value::JSValue; + let stored: Option = PROCESS_TITLE.with(|c| c.borrow().clone()); + let s = stored.unwrap_or_else(|| std::env::args().next().unwrap_or_default()); + let bytes = s.as_bytes(); + let ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + f64::from_bits(JSValue::string_ptr(ptr).bits()) +} + +/// process.title = value — coerces to string and stores in the cell. +#[no_mangle] +pub extern "C" fn js_process_set_title(value: f64) { + let ptr = crate::value::js_jsvalue_to_string(value); + let s = if ptr.is_null() { + String::new() + } else { + unsafe { + let header = &*ptr; + let len = header.byte_len as usize; + let data = (ptr as *const u8).add(std::mem::size_of::()); + String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned() + } + }; + #[cfg(target_os = "linux")] + { + let mut buf = [0i8; 16]; + let src = s.as_bytes(); + let copy_len = std::cmp::min(src.len(), 15); + for i in 0..copy_len { + buf[i] = src[i] as i8; + } + unsafe { + libc::prctl(libc::PR_SET_NAME, buf.as_ptr() as libc::c_ulong, 0, 0, 0); + } + } + PROCESS_TITLE.with(|c| *c.borrow_mut() = Some(s)); +} + /// process.umask() -> number. Returns the current file-mode creation mask /// without modifying it. POSIX's `umask` syscall has no read-only form, so /// we set the mask to 0, capture the previous value, then restore it.