Skip to content
Open
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
1 change: 1 addition & 0 deletions ext/node/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ deno_core::extension!(deno_node,
ops::ipc::op_node_ipc_buffer_constructor,
ops::ipc::op_node_ipc_ref,
ops::ipc::op_node_ipc_unref,
ops::process::op_node_process_set_title,
ops::process::op_node_process_kill,
ops::process::op_node_process_setegid,
ops::process::op_node_process_seteuid,
Expand Down
163 changes: 163 additions & 0 deletions ext/node/ops/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,169 @@ use nix::unistd::User;

use crate::ExtNodeSys;

// --- process.title support ---
//
// The argv buffer overwrite technique used here is the standard approach for
// setting the process title visible in `ps`. This is the same technique used by
// Node.js (via libuv's uv_setup_args/uv_set_process_title), nginx, PostgreSQL,
// and many other programs. The OS allocates argv as a contiguous buffer; we
// save its bounds at startup, then overwrite it with the new title.
//
// References:
// - libuv: https://github.com/libuv/libuv/blob/v1.x/src/unix/proctitle.c
// - Node.js: uses uv_setup_args() in node_main.cc, uv_set_process_title() in node.cc

#[cfg(target_os = "linux")]
mod argv_store {
use std::ffi::c_char;
use std::ffi::c_int;
use std::sync::Once;

static mut ARGV_PTR: *mut *mut c_char = std::ptr::null_mut();
static mut ARGV_BUF_SIZE: usize = 0;
static INIT: Once = Once::new();

/// # Safety
/// Called from `.init_array` before main. Must only store the pointers.
pub unsafe fn save(argc: c_int, argv: *mut *mut c_char) {
INIT.call_once(|| {
if argv.is_null() || argc <= 0 {
return;
}
let argc = argc as usize;
// SAFETY: argv is valid and has argc entries (guaranteed by the OS loader).
unsafe {
// Calculate the contiguous buffer size from argv[0] to end of argv[argc-1]
let start = (*argv) as *const u8;
let last_arg = *argv.add(argc - 1);
let last_arg_len = libc::strlen(last_arg);
let end = last_arg.add(last_arg_len + 1) as *const u8;
let buf_size = end.offset_from(start) as usize;

ARGV_PTR = argv;
ARGV_BUF_SIZE = buf_size;
}
});
}

/// # Safety
/// The stored argv pointer must still be valid (it always is for the process lifetime).
pub unsafe fn overwrite(title: &str) {
// SAFETY: ARGV_PTR and ARGV_BUF_SIZE are set once in save() and remain
// valid for the process lifetime. The buffer at *ARGV_PTR is the original
// argv[0] area allocated by the OS.
unsafe {
if ARGV_PTR.is_null() || ARGV_BUF_SIZE == 0 {
return;
}
let buf =
std::slice::from_raw_parts_mut(*ARGV_PTR as *mut u8, ARGV_BUF_SIZE);
let title_bytes = title.as_bytes();
let copy_len = title_bytes.len().min(ARGV_BUF_SIZE - 1);
buf[..copy_len].copy_from_slice(&title_bytes[..copy_len]);
buf[copy_len..].fill(0);
}
}
}

#[cfg(target_os = "linux")]
#[used]
#[unsafe(link_section = ".init_array")]
static ARGV_INIT: unsafe extern "C" fn(
libc::c_int,
*mut *mut libc::c_char,
*mut *mut libc::c_char,
) = {
unsafe extern "C" fn init(
argc: libc::c_int,
argv: *mut *mut libc::c_char,
_envp: *mut *mut libc::c_char,
) {
// SAFETY: argc and argv are provided by the OS at process init and are valid.
unsafe { argv_store::save(argc, argv) };
}
init
};

#[cfg(target_os = "macos")]
fn set_process_title(title: &str) {
// SAFETY: We call macOS-specific C functions to read and overwrite the
// process argv buffer in place. The argv pointer and argc count come from
// the OS and are valid for the lifetime of the process. We bounds-check
// before writing and null-terminate the buffer.
unsafe {
unsafe extern "C" {
fn _NSGetArgc() -> *mut libc::c_int;
fn _NSGetArgv() -> *mut *mut *mut libc::c_char;
}

let argc = *_NSGetArgc() as usize;
let argv = *_NSGetArgv();
if argv.is_null() || argc == 0 {
return;
}

// Calculate contiguous buffer size from argv[0] through argv[argc-1]
let start = *argv as *const u8;
let last_arg = *argv.add(argc - 1);
let last_arg_len = libc::strlen(last_arg);
let end = last_arg.add(last_arg_len + 1) as *const u8;
let buf_size = end.offset_from(start) as usize;

// Overwrite argv[0] buffer with the new title
let buf = std::slice::from_raw_parts_mut(*argv as *mut u8, buf_size);
let title_bytes = title.as_bytes();
let copy_len = title_bytes.len().min(buf_size - 1);
buf[..copy_len].copy_from_slice(&title_bytes[..copy_len]);
buf[copy_len..].fill(0);

// Also set the pthread name (visible in Activity Monitor / debugger, 63 char limit)
let c_title =
std::ffi::CString::new(&title.as_bytes()[..title.len().min(63)]);
if let Ok(c_title) = c_title {
libc::pthread_setname_np(c_title.as_ptr());
}
}
}

#[cfg(target_os = "linux")]
fn set_process_title(title: &str) {
// SAFETY: We overwrite the saved argv buffer with the new title via
// argv_store, then call prctl to set the kernel thread name.
unsafe {
argv_store::overwrite(title);

// Also set the kernel thread name via prctl (15 char limit, visible in /proc/self/comm)
let truncated = &title.as_bytes()[..title.len().min(15)];
if let Ok(c_title) = std::ffi::CString::new(truncated) {
libc::prctl(libc::PR_SET_NAME, c_title.as_ptr() as libc::c_ulong);
}
}
}

#[cfg(target_os = "windows")]
fn set_process_title(title: &str) {
let wide: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: FFI call, wide is null-terminated
unsafe {
winapi::um::wincon::SetConsoleTitleW(wide.as_ptr());
}
}

#[cfg(not(any(
target_os = "macos",
target_os = "linux",
target_os = "windows"
)))]
fn set_process_title(_title: &str) {
// No-op on unsupported platforms
}

#[op2(fast)]
pub fn op_node_process_set_title(#[string] title: &str) {
set_process_title(title);
}

#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum ProcessError {
#[class(inherit)]
Expand Down
47 changes: 42 additions & 5 deletions ext/node/polyfills/internal_binding/node_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,66 @@ import { primordials } from "ext:core/mod.js";
const {
SafeMap,
ArrayPrototypeForEach,
SafeRegExp,
StringPrototypeSplit,
ArrayPrototypePush,
StringPrototypeSlice,
StringPrototypeStartsWith,
} = primordials;

// This module ports:
// - https://github.com/nodejs/node/blob/master/src/node_options-inl.h
// - https://github.com/nodejs/node/blob/master/src/node_options.cc
// - https://github.com/nodejs/node/blob/master/src/node_options.h

// Quote-aware tokenizer for NODE_OPTIONS. Node.js uses a shell-like parser
// that respects single and double quotes, so `--title="hello world"` is a
// single token whose value is `hello world`, not two tokens.
function splitNodeOptions(input: string): string[] {
const args: string[] = [];
let current = "";
let inDouble = false;
let inSingle = false;

for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
} else if (ch === "'" && !inDouble) {
inSingle = !inSingle;
} else if (
(ch === " " || ch === "\t" || ch === "\n" || ch === "\r") && !inDouble &&
!inSingle
) {
if (current.length > 0) {
ArrayPrototypePush(args, current);
current = "";
}
} else {
current += ch;
}
}
if (current.length > 0) {
ArrayPrototypePush(args, current);
}
return args;
}

/** Gets the all options for Node.js
* This function is expensive to execute. `getOptionValue` in `internal/options.ts`
* should be used instead to get a specific option. */
export function getOptions() {
const options = new SafeMap([
["--warnings", { value: true }],
["--pending-deprecation", { value: false }],
["--title", { value: "" }],
]);

const nodeOptions = Deno.env.get("NODE_OPTIONS");
const args = nodeOptions
? StringPrototypeSplit(nodeOptions, new SafeRegExp("\\s"))
: [];
const args = nodeOptions ? splitNodeOptions(nodeOptions) : [];
ArrayPrototypeForEach(args, (arg) => {
if (StringPrototypeStartsWith(arg, "--title=")) {
options.set("--title", { value: StringPrototypeSlice(arg, 8) });
return;
}
switch (arg) {
case "--no-warnings":
options.set("--warnings", { value: false });
Expand Down
19 changes: 14 additions & 5 deletions ext/node/polyfills/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
op_node_load_env_file,
op_node_process_constrained_memory,
op_node_process_kill,
op_node_process_set_title,
op_node_process_setegid,
op_node_process_seteuid,
op_node_process_setgid,
Expand Down Expand Up @@ -620,14 +621,17 @@ Object.defineProperty(process, "report", {
},
});

let processTitle: string | undefined;
Object.defineProperty(process, "title", {
get() {
return "deno";
if (processTitle == null) {
return String(execPath);
}
return processTitle;
},
set(_value) {
// NOTE(bartlomieju): this is a noop. Node.js doesn't guarantee that the
// process name will be properly set and visible from other tools anyway.
// Might revisit in the future.
set(value) {
processTitle = `${value}`;
op_node_process_set_title(processTitle);
},
});

Expand Down Expand Up @@ -1170,6 +1174,11 @@ internals.__bootstrapNodeProcess = function (
execPath = Deno.execPath();
initializeDebugEnv(nodeDebug);

const title = getOptionValue("--title");
if (title) {
process.title = title;
}

if (getOptionValue("--warnings")) {
process.on("warning", onWarning);
}
Expand Down
3 changes: 3 additions & 0 deletions tests/node_compat/config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,7 @@
"parallel/test-process-no-deprecation.js": {},
"parallel/test-process-ppid.js": {},
"parallel/test-process-really-exit.js": {},
"parallel/test-process-title-cli.js": {},
"parallel/test-process-release.js": {},
"parallel/test-process-umask-mask.js": {},
"parallel/test-process-umask.js": {},
Expand Down Expand Up @@ -1729,6 +1730,7 @@
"parallel/test-runner-subtest-after-hook.js": {},
// TODO(bartlomieju): disabled during work on `node:inspector`, this test didn't actualy run before
// "parallel/test-set-process-debug-port.js": {},
"parallel/test-setproctitle.js": {},
"parallel/test-signal-handler-remove-on-exit.js": {},
"parallel/test-signal-handler.js": {},
"parallel/test-snapshot-api.js": {
Expand Down Expand Up @@ -2545,6 +2547,7 @@
"sequential/test-net-GH-5504.js": {},
"sequential/test-net-response-size.js": {},
"sequential/test-net-server-bind.js": {},
"sequential/test-process-title.js": {},
"sequential/test-require-cache-without-stat.js": {},
"sequential/test-resolution-inspect-brk.js": {},
// TODO(bartlomieju): disabled during work on `node:inspector`, this test didn't actualy run before
Expand Down
3 changes: 3 additions & 0 deletions tests/node_compat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ fn parse_flags(source: &str) -> (Vec<String>, Vec<String>) {
"--allow-natives-syntax" => {
v8_flags.push("--allow-natives-syntax".to_string());
}
f if f.starts_with("--title=") => {
node_options.push(f.to_string());
}
_ => {}
}
}
Expand Down
26 changes: 26 additions & 0 deletions tests/specs/node/process_title/__test__.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"tests": {
"default_title_is_exec_path": {
"args": "run -A main.ts default",
"output": "default.out"
},
"set_title": {
"args": "run -A main.ts set",
"output": "set.out"
},
"node_options_title": {
"envs": {
"NODE_OPTIONS": "--title=custom-title"
},
"args": "run -A main.ts node_options",
"output": "node_options.out"
},
"node_options_title_quoted_spaces": {
"envs": {
"NODE_OPTIONS": "--title=\"hello world\""
},
"args": "run -A main.ts node_options",
"output": "node_options_quoted.out"
}
}
}
1 change: 1 addition & 0 deletions tests/specs/node/process_title/default.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PASS
27 changes: 27 additions & 0 deletions tests/specs/node/process_title/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import process from "node:process";

const mode = Deno.args[0];

switch (mode) {
case "default":
// Default title should be the execPath
console.log(
process.title === process.execPath
? "PASS"
: `FAIL: expected execPath, got ${process.title}`,
);
break;
case "set":
// Setting title should work
process.title = "my-custom-title";
console.log(
process.title === "my-custom-title"
? "PASS"
: `FAIL: expected my-custom-title, got ${process.title}`,
);
break;
case "node_options":
// Title should come from NODE_OPTIONS --title=...
console.log(`title=${process.title}`);
break;
}
1 change: 1 addition & 0 deletions tests/specs/node/process_title/node_options.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
title=custom-title
1 change: 1 addition & 0 deletions tests/specs/node/process_title/node_options_quoted.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
title=hello world
1 change: 1 addition & 0 deletions tests/specs/node/process_title/set.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PASS
Loading
Loading