From 8c2afa5910fa5b640bbf0f784ab3140faa912272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 13:27:38 +0200 Subject: [PATCH 1/2] feat(bun): implement spawn and Terminal --- .../perry-api-manifest/src/entries/part_4.rs | 2 + .../src/lower_call/native_table/bun.rs | 18 + crates/perry-hir/src/lower/expr_new.rs | 16 + crates/perry-hir/src/lower/expr_new/member.rs | 15 +- crates/perry-runtime/src/bun_compat/mod.rs | 2 + crates/perry-runtime/src/bun_compat/spawn.rs | 1096 +++++++++++++++++ .../src/child_process/builder.rs | 6 +- .../src/child_process/emitter.rs | 21 + .../perry-runtime/src/child_process/fork.rs | 4 +- crates/perry-runtime/src/child_process/mod.rs | 7 +- .../src/child_process/reactor.rs | 67 +- .../src/child_process/reactor/integration.rs | 62 + .../src/node_submodules/consumers.rs | 20 + .../perry-runtime/src/node_submodules/mod.rs | 1 + .../callable_export_arity_table.rs | 12 +- .../native_module/callable_export_check.rs | 10 +- .../native_module/callable_export_table.rs | 2 + .../src/object/native_module/module_keys.rs | 2 + .../native_module_dispatch/dispatch_a_c.rs | 2 + crates/perry-runtime/src/pty/mod.rs | 4 +- crates/perry-runtime/src/pty/native.rs | 18 + crates/perry-runtime/src/pty/reactor.rs | 57 +- crates/perry/tests/issue_9601_bun_spawn.rs | 216 ++++ docs/src/cli/flags.md | 6 + scripts/gc_runtime_root_holders.json | 18 + 25 files changed, 1617 insertions(+), 67 deletions(-) create mode 100644 crates/perry-runtime/src/bun_compat/spawn.rs create mode 100644 crates/perry-runtime/src/child_process/reactor/integration.rs create mode 100644 crates/perry/tests/issue_9601_bun_spawn.rs diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 0daeb23e82..88925bf842 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1104,6 +1104,8 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("bun", "hash", false, None), method("bun", "file", false, None), method("bun", "write", false, None), + method("bun", "spawn", false, None), + method("bun", "Terminal", false, None), method("bun", "pathToFileURL", false, None), method("bun", "fileURLToPath", false, None), // #8537 — OpenCode compatibility coverage added these dispatch rows diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index dd183e07ab..72c2902b4f 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -8,6 +8,24 @@ use super::*; /// `Bun.stdin` / `Bun.stdout` / `Bun.stderr` are property reads (handled by /// `js_native_module_property_by_name`), not rows here. pub(crate) const BUN_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "bun", + has_receiver: false, + method: "spawn", + class_filter: None, + runtime: "js_bun_spawn", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "Terminal", + class_filter: None, + runtime: "js_bun_terminal_new", + args: &[NA_F64], + ret: NR_F64, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 8f361db3b3..ad609f97d4 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -73,6 +73,22 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } if let ast::Expr::Ident(callee_ident) = callee_expr { + // Bun.Terminal returns an already-built runtime object. Like + // bun:ffi's explicit-return constructors below, a named import must + // call the native export directly instead of falling through to the + // generic class path, which would manufacture an empty instance. + if matches!( + ctx.lookup_native_module(callee_ident.sym.as_ref()), + Some(("bun", Some("Terminal"))) + ) { + return Ok(Expr::NativeMethodCall { + module: "bun".to_string(), + class_name: None, + object: None, + method: "Terminal".to_string(), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + }); + } // Keep Bun's `Database` distinct from better-sqlite3's same-named // constructor while still allocating the shared native SQLite handle. if matches!( diff --git a/crates/perry-hir/src/lower/expr_new/member.rs b/crates/perry-hir/src/lower/expr_new/member.rs index fd7c535665..a3556b9656 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -29,16 +29,23 @@ pub(crate) fn lower_new_member_native( (peel_new_callee(member.obj.as_ref()), &member.prop) { let obj_name = obj_ident.sym.as_ref(); - if obj_name == "Bun" - && prop_ident.sym.as_ref() == "Glob" + let is_global_bun = obj_name == "Bun" && !ctx.shadows_unqualified_global("Bun") - && ctx.lookup_native_module("Bun").is_none() + && ctx.lookup_native_module("Bun").is_none(); + let is_bun_namespace = ctx.lookup_builtin_module_alias(obj_name) == Some("bun") + || ctx + .lookup_native_module(obj_name) + .is_some_and(|(module, export)| { + module == "bun" && (export.is_none() || export == Some("default")) + }); + if (is_global_bun || is_bun_namespace) + && matches!(prop_ident.sym.as_ref(), "Glob" | "Terminal") { return Ok(Some(Expr::NativeMethodCall { module: "bun".to_string(), class_name: None, object: None, - method: "Glob".to_string(), + method: prop_ident.sym.to_string(), args: lower_optional_args(ctx, new_expr.args.as_deref())?, })); } diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 545d27af23..0acb21b570 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -21,6 +21,7 @@ //! `.toString(16)` cache keys match bun-run installs. mod glob; +mod spawn; mod string_width; mod width_tables; mod wyhash; @@ -37,6 +38,7 @@ use crate::value::{js_jsvalue_to_string, JSValue}; use std::io::{Read, Write}; pub use glob::js_bun_glob_new; +pub use spawn::{js_bun_spawn, js_bun_terminal_new}; pub use string_width::bun_string_width; pub use wyhash::wyhash; diff --git a/crates/perry-runtime/src/bun_compat/spawn.rs b/crates/perry-runtime/src/bun_compat/spawn.rs new file mode 100644 index 0000000000..2ba472cecf --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/spawn.rs @@ -0,0 +1,1096 @@ +//! Bun child-process facade (#9601). +//! +//! The process engine stays in `child_process::reactor` and the POSIX PTY +//! engine stays in `pty`; this module translates Bun's option shapes and adds +//! the Bun-facing objects (`Subprocess`, stream consumers, and `Terminal`). + +use super::*; +use crate::child_process::{ + cp_array_ptr, cp_box_ptr, cp_box_string, cp_build_object, cp_cast0, cp_cast1, cp_cast2, + cp_get_field, cp_object_ptr, cp_set_field, cp_undefined, CpFn, TAG_FALSE_F64, TAG_NULL_F64, + TAG_TRUE_F64, +}; +use crate::closure::{ + js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64, + js_register_closure_arity, ClosureHeader, +}; +use crate::value::JSValue; + +#[cfg(unix)] +use std::os::fd::AsRawFd; + +const BUN_TERMINAL_SHAPE_ID: u32 = 0x7FFF_FC40; +const BUN_PTY_SUBPROCESS_SHAPE_ID: u32 = 0x7FFF_FC60; + +const TERMINAL_MARKER: &[u8] = b"__perryBunTerminal"; +const TERMINAL_CURRENT: &[u8] = b"__perryBunTerminalCurrent"; +const TERMINAL_DATA_CB: &[u8] = b"__perryBunTerminalData"; +const TERMINAL_EXIT_CB: &[u8] = b"__perryBunTerminalExit"; +const TERMINAL_DRAIN_CB: &[u8] = b"__perryBunTerminalDrain"; +const TERMINAL_REFED: &[u8] = b"__perryBunTerminalRefed"; +const SUBPROCESS_ON_EXIT: &[u8] = b"__perryBunOnExit"; + +fn is_undefined(value: f64) -> bool { + JSValue::from_bits(value.to_bits()).is_undefined() +} + +fn is_nullish(value: f64) -> bool { + let value = JSValue::from_bits(value.to_bits()); + value.is_undefined() || value.is_null() +} + +fn is_callable(value: f64) -> bool { + !crate::fs::extract_closure_ptr(value).is_null() +} + +fn number_i32(value: f64) -> Option { + let js = JSValue::from_bits(value.to_bits()); + if js.is_int32() { + return Some(js.as_int32()); + } + if js.is_number() + && value.is_finite() + && value.fract() == 0.0 + && value >= i32::MIN as f64 + && value <= i32::MAX as f64 + { + return Some(value as i32); + } + None +} + +fn bool_field(object: f64, key: &[u8]) -> bool { + cp_get_field(object, key).to_bits() == TAG_TRUE_F64.to_bits() +} + +fn call_value(callback: f64, args: &[f64]) -> f64 { + if !is_callable(callback) { + return cp_undefined(); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let callback = scope.root_nanbox_f64(callback); + let args = scope.root_nanbox_f64_slice(args); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&args); + unsafe { + crate::closure::js_native_call_value(callback.get_nanbox_f64(), args.as_ptr(), args.len()) + } +} + +fn call_method(receiver: f64, name: &[u8], args: &[f64]) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let method = cp_get_field(receiver.get_nanbox_f64(), name); + if !is_callable(method) { + return cp_undefined(); + } + let method = scope.root_nanbox_f64(method); + let args = scope.root_nanbox_f64_slice(args); + let previous = scope.root_nanbox_f64(crate::object::js_implicit_this_set( + receiver.get_nanbox_f64(), + )); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&args); + let result = unsafe { + crate::closure::js_native_call_value(method.get_nanbox_f64(), args.as_ptr(), args.len()) + }; + crate::object::js_implicit_this_set(previous.get_nanbox_f64()); + result +} + +fn closure_with_captures(func: *const u8, arity: u32, captures: &[f64]) -> f64 { + js_register_closure_arity(func, arity); + let scope = crate::gc::RuntimeHandleScope::new(); + let captures = scope.root_nanbox_f64_slice(captures); + let closure = js_closure_alloc(func, captures.len() as u32); + for (index, value) in captures.iter().enumerate() { + js_closure_set_capture_f64(closure, index as u32, value.get_nanbox_f64()); + } + cp_box_ptr(closure as *const u8) +} + +fn register_bun_spawn_arities() { + js_register_closure_arity(bun_terminal_write as *const u8, 1); + js_register_closure_arity(bun_terminal_resize as *const u8, 2); + js_register_closure_arity(bun_terminal_set_raw_mode as *const u8, 1); + js_register_closure_arity(bun_terminal_ref as *const u8, 0); + js_register_closure_arity(bun_terminal_unref as *const u8, 0); + js_register_closure_arity(bun_terminal_close as *const u8, 0); + #[cfg(unix)] + { + js_register_closure_arity(bun_pty_subprocess_kill as *const u8, 1); + js_register_closure_arity(bun_pty_subprocess_ref as *const u8, 0); + js_register_closure_arity(bun_pty_subprocess_unref as *const u8, 0); + js_register_closure_arity(bun_pty_subprocess_dispose as *const u8, 0); + } +} + +fn captured_at(closure: *const ClosureHeader, index: u32) -> f64 { + js_closure_get_capture_f64(closure, index) +} + +fn promise_pointer(value: f64) -> *mut crate::promise::Promise { + crate::value::js_nanbox_get_pointer(value) as *mut crate::promise::Promise +} + +fn new_pending_promise_value() -> f64 { + cp_box_ptr(crate::promise::js_promise_new() as *const u8) +} + +fn type_error(message: &str) -> f64 { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + cp_box_ptr(crate::error::js_typeerror_new(message) as *const u8) +} + +fn plain_object(capacity: u32) -> f64 { + cp_box_ptr(js_object_alloc(0, capacity) as *const u8) +} + +/// Clone the enumerable own fields of an option bag. Bun's stdio translation +/// must not mutate the caller's object. +fn clone_options(options: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_nanbox_f64(options); + let out = scope.root_nanbox_f64(plain_object(16)); + let Some(source_obj) = cp_object_ptr(source.get_nanbox_f64()) else { + return out.get_nanbox_f64(); + }; + let keys = crate::object::js_object_keys(source_obj); + if keys.is_null() { + return out.get_nanbox_f64(); + } + let keys = scope.root_nanbox_f64(cp_box_ptr(keys as *const u8)); + let length = cp_array_ptr(keys.get_nanbox_f64()) + .map(|array| crate::array::js_array_length(array)) + .unwrap_or(0); + for index in 0..length { + let Some(keys_array) = cp_array_ptr(keys.get_nanbox_f64()) else { + break; + }; + let key_value = crate::array::js_array_get_f64(keys_array, index); + let Some(key) = crate::child_process::cp_value_to_string(key_value) else { + continue; + }; + let value = scope.root_nanbox_f64(cp_get_field(source.get_nanbox_f64(), key.as_bytes())); + cp_set_field(out.get_nanbox_f64(), key.as_bytes(), value.get_nanbox_f64()); + } + out.get_nanbox_f64() +} + +// ------------------------------------------------------------------------- +// Readable / writable stream facade +// ------------------------------------------------------------------------- + +extern "C" fn bun_readable_text(closure: *const ClosureHeader) -> f64 { + crate::node_submodules::consume_text(captured_at(closure, 0)) +} + +extern "C" fn bun_readable_json(closure: *const ClosureHeader) -> f64 { + crate::node_submodules::consume_json(captured_at(closure, 0)) +} + +extern "C" fn bun_readable_array_buffer(closure: *const ClosureHeader) -> f64 { + crate::node_submodules::consume_array_buffer(captured_at(closure, 0)) +} + +extern "C" fn bun_readable_bytes(closure: *const ClosureHeader) -> f64 { + crate::node_submodules::consume_bytes(captured_at(closure, 0)) +} + +fn decorate_readable(stream: f64) -> f64 { + if cp_object_ptr(stream).is_none() { + return stream; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(stream); + for (name, function) in [ + (b"text".as_slice(), bun_readable_text as *const u8), + (b"json".as_slice(), bun_readable_json as *const u8), + ( + b"arrayBuffer".as_slice(), + bun_readable_array_buffer as *const u8, + ), + (b"bytes".as_slice(), bun_readable_bytes as *const u8), + ] { + let method = scope.root_nanbox_f64(closure_with_captures( + function, + 0, + &[stream.get_nanbox_f64()], + )); + cp_set_field(stream.get_nanbox_f64(), name, method.get_nanbox_f64()); + } + stream.get_nanbox_f64() +} + +extern "C" fn bun_sink_flush(_closure: *const ClosureHeader, _wait: f64) -> f64 { + 0.0 +} + +fn decorate_sink(stream: f64) -> f64 { + if cp_object_ptr(stream).is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(stream); + let flush = scope.root_nanbox_f64(closure_with_captures( + bun_sink_flush as *const u8, + 1, + &[stream.get_nanbox_f64()], + )); + cp_set_field(stream.get_nanbox_f64(), b"flush", flush.get_nanbox_f64()); + return stream.get_nanbox_f64(); + } + stream +} + +// ------------------------------------------------------------------------- +// Bun stdio translation +// ------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum StdioSpec { + Pipe, + Ignore, + Inherit, + Fd(i32), +} + +fn stdio_spec( + value: f64, + fd_index: usize, + held: &mut Vec, +) -> Result { + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(value); + let value_now = value.get_nanbox_f64(); + let js = JSValue::from_bits(value_now.to_bits()); + if js.is_undefined() { + return Ok(match fd_index { + 0 => StdioSpec::Ignore, + 1 => StdioSpec::Pipe, + _ => StdioSpec::Inherit, + }); + } + if js.is_null() { + return Ok(StdioSpec::Ignore); + } + if let Some(fd) = number_i32(value_now).filter(|fd| *fd >= 0) { + return Ok(StdioSpec::Fd(fd)); + } + if js.is_any_string() { + return Ok(match value_to_string(value_now).as_str() { + "ignore" => StdioSpec::Ignore, + "inherit" => StdioSpec::Inherit, + _ => StdioSpec::Pipe, + }); + } + if cp_object_ptr(value_now).is_some() { + let std_fd = cp_get_field(value.get_nanbox_f64(), BUN_STD_FD_KEY); + if let Some(fd) = number_i32(std_fd).filter(|fd| *fd >= 0) { + return Ok(StdioSpec::Fd(fd)); + } + let path = cp_get_field(value.get_nanbox_f64(), BUN_FILE_PATH_KEY); + if !is_undefined(path) { + let path = value_to_string(path); + #[cfg(unix)] + { + let opened = if fd_index == 0 { + std::fs::File::open(&path) + } else { + if let Some(parent) = std::path::Path::new(&path).parent() { + if !parent.as_os_str().is_empty() { + let _ = std::fs::create_dir_all(parent); + } + } + std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&path) + }; + return match opened { + Ok(file) => { + let fd = file.as_raw_fd(); + held.push(file); + Ok(StdioSpec::Fd(fd)) + } + Err(error) => Err(unsafe { + crate::fs::build_fs_error_value( + &error, + if fd_index == 0 { "open" } else { "write" }, + &path, + ) + }), + }; + } + #[cfg(not(unix))] + { + let _ = (fd_index, held); + return Err(type_error(&format!( + "Bun.spawn: Bun.file stdio is not supported on this platform ({path})" + ))); + } + } + } + Ok(StdioSpec::Pipe) +} + +fn stdio_value(spec: StdioSpec) -> f64 { + match spec { + StdioSpec::Pipe => cp_box_string("pipe"), + StdioSpec::Ignore => cp_box_string("ignore"), + StdioSpec::Inherit => cp_box_string("inherit"), + StdioSpec::Fd(fd) => fd as f64, + } +} + +fn normalized_options(options: f64) -> Result<(f64, Vec), f64> { + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_nanbox_f64(options); + let out = scope.root_nanbox_f64(clone_options(source.get_nanbox_f64())); + let mut held = Vec::new(); + let mut specs = [StdioSpec::Ignore, StdioSpec::Pipe, StdioSpec::Inherit]; + + let stdio = cp_get_field(source.get_nanbox_f64(), b"stdio"); + if let Some(array) = cp_array_ptr(stdio) { + let count = crate::array::js_array_length(array).min(3); + for index in 0..count { + let Some(array) = cp_array_ptr(cp_get_field(source.get_nanbox_f64(), b"stdio")) else { + break; + }; + specs[index as usize] = stdio_spec( + crate::array::js_array_get_f64(array, index), + index as usize, + &mut held, + )?; + } + } + + for (index, name) in [ + b"stdin".as_slice(), + b"stdout".as_slice(), + b"stderr".as_slice(), + ] + .into_iter() + .enumerate() + { + let value = cp_get_field(source.get_nanbox_f64(), name); + if !is_undefined(value) { + specs[index] = stdio_spec(value, index, &mut held)?; + } + } + + let stdio_array = + scope.root_nanbox_f64(cp_box_ptr(crate::array::js_array_alloc(3) as *const u8)); + for spec in specs { + let value = scope.root_nanbox_f64(stdio_value(spec)); + let Some(array) = cp_array_ptr(stdio_array.get_nanbox_f64()) else { + break; + }; + let array = crate::array::js_array_push_f64(array, value.get_nanbox_f64()); + stdio_array.set_nanbox_f64(cp_box_ptr(array as *const u8)); + } + cp_set_field(out.get_nanbox_f64(), b"stdio", stdio_array.get_nanbox_f64()); + Ok((out.get_nanbox_f64(), held)) +} + +// ------------------------------------------------------------------------- +// Non-PTY Subprocess facade +// ------------------------------------------------------------------------- + +extern "C" fn bun_subprocess_exit(closure: *const ClosureHeader, code: f64, signal: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(captured_at(closure, 0)); + let code = scope.root_nanbox_f64(code); + let signal = scope.root_nanbox_f64(signal); + let resolved = if number_i32(code.get_nanbox_f64()).is_some() { + code.get_nanbox_f64() + } else { + 1.0 + }; + let promise = cp_get_field(subprocess.get_nanbox_f64(), b"exited"); + let promise = promise_pointer(promise); + if !promise.is_null() { + crate::promise::js_promise_resolve(promise, resolved); + } + let callback = cp_get_field(subprocess.get_nanbox_f64(), SUBPROCESS_ON_EXIT); + if is_callable(callback) { + call_value( + callback, + &[ + subprocess.get_nanbox_f64(), + code.get_nanbox_f64(), + signal.get_nanbox_f64(), + cp_undefined(), + ], + ); + } + cp_undefined() +} + +fn install_dispose_aliases(object: f64, method: f64, include_async: bool) { + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_nanbox_f64(object); + let method = scope.root_nanbox_f64(method); + cp_set_field( + object.get_nanbox_f64(), + b"__perry_dispose__", + method.get_nanbox_f64(), + ); + cp_set_field( + object.get_nanbox_f64(), + b"@@__perry_wk_dispose", + method.get_nanbox_f64(), + ); + let dispose = crate::symbol::well_known_symbol("dispose"); + if !dispose.is_null() { + unsafe { + crate::symbol::js_object_set_symbol_property( + object.get_nanbox_f64(), + cp_box_ptr(dispose as *const u8), + method.get_nanbox_f64(), + ); + } + } + if include_async { + cp_set_field( + object.get_nanbox_f64(), + b"__perry_async_dispose__", + method.get_nanbox_f64(), + ); + cp_set_field( + object.get_nanbox_f64(), + b"@@__perry_wk_asyncDispose", + method.get_nanbox_f64(), + ); + let async_dispose = crate::symbol::well_known_symbol("asyncDispose"); + if !async_dispose.is_null() { + unsafe { + crate::symbol::js_object_set_symbol_property( + object.get_nanbox_f64(), + cp_box_ptr(async_dispose as *const u8), + method.get_nanbox_f64(), + ); + } + } + } +} + +fn finish_non_pty_subprocess(subprocess: f64, options: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(subprocess); + let options = scope.root_nanbox_f64(options); + + let error = cp_get_field(subprocess.get_nanbox_f64(), b"__cpError"); + if !is_undefined(error) { + crate::exception::js_throw(error); + } + + let stdout = scope.root_nanbox_f64(decorate_readable(cp_get_field( + subprocess.get_nanbox_f64(), + b"stdout", + ))); + let stderr = scope.root_nanbox_f64(decorate_readable(cp_get_field( + subprocess.get_nanbox_f64(), + b"stderr", + ))); + let stdin = scope.root_nanbox_f64(decorate_sink(cp_get_field( + subprocess.get_nanbox_f64(), + b"stdin", + ))); + cp_set_field( + subprocess.get_nanbox_f64(), + b"stdout", + stdout.get_nanbox_f64(), + ); + cp_set_field( + subprocess.get_nanbox_f64(), + b"stderr", + stderr.get_nanbox_f64(), + ); + cp_set_field( + subprocess.get_nanbox_f64(), + b"stdin", + stdin.get_nanbox_f64(), + ); + cp_set_field( + subprocess.get_nanbox_f64(), + b"readable", + stdout.get_nanbox_f64(), + ); + cp_set_field(subprocess.get_nanbox_f64(), b"terminal", cp_undefined()); + let dispose = cp_get_field(subprocess.get_nanbox_f64(), b"__perry_dispose__"); + install_dispose_aliases(subprocess.get_nanbox_f64(), dispose, true); + + let exited = scope.root_nanbox_f64(new_pending_promise_value()); + cp_set_field( + subprocess.get_nanbox_f64(), + b"exited", + exited.get_nanbox_f64(), + ); + let on_exit = scope.root_nanbox_f64(cp_get_field(options.get_nanbox_f64(), b"onExit")); + cp_set_field( + subprocess.get_nanbox_f64(), + SUBPROCESS_ON_EXIT, + on_exit.get_nanbox_f64(), + ); + let listener = scope.root_nanbox_f64(closure_with_captures( + bun_subprocess_exit as *const u8, + 2, + &[subprocess.get_nanbox_f64()], + )); + let event = scope.root_nanbox_f64(cp_box_string("exit")); + crate::child_process::cp_register( + subprocess.get_nanbox_f64(), + event.get_nanbox_f64(), + listener.get_nanbox_f64(), + ); + subprocess.get_nanbox_f64() +} + +// ------------------------------------------------------------------------- +// Bun.Terminal and PTY-backed Subprocess +// ------------------------------------------------------------------------- + +#[cfg(unix)] +fn terminal_current(terminal: f64) -> Option { + let current = cp_get_field(terminal, TERMINAL_CURRENT); + (!is_nullish(current)).then_some(current) +} + +#[cfg(unix)] +fn terminal_set_refed(terminal: f64, refed: bool) { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(terminal); + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + if let Some(handle) = crate::pty::pty_handle_of(current) { + crate::pty::reactor::pty_live_set_refed(handle, refed); + } + } + cp_set_field( + terminal.get_nanbox_f64(), + TERMINAL_REFED, + if refed { TAG_TRUE_F64 } else { TAG_FALSE_F64 }, + ); +} + +extern "C" fn bun_terminal_write(closure: *const ClosureHeader, data: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + let data = scope.root_nanbox_f64(data); + #[cfg(unix)] + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + let current = scope.root_nanbox_f64(current); + let bytes = crate::child_process::cp_value_to_bytes(data.get_nanbox_f64()); + let written = bytes.len() as f64; + let _ = call_method(current.get_nanbox_f64(), b"write", &[data.get_nanbox_f64()]); + let drain = cp_get_field(terminal.get_nanbox_f64(), TERMINAL_DRAIN_CB); + if is_callable(drain) { + call_value(drain, &[terminal.get_nanbox_f64()]); + } + return written; + } + 0.0 +} + +extern "C" fn bun_terminal_resize(closure: *const ClosureHeader, columns: f64, rows: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + let columns = number_i32(columns).unwrap_or(0); + let rows = number_i32(rows).unwrap_or(0); + if columns <= 0 || rows <= 0 || columns > u16::MAX as i32 || rows > u16::MAX as i32 { + crate::exception::js_throw(type_error( + "Terminal.resize expects positive columns and rows", + )); + } + cp_set_field(terminal.get_nanbox_f64(), b"cols", columns as f64); + cp_set_field(terminal.get_nanbox_f64(), b"rows", rows as f64); + #[cfg(unix)] + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + let _ = call_method(current, b"resize", &[columns as f64, rows as f64]); + } + cp_undefined() +} + +extern "C" fn bun_terminal_set_raw_mode(closure: *const ClosureHeader, enabled: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + let enabled = crate::value::js_is_truthy(enabled) != 0; + cp_set_field( + terminal.get_nanbox_f64(), + b"rawMode", + if enabled { TAG_TRUE_F64 } else { TAG_FALSE_F64 }, + ); + #[cfg(unix)] + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + if let Some(handle) = crate::pty::pty_handle_of(current) { + crate::pty::reactor::pty_live_set_raw_mode(handle, enabled); + } + } + cp_undefined() +} + +extern "C" fn bun_terminal_ref(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + #[cfg(unix)] + terminal_set_refed(terminal.get_nanbox_f64(), true); + terminal.get_nanbox_f64() +} + +extern "C" fn bun_terminal_unref(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + #[cfg(unix)] + terminal_set_refed(terminal.get_nanbox_f64(), false); + terminal.get_nanbox_f64() +} + +extern "C" fn bun_terminal_close(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + if bool_field(terminal.get_nanbox_f64(), b"closed") { + return cp_undefined(); + } + #[cfg(unix)] + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + let current = scope.root_nanbox_f64(current); + let signal = scope.root_nanbox_f64(cp_box_string("SIGHUP")); + let _ = call_method( + current.get_nanbox_f64(), + b"kill", + &[signal.get_nanbox_f64()], + ); + } + cp_set_field(terminal.get_nanbox_f64(), b"closed", TAG_TRUE_F64); + cp_set_field(terminal.get_nanbox_f64(), TERMINAL_CURRENT, cp_undefined()); + cp_undefined() +} + +fn terminal_options_i32(options: f64, key: &[u8], default: i32) -> i32 { + number_i32(cp_get_field(options, key)) + .filter(|value| *value > 0 && *value <= u16::MAX as i32) + .unwrap_or(default) +} + +/// `new Bun.Terminal(options)` — a reusable POSIX terminal configuration and +/// callback owner. The underlying PTY is attached lazily by `Bun.spawn`. +#[no_mangle] +pub extern "C" fn js_bun_terminal_new(options: f64) -> f64 { + register_bun_spawn_arities(); + #[cfg(not(unix))] + { + let _ = options; + crate::exception::js_throw(crate::child_process::cp_make_error( + "Bun.Terminal is only supported on POSIX platforms by Perry", + &[], + )); + } + + #[cfg(unix)] + { + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); + let methods: [(&str, CpFn); 6] = [ + ("write", cp_cast1(bun_terminal_write)), + ("resize", cp_cast2(bun_terminal_resize)), + ("setRawMode", cp_cast1(bun_terminal_set_raw_mode)), + ("ref", cp_cast0(bun_terminal_ref)), + ("unref", cp_cast0(bun_terminal_unref)), + ("close", cp_cast0(bun_terminal_close)), + ]; + let terminal = scope.root_nanbox_f64(cp_box_ptr(cp_build_object( + &methods, + BUN_TERMINAL_SHAPE_ID + methods.len() as u32, + ) as *const u8)); + cp_set_field(terminal.get_nanbox_f64(), TERMINAL_MARKER, TAG_TRUE_F64); + cp_set_field(terminal.get_nanbox_f64(), TERMINAL_CURRENT, cp_undefined()); + cp_set_field(terminal.get_nanbox_f64(), TERMINAL_REFED, TAG_TRUE_F64); + cp_set_field(terminal.get_nanbox_f64(), b"closed", TAG_FALSE_F64); + cp_set_field(terminal.get_nanbox_f64(), b"rawMode", TAG_FALSE_F64); + cp_set_field( + terminal.get_nanbox_f64(), + b"cols", + terminal_options_i32(options.get_nanbox_f64(), b"cols", 80) as f64, + ); + cp_set_field( + terminal.get_nanbox_f64(), + b"rows", + terminal_options_i32(options.get_nanbox_f64(), b"rows", 24) as f64, + ); + let name = cp_get_field(options.get_nanbox_f64(), b"name"); + let name = scope.root_nanbox_f64(if is_undefined(name) { + cp_box_string("xterm-256color") + } else { + name + }); + cp_set_field(terminal.get_nanbox_f64(), b"name", name.get_nanbox_f64()); + for (target, source) in [ + (TERMINAL_DATA_CB, b"data".as_slice()), + (TERMINAL_EXIT_CB, b"exit".as_slice()), + (TERMINAL_DRAIN_CB, b"drain".as_slice()), + ] { + let callback = scope.root_nanbox_f64(cp_get_field(options.get_nanbox_f64(), source)); + cp_set_field(terminal.get_nanbox_f64(), target, callback.get_nanbox_f64()); + } + cp_set_field(terminal.get_nanbox_f64(), b"inputFlags", 0.0); + cp_set_field(terminal.get_nanbox_f64(), b"outputFlags", 0.0); + cp_set_field(terminal.get_nanbox_f64(), b"localFlags", 0.0); + cp_set_field(terminal.get_nanbox_f64(), b"controlFlags", 0.0); + let close = cp_get_field(terminal.get_nanbox_f64(), b"close"); + install_dispose_aliases(terminal.get_nanbox_f64(), close, true); + terminal.get_nanbox_f64() + } +} + +#[cfg(unix)] +extern "C" fn bun_terminal_data_bridge(closure: *const ClosureHeader, text: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(captured_at(closure, 0)); + let text = scope.root_nanbox_f64(text); + let callback = cp_get_field(terminal.get_nanbox_f64(), TERMINAL_DATA_CB); + if is_callable(callback) { + let callback = scope.root_nanbox_f64(callback); + let bytes = crate::child_process::cp_value_to_bytes(text.get_nanbox_f64()); + let bytes = scope.root_nanbox_f64(uint8_array_from_bytes(&bytes)); + call_value( + callback.get_nanbox_f64(), + &[terminal.get_nanbox_f64(), bytes.get_nanbox_f64()], + ); + } + cp_undefined() +} + +#[cfg(unix)] +extern "C" fn bun_pty_subprocess_exit(closure: *const ClosureHeader, payload: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(captured_at(closure, 0)); + let terminal = scope.root_nanbox_f64(captured_at(closure, 1)); + let payload = scope.root_nanbox_f64(payload); + let code = scope.root_nanbox_f64(cp_get_field(payload.get_nanbox_f64(), b"exitCode")); + let signal_number = cp_get_field(payload.get_nanbox_f64(), b"signal"); + let signal_number_i32 = number_i32(signal_number); + let signal = scope.root_nanbox_f64( + signal_number_i32 + .map(|number| cp_box_string(crate::child_process::cp_signal_name(number))) + .unwrap_or(TAG_NULL_F64), + ); + let exit_code = if signal_number_i32.is_some() { + TAG_NULL_F64 + } else { + code.get_nanbox_f64() + }; + cp_set_field(subprocess.get_nanbox_f64(), b"exitCode", exit_code); + cp_set_field( + subprocess.get_nanbox_f64(), + b"signalCode", + signal.get_nanbox_f64(), + ); + cp_set_field(terminal.get_nanbox_f64(), TERMINAL_CURRENT, cp_undefined()); + let promise = promise_pointer(cp_get_field(subprocess.get_nanbox_f64(), b"exited")); + if !promise.is_null() { + let resolved = signal_number_i32 + .map(|number| (128 + number) as f64) + .unwrap_or(code.get_nanbox_f64()); + crate::promise::js_promise_resolve(promise, resolved); + } + let callback = cp_get_field(subprocess.get_nanbox_f64(), SUBPROCESS_ON_EXIT); + if is_callable(callback) { + call_value( + callback, + &[ + subprocess.get_nanbox_f64(), + exit_code, + signal.get_nanbox_f64(), + cp_undefined(), + ], + ); + } + let terminal_callback = cp_get_field(terminal.get_nanbox_f64(), TERMINAL_EXIT_CB); + if is_callable(terminal_callback) { + // Bun.Terminal reports the PTY stream lifecycle here (0 = EOF), + // independently from the subprocess status exposed by `onExit` and + // `exited`. + call_value( + terminal_callback, + &[terminal.get_nanbox_f64(), 0.0, TAG_NULL_F64], + ); + } + cp_undefined() +} + +#[cfg(unix)] +extern "C" fn bun_pty_subprocess_kill(closure: *const ClosureHeader, signal: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(captured_at(closure, 0)); + let terminal = scope.root_nanbox_f64(cp_get_field(subprocess.get_nanbox_f64(), b"terminal")); + let signal = scope.root_nanbox_f64(signal); + if let Some(current) = terminal_current(terminal.get_nanbox_f64()) { + let current = scope.root_nanbox_f64(current); + let signal = + if is_undefined(signal.get_nanbox_f64()) || signal.get_nanbox_f64().to_bits() == 0 { + cp_box_string("SIGTERM") + } else { + signal.get_nanbox_f64() + }; + let signal = scope.root_nanbox_f64(signal); + let _ = call_method( + current.get_nanbox_f64(), + b"kill", + &[signal.get_nanbox_f64()], + ); + cp_set_field(subprocess.get_nanbox_f64(), b"killed", TAG_TRUE_F64); + } + cp_undefined() +} + +#[cfg(unix)] +extern "C" fn bun_pty_subprocess_ref(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(captured_at(closure, 0)); + terminal_set_refed(cp_get_field(subprocess.get_nanbox_f64(), b"terminal"), true); + subprocess.get_nanbox_f64() +} + +#[cfg(unix)] +extern "C" fn bun_pty_subprocess_unref(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let subprocess = scope.root_nanbox_f64(captured_at(closure, 0)); + terminal_set_refed( + cp_get_field(subprocess.get_nanbox_f64(), b"terminal"), + false, + ); + subprocess.get_nanbox_f64() +} + +#[cfg(unix)] +extern "C" fn bun_pty_subprocess_dispose(closure: *const ClosureHeader) -> f64 { + let _ = bun_pty_subprocess_kill(closure, cp_undefined()); + cp_undefined() +} + +#[cfg(unix)] +fn finish_pty_subprocess(command: &str, args: &[String], options: f64, terminal: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let terminal = scope.root_nanbox_f64(terminal); + let options = scope.root_nanbox_f64(options); + if bool_field(terminal.get_nanbox_f64(), b"closed") { + crate::exception::js_throw(type_error("Bun.spawn cannot attach a closed Terminal")); + } + if terminal_current(terminal.get_nanbox_f64()).is_some() { + crate::exception::js_throw(type_error( + "Bun.Terminal is already attached to a subprocess", + )); + } + + let pty_options = scope.root_nanbox_f64(clone_options(options.get_nanbox_f64())); + for field in [b"cols".as_slice(), b"rows".as_slice(), b"name".as_slice()] { + let value = scope.root_nanbox_f64(cp_get_field(terminal.get_nanbox_f64(), field)); + cp_set_field(pty_options.get_nanbox_f64(), field, value.get_nanbox_f64()); + } + + let command_value = scope.root_nanbox_f64(cp_box_string(command)); + let args_array = scope.root_nanbox_f64(cp_box_ptr( + crate::array::js_array_alloc(args.len() as u32) as *const u8, + )); + for arg in args { + let arg = scope.root_nanbox_f64(cp_box_string(arg)); + let Some(array) = cp_array_ptr(args_array.get_nanbox_f64()) else { + break; + }; + let array = crate::array::js_array_push_f64(array, arg.get_nanbox_f64()); + args_array.set_nanbox_f64(cp_box_ptr(array as *const u8)); + } + let ipty = crate::pty::js_pty_spawn( + command_value.get_nanbox_f64().to_bits() as i64, + args_array.get_nanbox_f64().to_bits() as i64, + pty_options.get_nanbox_f64().to_bits() as i64, + ); + let ipty = scope.root_nanbox_f64(ipty); + cp_set_field( + terminal.get_nanbox_f64(), + TERMINAL_CURRENT, + ipty.get_nanbox_f64(), + ); + + let methods: [(&str, CpFn); 4] = [ + ("kill", cp_cast1(bun_pty_subprocess_kill)), + ("ref", cp_cast0(bun_pty_subprocess_ref)), + ("unref", cp_cast0(bun_pty_subprocess_unref)), + ("dispose", cp_cast0(bun_pty_subprocess_dispose)), + ]; + let subprocess = scope.root_nanbox_f64(cp_box_ptr(cp_build_object( + &methods, + BUN_PTY_SUBPROCESS_SHAPE_ID + methods.len() as u32, + ) as *const u8)); + cp_set_field( + subprocess.get_nanbox_f64(), + b"pid", + cp_get_field(ipty.get_nanbox_f64(), b"pid"), + ); + cp_set_field(subprocess.get_nanbox_f64(), b"stdin", TAG_NULL_F64); + cp_set_field(subprocess.get_nanbox_f64(), b"stdout", TAG_NULL_F64); + cp_set_field(subprocess.get_nanbox_f64(), b"stderr", TAG_NULL_F64); + cp_set_field(subprocess.get_nanbox_f64(), b"readable", TAG_NULL_F64); + cp_set_field( + subprocess.get_nanbox_f64(), + b"terminal", + terminal.get_nanbox_f64(), + ); + cp_set_field(subprocess.get_nanbox_f64(), b"exitCode", TAG_NULL_F64); + cp_set_field(subprocess.get_nanbox_f64(), b"signalCode", TAG_NULL_F64); + cp_set_field(subprocess.get_nanbox_f64(), b"killed", TAG_FALSE_F64); + cp_set_field( + subprocess.get_nanbox_f64(), + b"exited", + scope + .root_nanbox_f64(new_pending_promise_value()) + .get_nanbox_f64(), + ); + let on_exit = scope.root_nanbox_f64(cp_get_field(options.get_nanbox_f64(), b"onExit")); + cp_set_field( + subprocess.get_nanbox_f64(), + SUBPROCESS_ON_EXIT, + on_exit.get_nanbox_f64(), + ); + let dispose = cp_get_field(subprocess.get_nanbox_f64(), b"dispose"); + install_dispose_aliases(subprocess.get_nanbox_f64(), dispose, true); + + let data_bridge = scope.root_nanbox_f64(closure_with_captures( + bun_terminal_data_bridge as *const u8, + 1, + &[terminal.get_nanbox_f64()], + )); + crate::pty::pty_register(ipty.get_nanbox_f64(), "data", data_bridge.get_nanbox_f64()); + let exit_bridge = scope.root_nanbox_f64(closure_with_captures( + bun_pty_subprocess_exit as *const u8, + 1, + &[subprocess.get_nanbox_f64(), terminal.get_nanbox_f64()], + )); + crate::pty::pty_register(ipty.get_nanbox_f64(), "exit", exit_bridge.get_nanbox_f64()); + if bool_field(terminal.get_nanbox_f64(), b"rawMode") { + if let Some(handle) = crate::pty::pty_handle_of(ipty.get_nanbox_f64()) { + crate::pty::reactor::pty_live_set_raw_mode(handle, true); + } + } + if !bool_field(terminal.get_nanbox_f64(), TERMINAL_REFED) { + if let Some(handle) = crate::pty::pty_handle_of(ipty.get_nanbox_f64()) { + crate::pty::reactor::pty_live_set_refed(handle, false); + } + } + subprocess.get_nanbox_f64() +} + +// ------------------------------------------------------------------------- +// Public Bun.spawn entry +// ------------------------------------------------------------------------- + +fn parse_command(command_or_options: f64, options: f64) -> (String, Vec, f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let command_or_options = scope.root_nanbox_f64(command_or_options); + let options_arg = scope.root_nanbox_f64(options); + let (command, options) = if cp_array_ptr(command_or_options.get_nanbox_f64()).is_some() { + ( + command_or_options.get_nanbox_f64(), + options_arg.get_nanbox_f64(), + ) + } else if cp_object_ptr(command_or_options.get_nanbox_f64()).is_some() { + ( + cp_get_field(command_or_options.get_nanbox_f64(), b"cmd"), + command_or_options.get_nanbox_f64(), + ) + } else { + crate::exception::js_throw(type_error( + "Bun.spawn expects a command array or an options object with cmd", + )); + }; + let command = scope.root_nanbox_f64(command); + let Some(command_array) = cp_array_ptr(command.get_nanbox_f64()) else { + crate::exception::js_throw(type_error("Bun.spawn cmd must be an array")); + }; + let length = crate::array::js_array_length(command_array); + if length == 0 { + crate::exception::js_throw(type_error("Bun.spawn cmd must not be empty")); + } + let mut values = Vec::with_capacity(length as usize); + for index in 0..length { + let Some(command_array) = cp_array_ptr(command.get_nanbox_f64()) else { + break; + }; + let value = crate::array::js_array_get_f64(command_array, index); + let Some(value) = crate::child_process::cp_value_to_string(value) else { + crate::exception::js_throw(type_error("Bun.spawn command entries must be strings")); + }; + values.push(value); + } + let command = values.remove(0); + (command, values, options) +} + +/// `Bun.spawn(cmd, options?)` / `Bun.spawn({ cmd, ...options })`. +#[no_mangle] +pub extern "C" fn js_bun_spawn(command_or_options: f64, options: f64) -> f64 { + register_bun_spawn_arities(); + let scope = crate::gc::RuntimeHandleScope::new(); + let command_or_options = scope.root_nanbox_f64(command_or_options); + let options_arg = scope.root_nanbox_f64(options); + let (command, args, options) = parse_command( + command_or_options.get_nanbox_f64(), + options_arg.get_nanbox_f64(), + ); + let options = scope.root_nanbox_f64(options); + + let terminal_option = + scope.root_nanbox_f64(cp_get_field(options.get_nanbox_f64(), b"terminal")); + if !is_nullish(terminal_option.get_nanbox_f64()) { + #[cfg(unix)] + { + let terminal = if bool_field(terminal_option.get_nanbox_f64(), TERMINAL_MARKER) { + terminal_option.get_nanbox_f64() + } else if cp_object_ptr(terminal_option.get_nanbox_f64()).is_some() { + js_bun_terminal_new(terminal_option.get_nanbox_f64()) + } else { + crate::exception::js_throw(type_error( + "Bun.spawn terminal must be a Bun.Terminal or Terminal options object", + )); + }; + return finish_pty_subprocess(&command, &args, options.get_nanbox_f64(), terminal); + } + #[cfg(not(unix))] + { + crate::exception::js_throw(crate::child_process::cp_make_error( + "Bun.spawn terminal is only supported on POSIX platforms by Perry", + &[], + )); + } + } + + let (normalized, held_files) = match normalized_options(options.get_nanbox_f64()) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + }; + let normalized = scope.root_nanbox_f64(normalized); + let args_array = scope.root_nanbox_f64(cp_box_ptr( + crate::array::js_array_alloc(args.len() as u32) as *const u8, + )); + for arg in &args { + let arg = scope.root_nanbox_f64(cp_box_string(arg)); + let Some(array) = cp_array_ptr(args_array.get_nanbox_f64()) else { + break; + }; + let array = crate::array::js_array_push_f64(array, arg.get_nanbox_f64()); + args_array.set_nanbox_f64(cp_box_ptr(array as *const u8)); + } + let command_value = scope.root_nanbox_f64(cp_box_string(&command)); + let command_ptr = crate::value::js_nanbox_get_pointer(command_value.get_nanbox_f64()); + let options_ptr = cp_object_ptr(normalized.get_nanbox_f64()) + .map(|ptr| ptr as i64) + .unwrap_or(0); + let subprocess = crate::child_process::reactor::js_child_process_spawn_streams( + command_ptr, + cp_array_ptr(args_array.get_nanbox_f64()) + .map(|ptr| ptr as i64) + .unwrap_or(0), + options_ptr, + ); + // Keep BunFile-backed descriptors open through `Command::spawn`; the + // child-process layer duplicates them before this vector is dropped. + drop(held_files); + finish_non_pty_subprocess(subprocess, options.get_nanbox_f64()) +} diff --git a/crates/perry-runtime/src/child_process/builder.rs b/crates/perry-runtime/src/child_process/builder.rs index 5753d2b5da..578b2fb03d 100644 --- a/crates/perry-runtime/src/child_process/builder.rs +++ b/crates/perry-runtime/src/child_process/builder.rs @@ -47,6 +47,8 @@ pub(crate) fn cp_register_arities() { js_register_closure_arity(cp_method_remove_listener as *const u8, 2); js_register_closure_arity(cp_method_remove_all_listeners as *const u8, 1); js_register_closure_arity(cp_method_kill as *const u8, 1); + js_register_closure_arity(cp_method_ref as *const u8, 0); + js_register_closure_arity(cp_method_unref as *const u8, 0); js_register_closure_arity(cp_method_dispose as *const u8, 0); crate::closure::js_register_closure_length(cp_method_dispose as *const u8, 0); js_register_closure_arity(cp_method_read as *const u8, 1); @@ -202,8 +204,8 @@ pub(crate) fn cp_build_unstarted_child_process() -> f64 { cp_cast1(cp_method_remove_all_listeners), ), ("kill", cp_cast1(cp_method_kill)), - ("ref", cp_cast0(cp_method_this0)), - ("unref", cp_cast0(cp_method_this0)), + ("ref", cp_cast0(cp_method_ref)), + ("unref", cp_cast0(cp_method_unref)), ("spawn", cp_cast1(cp_method_child_spawn)), ]; let obj = cp_build_object(&methods, CP_SHAPE_ID + 0x60 + methods.len() as u32); diff --git a/crates/perry-runtime/src/child_process/emitter.rs b/crates/perry-runtime/src/child_process/emitter.rs index d816ea5618..511ce237db 100644 --- a/crates/perry-runtime/src/child_process/emitter.rs +++ b/crates/perry-runtime/src/child_process/emitter.rs @@ -117,6 +117,27 @@ pub(crate) extern "C" fn cp_method_this1(closure: *const ClosureHeader, _a: f64) cp_this(closure) } +/// Keep a live child attached to the event loop. Calls are idempotent, matching +/// Node and Bun's process-handle contract. +pub(crate) extern "C" fn cp_method_ref(closure: *const ClosureHeader) -> f64 { + let this = cp_this(closure); + if let Some(handle) = cp_handle_of(this) { + reactor::cp_live_set_refed(handle, true); + } + this +} + +/// Let the program terminate while this child continues running. The reactor +/// still pumps and roots the child whenever another handle keeps the loop +/// alive; only the active-handle accounting changes. +pub(crate) extern "C" fn cp_method_unref(closure: *const ClosureHeader) -> f64 { + let this = cp_this(closure); + if let Some(handle) = cp_handle_of(this) { + reactor::cp_live_set_refed(handle, false); + } + this +} + /// Low-level `new ChildProcess().spawn(options)` validation boundary. Node's /// constructor is public even though normal callers use `spawn()`; keep the /// constructed idle object inert after its setup checks. diff --git a/crates/perry-runtime/src/child_process/fork.rs b/crates/perry-runtime/src/child_process/fork.rs index 208c115d13..9b99218d02 100644 --- a/crates/perry-runtime/src/child_process/fork.rs +++ b/crates/perry-runtime/src/child_process/fork.rs @@ -139,8 +139,8 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr ), ("emit", cp_cast2(cp_method_emit)), ("kill", cp_cast1(cp_method_kill)), - ("ref", cp_cast0(cp_method_this0)), - ("unref", cp_cast0(cp_method_this0)), + ("ref", cp_cast0(cp_method_ref)), + ("unref", cp_cast0(cp_method_unref)), ("send", cp_cast4(cp_method_send)), ("disconnect", cp_cast0(cp_method_disconnect)), ]; diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 929edda404..487d6eddf7 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -91,9 +91,10 @@ pub(crate) use signals::{ // emitter.rs — EventEmitter listener registry, method bodies, IPC send/disconnect. pub(crate) use emitter::{ cp_emit, cp_method_child_spawn, cp_method_disconnect, cp_method_dispose, cp_method_emit, - cp_method_kill, cp_method_on, cp_method_pipe, cp_method_read, cp_method_remove_all_listeners, - cp_method_remove_listener, cp_method_send, cp_method_set_encoding, cp_method_stdin_end, - cp_method_stdin_write, cp_method_this0, cp_method_this1, cp_send_callback_thunk, + cp_method_kill, cp_method_on, cp_method_pipe, cp_method_read, cp_method_ref, + cp_method_remove_all_listeners, cp_method_remove_listener, cp_method_send, + cp_method_set_encoding, cp_method_stdin_end, cp_method_stdin_write, cp_method_this0, + cp_method_this1, cp_method_unref, cp_register, cp_send_callback_thunk, cp_stream_callback_thunk, js_fork_child, }; diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 65cda0298e..25d024cc9b 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -227,6 +227,11 @@ static CP_NEXT_LIVE_ID: AtomicU64 = AtomicU64::new(1); /// any lock, so the hot async loop pays a single relaxed load per tick. static CP_LIVE_COUNT: AtomicU64 = AtomicU64::new(0); +/// Live children which currently keep the event loop alive. `unref()` only +/// changes this count; the total live count above continues to gate pumping +/// and GC scanning while the runtime is alive for another reason. +static CP_REFED_COUNT: AtomicU64 = AtomicU64::new(0); + /// An event produced by a child's background thread, consumed by the pump. enum CpEvent { /// A stdout (`stderr == false`) or stderr chunk. @@ -295,6 +300,8 @@ struct LiveChild { exited: Option<(Option, Option)>, /// Whether `exit`/`close` have been emitted (terminal state). closed: bool, + /// Whether this process currently contributes an active event-loop handle. + refed: bool, /// #1933: the parent end of the IPC socket for a `fork()`ed child (a clone /// for `child.send()` / `child.disconnect()`; the reader thread owns /// another clone). `None` for plain `spawn`. @@ -739,6 +746,7 @@ fn cp_register_live_child_parts( spawned: false, exited: None, closed: false, + refed: true, ipc_send, ipc_advanced, abort_signal_bits: 0, @@ -754,6 +762,7 @@ fn cp_register_live_child_parts( } crate::stdlib_pump::register_runtime_pump(0, cp_reactor_pump_extern); CP_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + CP_REFED_COUNT.fetch_add(1, Ordering::SeqCst); if let Some(o) = stdout_pipe { cp_spawn_reader(handle, o, 1); @@ -1083,8 +1092,8 @@ pub extern "C" fn js_child_process_spawn_streams( ), ("emit", cp_cast2(cp_method_emit)), ("kill", cp_cast1(cp_method_kill)), - ("ref", cp_cast0(cp_method_this0)), - ("unref", cp_cast0(cp_method_this0)), + ("ref", cp_cast0(cp_method_ref)), + ("unref", cp_cast0(cp_method_unref)), ]; let cp_obj = cp_build_object(&cp_methods, CP_SHAPE_ID + cp_methods.len() as u32); let cp = cp_box_ptr(cp_obj as *const u8); @@ -1273,8 +1282,8 @@ pub(super) fn cp_exec_async( ), ("emit", cp_cast2(cp_method_emit)), ("kill", cp_cast1(cp_method_kill)), - ("ref", cp_cast0(cp_method_this0)), - ("unref", cp_cast0(cp_method_this0)), + ("ref", cp_cast0(cp_method_ref)), + ("unref", cp_cast0(cp_method_unref)), ]; let cp = cp_box_ptr(cp_build_object(&methods, CP_SHAPE_ID + methods.len() as u32) as *const u8); cp_set_field(cp, b"stdout", stdout_obj); @@ -1344,6 +1353,7 @@ pub(super) fn cp_exec_async( spawned: false, exited: None, closed: false, + refed: true, ipc_send: None, ipc_advanced: false, abort_signal_bits: 0, @@ -1359,6 +1369,7 @@ pub(super) fn cp_exec_async( } crate::stdlib_pump::register_runtime_pump(0, cp_reactor_pump_extern); CP_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + CP_REFED_COUNT.fetch_add(1, Ordering::SeqCst); if let Some(o) = stdout_pipe { cp_spawn_reader(handle, o, 1); @@ -1746,6 +1757,7 @@ fn cp_reactor_pump_inner() { exec: lc.exec.take(), process_ids: lc.process_ids, pipe_ids: lc.pipe_ids, + refed: lc.refed, }); lc.abort_signal_bits = 0; lc.abort_listener_bits = 0; @@ -1792,6 +1804,9 @@ fn cp_reactor_pump_inner() { crate::async_hooks::destroy(pipe.async_id); } CP_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if item.refed { + CP_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } } } @@ -1808,6 +1823,7 @@ struct CpCloseItem { exec: Option>, process_ids: crate::async_hooks::AsyncResourceIds, pipe_ids: [crate::async_hooks::AsyncResourceIds; 3], + refed: bool, } fn cp_lookup_cp_bits(handle: u64) -> Option { @@ -1942,51 +1958,12 @@ pub(super) fn cp_live_stdin_queue_callback(handle: u64, callback_bits: u64) -> b false } -// ============================================================================ -// Event-loop integration hooks (wired from lib.rs / gc/mod.rs). -// ============================================================================ - -/// Whether any live child is keeping the event loop alive — OR'd into -/// `js_stdlib_has_active_handles`. -pub(crate) fn cp_reactor_has_live() -> bool { - CP_LIVE_COUNT.load(Ordering::Relaxed) > 0 -} - -/// GC mutable-root scanner: keep every live ChildProcess (and its reachable -/// stdio sub-objects + listener arrays) alive across collections, and rewrite -/// the stored pointer on evacuation. -pub(crate) fn cp_reactor_scan_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - if CP_LIVE_COUNT.load(Ordering::Relaxed) == 0 { - return; - } - if let Some(map) = cp_live_lock().as_mut() { - for lc in map.values_mut() { - visitor.visit_nanbox_u64_slot(&mut lc.cp_bits); - if lc.abort_signal_bits != 0 { - visitor.visit_nanbox_u64_slot(&mut lc.abort_signal_bits); - } - if lc.abort_listener_bits != 0 { - visitor.visit_nanbox_u64_slot(&mut lc.abort_listener_bits); - } - // #4912: keep the async exec/execFile callback closure alive until - // it fires on `close`. - if let Some(exec) = lc.exec.as_mut() { - visitor.visit_nanbox_u64_slot(&mut exec.cb_bits); - } - // #9493: stdin write/end callbacks waiting on the drain thread. - if let Some(stdin) = lc.stdin.as_mut() { - for callback in &mut stdin.callbacks { - visitor.visit_nanbox_u64_slot(callback); - } - } - } - } -} - +mod integration; mod kill; mod stdin_drain; #[cfg(all(test, windows))] #[path = "reactor/windows_kill_tests.rs"] mod windows_kill_tests; +pub(crate) use integration::*; pub(crate) use kill::*; use stdin_drain::*; diff --git a/crates/perry-runtime/src/child_process/reactor/integration.rs b/crates/perry-runtime/src/child_process/reactor/integration.rs new file mode 100644 index 0000000000..c0b746c9d9 --- /dev/null +++ b/crates/perry-runtime/src/child_process/reactor/integration.rs @@ -0,0 +1,62 @@ +//! Event-loop keepalive and GC integration for live child processes. + +use super::*; + +/// Whether any live child is keeping the event loop alive — OR'd into +/// `js_stdlib_has_active_handles`. +pub(crate) fn cp_reactor_has_live() -> bool { + CP_REFED_COUNT.load(Ordering::Relaxed) > 0 +} + +/// Toggle one live child's event-loop keepalive bit. Returns `false` after the +/// child has already left the registry. +pub(crate) fn cp_live_set_refed(handle: u64, refed: bool) -> bool { + { + let mut guard = cp_live_lock(); + let Some(child) = guard.as_mut().and_then(|map| map.get_mut(&handle)) else { + return false; + }; + if child.closed { + return false; + } + if child.refed == refed { + return true; + } + child.refed = refed; + } + if refed { + CP_REFED_COUNT.fetch_add(1, Ordering::SeqCst); + crate::event_pump::js_notify_main_thread(); + } else { + CP_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } + true +} + +/// Keep every live ChildProcess (and its reachable stdio sub-objects and +/// listener arrays) alive across collections, rewriting stored pointers after +/// evacuation. +pub(crate) fn cp_reactor_scan_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + if CP_LIVE_COUNT.load(Ordering::Relaxed) == 0 { + return; + } + if let Some(map) = cp_live_lock().as_mut() { + for child in map.values_mut() { + visitor.visit_nanbox_u64_slot(&mut child.cp_bits); + if child.abort_signal_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut child.abort_signal_bits); + } + if child.abort_listener_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut child.abort_listener_bits); + } + if let Some(exec) = child.exec.as_mut() { + visitor.visit_nanbox_u64_slot(&mut exec.cb_bits); + } + if let Some(stdin) = child.stdin.as_mut() { + for callback in &mut stdin.callbacks { + visitor.visit_nanbox_u64_slot(callback); + } + } + } + } +} diff --git a/crates/perry-runtime/src/node_submodules/consumers.rs b/crates/perry-runtime/src/node_submodules/consumers.rs index f91bb9a339..33a7f0dcea 100644 --- a/crates/perry-runtime/src/node_submodules/consumers.rs +++ b/crates/perry-runtime/src/node_submodules/consumers.rs @@ -562,6 +562,26 @@ fn consume_stream(kind: ConsumerKind, stream: f64) -> f64 { promise_rejected(invalid_stream_error()) } +/// Bun's subprocess-readable convenience methods share the exact same +/// event-driven collector as `node:stream/consumers`. Keeping these as small +/// crate-visible entry points avoids a second buffering implementation while +/// preserving the public module thunk ABI below. +pub(crate) fn consume_text(stream: f64) -> f64 { + consume_stream(ConsumerKind::Text, stream) +} + +pub(crate) fn consume_json(stream: f64) -> f64 { + consume_stream(ConsumerKind::Json, stream) +} + +pub(crate) fn consume_array_buffer(stream: f64) -> f64 { + consume_stream(ConsumerKind::ArrayBuffer, stream) +} + +pub(crate) fn consume_bytes(stream: f64) -> f64 { + consume_stream(ConsumerKind::Bytes, stream) +} + extern "C" fn consumer_collect_rejected(closure: *const ClosureHeader, reason: f64) -> f64 { let promise = js_closure_get_capture_ptr(closure, 0) as *mut crate::Promise; crate::promise::js_promise_reject(promise, reason); diff --git a/crates/perry-runtime/src/node_submodules/mod.rs b/crates/perry-runtime/src/node_submodules/mod.rs index e58f3f120c..1f473aaf88 100644 --- a/crates/perry-runtime/src/node_submodules/mod.rs +++ b/crates/perry-runtime/src/node_submodules/mod.rs @@ -128,6 +128,7 @@ use hono_jsx::{ thunk_hono_render_to_readable_stream, thunk_hono_suspense, }; +pub(crate) use consumers::{consume_array_buffer, consume_bytes, consume_json, consume_text}; use consumers::{ thunk_consumers_arrayBuffer, thunk_consumers_blob, thunk_consumers_buffer, thunk_consumers_bytes, thunk_consumers_json, thunk_consumers_text, diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 20933f33cd..3ef61b5af2 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -3,10 +3,12 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option { match (module, prop) { // Bun global/module surface (#9599). - ("bun", "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth") => { - Some(1) - } - ("bun", "write") => Some(2), + ( + "bun", + "Glob" | "Terminal" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" + | "stringWidth", + ) => Some(1), + ("bun", "spawn" | "write") => Some(2), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), ("bun:ffi", "ptr" | "CString" | "CFunction" | "linkSymbols") => Some(1), @@ -287,10 +289,12 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "bun", &[ ("Glob", 1), + ("Terminal", 1), ("file", 1), ("fileURLToPath", 1), ("hash", 1), ("pathToFileURL", 1), + ("spawn", 2), ("stringWidth", 1), ("write", 2), ], diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 72388da1d9..118b4da066 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -38,7 +38,15 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st if module == "bun" && matches!( prop, - "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + "Glob" + | "Terminal" + | "file" + | "fileURLToPath" + | "hash" + | "pathToFileURL" + | "spawn" + | "stringWidth" + | "write" ) { return true; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 833a53f044..8bf0cbc9f5 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -85,10 +85,12 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "bun", &[ "Glob", + "Terminal", "file", "fileURLToPath", "hash", "pathToFileURL", + "spawn", "stringWidth", "write", ], diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 3e7757628e..2fb9713c9d 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1630,11 +1630,13 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati // "bun"` share this one enumerable native-module surface. "bun" => Some(&[ b"Glob", + b"Terminal", b"file", b"fileURLToPath", b"hash", b"isStandaloneExecutable", b"pathToFileURL", + b"spawn", b"stderr", b"stdin", b"stdout", diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index 3eaeed3cb3..fd610ddd8e 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -272,6 +272,8 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name typed_kind ); match (module_name, method_name) { + ("bun", "spawn") => crate::bun_compat::js_bun_spawn(arg(0), arg(1)), + ("bun", "Terminal") => crate::bun_compat::js_bun_terminal_new(arg(0)), ("bun", "stringWidth") => crate::bun_compat::js_bun_string_width(arg(0), arg(1)), ("bun", "hash") => crate::bun_compat::js_bun_hash(arg(0), arg(1)), ("bun", "file") => crate::bun_compat::js_bun_file(arg(0)), diff --git a/crates/perry-runtime/src/pty/mod.rs b/crates/perry-runtime/src/pty/mod.rs index 439be74300..b30ed0ce4d 100644 --- a/crates/perry-runtime/src/pty/mod.rs +++ b/crates/perry-runtime/src/pty/mod.rs @@ -34,7 +34,7 @@ pub(crate) mod reactor; #[cfg(unix)] pub use unix_impl::js_pty_spawn; #[cfg(unix)] -pub(crate) use unix_impl::pty_emit; +pub(crate) use unix_impl::{pty_emit, pty_handle_of, pty_register}; #[cfg(unix)] mod unix_impl { @@ -141,7 +141,7 @@ mod unix_impl { /// Read the reactor registry key (`__ptyHandle`) off an IPty. `None` for /// a foreign object. - fn pty_handle_of(this: f64) -> Option { + pub(crate) fn pty_handle_of(this: f64) -> Option { let h = cp_get_field(this, b"__ptyHandle"); if JSValue::from_bits(h.to_bits()).is_undefined() { return None; diff --git a/crates/perry-runtime/src/pty/native.rs b/crates/perry-runtime/src/pty/native.rs index 34ac7f4bba..5737dabb0d 100644 --- a/crates/perry-runtime/src/pty/native.rs +++ b/crates/perry-runtime/src/pty/native.rs @@ -261,6 +261,24 @@ pub(crate) fn resize_pty(master: RawFd, cols: u16, rows: u16) -> bool { unsafe { libc::ioctl(master, libc::TIOCSWINSZ as _, &ws) == 0 } } +/// Switch the slave-side line discipline through the master descriptor. A +/// disabled raw mode restores the same deterministic cooked settings used at +/// PTY creation, which is the useful Bun.Terminal contract for reusable +/// terminals. +pub(crate) fn set_raw_mode(master: RawFd, enabled: bool) -> bool { + let mut term = if enabled { + let mut current: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(master, &mut current) } != 0 { + return false; + } + unsafe { libc::cfmakeraw(&mut current) }; + current + } else { + sane_termios() + }; + unsafe { libc::tcsetattr(master, libc::TCSANOW, &mut term) == 0 } +} + /// `kill(2)` — deliver `signo` to `pid`. pub(crate) fn signal_pid(pid: i32, signo: i32) -> bool { unsafe { libc::kill(pid, signo) == 0 } diff --git a/crates/perry-runtime/src/pty/reactor.rs b/crates/perry-runtime/src/pty/reactor.rs index f56afec0c7..ec99871d39 100644 --- a/crates/perry-runtime/src/pty/reactor.rs +++ b/crates/perry-runtime/src/pty/reactor.rs @@ -34,6 +34,10 @@ static PTY_NEXT_LIVE_ID: AtomicU64 = AtomicU64::new(1); /// gate for the pump and the active-handle check. static PTY_LIVE_COUNT: AtomicU64 = AtomicU64::new(0); +/// Live PTYs which currently keep the event loop alive. Unreferenced PTYs are +/// still pumped and rooted while another handle drives the runtime. +static PTY_REFED_COUNT: AtomicU64 = AtomicU64::new(0); + /// An event produced by a pty's background threads, consumed by the pump. enum PtyEvent { /// One master-side read chunk. @@ -66,6 +70,8 @@ struct LivePty { exited: Option<(Option, Option)>, /// Whether `onExit` has been fired (terminal state). closed: bool, + /// Whether this PTY currently contributes an active event-loop handle. + refed: bool, } static PTY_LIVE: Mutex>> = Mutex::new(None); @@ -149,11 +155,13 @@ pub(super) fn pty_register_live(ipty: f64, child: native::PtyChild) -> u64 { eof: false, exited: None, closed: false, + refed: true, }, ); } crate::stdlib_pump::register_runtime_pump(1, pty_reactor_pump_extern); PTY_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); + PTY_REFED_COUNT.fetch_add(1, Ordering::SeqCst); pty_spawn_reader(handle, child.master); pty_spawn_waiter(handle, child.pid); crate::event_pump::js_notify_main_thread(); @@ -161,7 +169,7 @@ pub(super) fn pty_register_live(ipty: f64, child: native::PtyChild) -> u64 { } /// Write `bytes` to a live pty's master. Returns whether the write succeeded. -pub(super) fn pty_live_write(handle: u64, bytes: &[u8]) -> bool { +pub(crate) fn pty_live_write(handle: u64, bytes: &[u8]) -> bool { let master = { let guard = pty_live_lock(); match guard.as_ref().and_then(|m| m.get(&handle)) { @@ -193,7 +201,7 @@ pub(super) fn pty_live_write(handle: u64, bytes: &[u8]) -> bool { } /// `TIOCSWINSZ` a live pty. Returns whether the ioctl succeeded. -pub(super) fn pty_live_resize(handle: u64, cols: u16, rows: u16) -> bool { +pub(crate) fn pty_live_resize(handle: u64, cols: u16, rows: u16) -> bool { let master = { let guard = pty_live_lock(); match guard.as_ref().and_then(|m| m.get(&handle)) { @@ -204,8 +212,20 @@ pub(super) fn pty_live_resize(handle: u64, cols: u16, rows: u16) -> bool { native::resize_pty(master, cols, rows) } +/// Toggle raw mode on a live PTY. +pub(crate) fn pty_live_set_raw_mode(handle: u64, enabled: bool) -> bool { + let master = { + let guard = pty_live_lock(); + match guard.as_ref().and_then(|m| m.get(&handle)) { + Some(lp) if !lp.closed => lp.master, + _ => return false, + } + }; + native::set_raw_mode(master, enabled) +} + /// Signal a live pty child. Skipped once reaped (the pid may be recycled). -pub(super) fn pty_live_kill(handle: u64, signo: i32) -> bool { +pub(crate) fn pty_live_kill(handle: u64, signo: i32) -> bool { let pid = { let guard = pty_live_lock(); match guard.as_ref().and_then(|m| m.get(&handle)) { @@ -216,6 +236,30 @@ pub(super) fn pty_live_kill(handle: u64, signo: i32) -> bool { native::signal_pid(pid, signo) } +/// Toggle one PTY's event-loop keepalive bit. Calls are idempotent. +pub(crate) fn pty_live_set_refed(handle: u64, refed: bool) -> bool { + { + let mut guard = pty_live_lock(); + let Some(pty) = guard.as_mut().and_then(|map| map.get_mut(&handle)) else { + return false; + }; + if pty.closed { + return false; + } + if pty.refed == refed { + return true; + } + pty.refed = refed; + } + if refed { + PTY_REFED_COUNT.fetch_add(1, Ordering::SeqCst); + crate::event_pump::js_notify_main_thread(); + } else { + PTY_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } + true +} + /// Decode `bytes` (with the pty's carry-over prefix) into a String, saving an /// incomplete trailing UTF-8 sequence back into `carry` for the next chunk. /// Interior invalid bytes are replaced with U+FFFD. @@ -352,6 +396,7 @@ fn pty_reactor_pump_inner() { master: RawFd, code: Option, signal: Option, + refed: bool, } let to_close: Vec = { let mut guard = pty_live_lock(); @@ -370,6 +415,7 @@ fn pty_reactor_pump_inner() { master: lp.master, code, signal, + refed: lp.refed, }); } } @@ -402,6 +448,9 @@ fn pty_reactor_pump_inner() { map.remove(&item.handle); } PTY_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); + if item.refed { + PTY_REFED_COUNT.fetch_sub(1, Ordering::SeqCst); + } } } @@ -412,7 +461,7 @@ fn pty_reactor_pump_inner() { /// Whether any live pty is keeping the event loop alive — OR'd into /// `js_stdlib_has_active_handles`. pub(crate) fn pty_reactor_has_live() -> bool { - PTY_LIVE_COUNT.load(Ordering::Relaxed) > 0 + PTY_REFED_COUNT.load(Ordering::Relaxed) > 0 } /// GC mutable-root scanner: keep every live IPty (and, through its fields, diff --git a/crates/perry/tests/issue_9601_bun_spawn.rs b/crates/perry/tests/issue_9601_bun_spawn.rs new file mode 100644 index 0000000000..8d6507eb5d --- /dev/null +++ b/crates/perry/tests/issue_9601_bun_spawn.rs @@ -0,0 +1,216 @@ +//! End-to-end coverage for Bun.spawn and Bun.Terminal (#9601). + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--platform") + .arg("bun") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed ({:?})\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn bun_spawn_streams_stdio_lifecycle_and_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { spawn } from "bun"; +import { closeSync, openSync, readFileSync } from "node:fs"; + +async function main() { + const basic = spawn([process.execPath, "-e", 'process.stdout.write("ok")'], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + cwd: process.cwd(), + env: process.env, + windowsHide: true, + }); + basic.unref(); + basic.ref(); + const basicText = await basic.stdout.text(); + const basicExit = await basic.exited; + console.log("BASIC:" + basicText + ":" + basicExit + ":" + typeof basic.pid); + console.log("STREAMS:" + ["text", "json", "arrayBuffer", "bytes"].every((key) => typeof basic.stdout[key] === "function")); + + let callback = "missing"; + await using objectChild = spawn({ + cmd: ["/bin/sh", "-lc", "printf object-ok"], + stdout: "pipe", + stderr: "pipe", + onExit(_child, code, signal, error) { + callback = code + ":" + signal + ":" + error; + }, + }); + console.log("OBJECT:" + await objectChild.stdout.text() + ":" + await objectChild.exited + ":" + callback); + + const argvChild = spawn(["/bin/sh", "-c", 'printf %s "$0"'], { + argv0: "custom-argv0", + stdout: "pipe", + stderr: "pipe", + }); + console.log("ARGV0:" + await argvChild.stdout.text() + ":" + await argvChild.exited); + + const fdPath = process.cwd() + "/fd-output.txt"; + const fd = openSync(fdPath, "w"); + const fdChild = spawn(["/bin/sh", "-lc", "printf fd-ok"], { + stdin: "ignore", + stdout: fd, + stderr: "pipe", + }); + await fdChild.exited; + closeSync(fd); + console.log("FD:" + readFileSync(fdPath, "utf8")); + + const filePath = process.cwd() + "/bun-file-output.txt"; + const fileChild = spawn(["/bin/sh", "-lc", "printf file-ok"], { + stdin: "ignore", + stdout: Bun.file(filePath), + stderr: "pipe", + }); + await fileChild.exited; + console.log("FILE:" + await Bun.file(filePath).text()); + + const killedChild = spawn(["/bin/sh", "-lc", "sleep 30"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + detached: true, + }); + const delivered = killedChild.kill("SIGTERM"); + const killedExit = await killedChild.exited; + console.log("KILL:" + delivered + ":" + killedChild.killed + ":" + typeof killedExit); + + try { + spawn(["/definitely/missing/perry-bun-spawn"]); + } catch (error: any) { + console.log("ERROR:" + error.code); + } +} + +main(); +"#, + ); + + for expected in [ + "BASIC:ok:0:number", + "STREAMS:true", + "OBJECT:object-ok:0:0:null:undefined", + "ARGV0:custom-argv0:0", + "FD:fd-ok", + "FILE:file-ok", + "KILL:true:true:number", + "ERROR:ENOENT", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?} in {stdout:?}" + ); + } +} + +#[test] +fn bun_terminal_attaches_a_posix_pty() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import { spawn, Terminal } from "bun"; + +async function main() { + let output = ""; + let exitSeen = "missing"; + let drains = 0; + await using globalTerminal = new Bun.Terminal(); + console.log("GLOBAL-TERMINAL:" + typeof globalTerminal.resize); + await using terminal = new Terminal({ + cols: 80, + rows: 24, + data(_terminal, bytes) { + output += Buffer.from(bytes).toString(); + }, + exit(_terminal, code, signal, error) { + exitSeen = code + ":" + signal + ":" + error; + }, + drain() { + drains++; + }, + }); + + const child = spawn(["/bin/sh", "-lc", "printf pty-ok"], { terminal }); + terminal.write(""); + terminal.setRawMode(true); + terminal.setRawMode(false); + terminal.unref(); + terminal.ref(); + child.unref(); + child.ref(); + const code = await child.exited; + terminal.resize(100, 30); + + console.log("PTY:" + output.includes("pty-ok") + ":" + code + ":" + typeof child.pid); + console.log("SIZE:" + terminal.cols + "x" + terminal.rows); + console.log("CALLBACKS:" + exitSeen + ":" + drains); + console.log("METHODS:" + ["write", "resize", "setRawMode", "ref", "unref", "close"].every((key) => typeof terminal[key] === "function")); + + const killed = spawn(["/bin/sh", "-lc", "sleep 30"], { terminal }); + killed.kill("SIGTERM"); + const killedCode = await killed.exited; + console.log("PTY-KILL:" + killed.exitCode + ":" + killed.signalCode + ":" + typeof killedCode); +} + +main(); +"#, + ); + + for expected in [ + "GLOBAL-TERMINAL:function", + "PTY:true:0:number", + "SIZE:100x30", + "CALLBACKS:0:null:undefined:1", + "METHODS:true", + "PTY-KILL:null:SIGTERM:number", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?} in {stdout:?}" + ); + } +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index ab019d8e95..56898d81d3 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -101,6 +101,12 @@ release number. `Bun.isStandaloneExecutable` is always `true`, because Perry produces standalone binaries. Unsupported properties are absent; a direct call to an unsupported `Bun.*` member raises Perry's Bun compatibility error. +`Bun.spawn` supports the command-array and `{ cmd, ...options }` forms, piped +output consumers such as `child.stdout.text()`, lifecycle controls, and raw-fd +or `Bun.file` stdio. On POSIX targets, `Bun.Terminal` can attach a subprocess +to Perry's native PTY implementation; ConPTY-backed terminals are not yet +available on Windows. + ## Embedding Assets Bake static files (an SPA `dist/`, images, JSON, fonts, …) into the standalone diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 7d6768c92a..02a1111d86 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -153,12 +153,24 @@ "verdict": "not_a_gc_pointer", "why": "Records only the smallest and largest address ever inserted into the sibling thread-local buffer registry, as a conservative min/max filter in front of that registry's hash lookup (#9176). The two usizes are compared (`addr >= lo && addr <= hi`) and never dereferenced, never handed out, and never used to reach an object; a value outside the range is rejected, a value inside falls through to the lookup that was already there. The range only ever widens, so it cannot go stale in the unsafe direction: a bound that no longer matches reality can only admit more addresses to the real lookup, never deny a registered one." }, + { + "file": "crates/perry-runtime/src/child_process/reactor.rs", + "name": "CP_LIVE_COUNT", + "verdict": "not_a_gc_pointer", + "why": "Atomic count of spawned children that have not reached close. It is only a lock-free emptiness gate for the reactor and root scanner; the ChildProcess values themselves live in CP_LIVE and are visited by cp_reactor_scan_roots_mut." + }, { "file": "crates/perry-runtime/src/child_process/reactor.rs", "name": "CP_NEXT_LIVE_ID", "verdict": "not_a_gc_pointer", "why": "Monotonic id counter for CP_LIVE keys. CP_LIVE itself is covered by cp_reactor_scan_roots_mut." }, + { + "file": "crates/perry-runtime/src/child_process/reactor.rs", + "name": "CP_REFED_COUNT", + "verdict": "not_a_gc_pointer", + "why": "Atomic count of live child-process handles that currently keep the event loop active. It stores only a scalar count; ChildProcess JS values live in CP_LIVE and are visited by cp_reactor_scan_roots_mut." + }, { "file": "crates/perry-runtime/src/closure/alloc.rs", "name": "CAPTURED_MISS_STREAK", @@ -411,6 +423,12 @@ "verdict": "test_only", "why": "#[cfg(test)] sink for the pty exit callback's two JSValues. Never compiled into a shipped binary." }, + { + "file": "crates/perry-runtime/src/pty/reactor.rs", + "name": "PTY_REFED_COUNT", + "verdict": "not_a_gc_pointer", + "why": "Atomic count of live PTY handles that currently keep the event loop active. It stores only a scalar count; PTY JS values live in PTY_LIVE and are visited by pty_reactor_scan_roots_mut." + }, { "file": "crates/perry-runtime/src/regex.rs", "name": "REGEX_POINTERS", From 74aaa0b2d1d75cd2ae8f8e478138009c32646811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 13:28:18 +0200 Subject: [PATCH 2/2] docs: add changelog fragment for PR 9622 --- changelog.d/9622-bun-spawn-terminal.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/9622-bun-spawn-terminal.md diff --git a/changelog.d/9622-bun-spawn-terminal.md b/changelog.d/9622-bun-spawn-terminal.md new file mode 100644 index 0000000000..0c6bb94dba --- /dev/null +++ b/changelog.d/9622-bun-spawn-terminal.md @@ -0,0 +1,7 @@ +# Bun subprocess and terminal support + +The `bun` compatibility layer now implements `Bun.spawn` in array and object +forms, including consumable output streams, process lifecycle controls, stdio +file descriptors and `Bun.file` sinks, and structured spawn errors. On POSIX +targets, `Bun.Terminal` attaches subprocesses to a native PTY with data, exit, +drain, write, resize, raw-mode, ref/unref, close, and async-disposal support.