diff --git a/Cargo.lock b/Cargo.lock index 3192d55a58..d036a0ac70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -220,7 +220,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1269,7 +1269,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1281,7 +1281,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -1931,7 +1931,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2236,7 +2236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2676,7 +2676,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -2797,7 +2797,7 @@ dependencies = [ "gobject-sys 0.22.6", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3940,7 +3940,7 @@ checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console 0.16.4", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -4062,7 +4062,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4691,6 +4691,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mimalloc" version = "0.1.52" @@ -4939,6 +4961,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "node-semver" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" +dependencies = [ + "bytecount", + "miette", + "nom 7.1.3", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "nom" version = "7.1.3" @@ -5006,7 +5041,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6345,6 +6380,7 @@ dependencies = [ "libmimalloc-sys", "mach2", "mimalloc", + "node-semver", "notify", "perry-diagnostics", "perry-dispatch", @@ -6363,12 +6399,16 @@ dependencies = [ "temporal_rs", "thiserror 1.0.69", "timezone_provider", + "toml", "unicode-normalization", "unicode-segmentation", + "unsafe-libyaml", "url", "windows-sys 0.61.2", "writeable", "x509-cert", + "xxhash-rust", + "zstd", ] [[package]] @@ -7836,7 +7876,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8477,7 +8517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8754,7 +8794,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8886,7 +8926,7 @@ dependencies = [ "swc_eq_ignore_macros", "swc_visit", "tracing", - "unicode-width", + "unicode-width 0.2.2", "url", ] @@ -9304,7 +9344,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9901,7 +9941,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9973,6 +10013,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -10513,7 +10559,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] 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. diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 0daeb23e82..af7d0c6dca 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1102,8 +1102,18 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // Implementation: perry-runtime `bun_compat`. method("bun", "stringWidth", false, None), method("bun", "hash", false, None), + method("bun", "deepEquals", false, None), + method("bun", "stripANSI", false, None), + method("bun", "wrapAnsi", false, None), + method("bun", "which", false, None), + method("bun", "zstdDecompress", true, None), + method("bun", "zstdDecompressSync", false, None), + method("bun", "gc", false, None), + method("bun", "generateHeapSnapshot", 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 @@ -1116,6 +1126,10 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ property("bun", "stderr"), property("bun", "version"), property("bun", "isStandaloneExecutable"), + property("bun", "YAML"), + property("bun", "TOML"), + property("bun", "semver"), + property("bun", "JSONL"), // --- qs (issue #8751) --- // Native nested query-string codec. This keeps Stripe's request encoder // off qs' legacy get-intrinsic/ES-shims dependency chain. 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..cbab9a0ee5 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, @@ -28,6 +46,78 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ args: &[NA_F64, NA_F64], ret: NR_F64, }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "deepEquals", + class_filter: None, + runtime: "js_bun_deep_equals", + args: &[NA_F64, NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "stripANSI", + class_filter: None, + runtime: "js_bun_strip_ansi", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "wrapAnsi", + class_filter: None, + runtime: "js_bun_wrap_ansi", + args: &[NA_F64, NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "which", + class_filter: None, + runtime: "js_bun_which", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "zstdDecompress", + class_filter: None, + runtime: "js_bun_zstd_decompress", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "zstdDecompressSync", + class_filter: None, + runtime: "js_bun_zstd_decompress_sync", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "gc", + class_filter: None, + runtime: "js_bun_gc", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "generateHeapSnapshot", + class_filter: None, + runtime: "js_bun_generate_heap_snapshot", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-hir/src/lower/expr_call/module_class_static.rs b/crates/perry-hir/src/lower/expr_call/module_class_static.rs index ed8f4652d0..e9ac036358 100644 --- a/crates/perry-hir/src/lower/expr_call/module_class_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_class_static.rs @@ -75,6 +75,14 @@ pub(super) fn try_module_class_static( // is a String method on the property value. | ("os", "EOL") | ("os", "devNull") + // Bun's utility namespaces are object-valued + // exports. Their methods are native closures, + // not class statics on the `bun` dispatcher. + | ("bun", "YAML") + | ("bun", "TOML") + | ("bun", "semver") + | ("bun", "JSONL") + | ("bun", "hash") ); // Unimplemented-API gate (#463) for the chained // `mod.X.Y()` case. The lower_member gate fires diff --git a/crates/perry-hir/src/lower/expr_call/module_static.rs b/crates/perry-hir/src/lower/expr_call/module_static.rs index 0606625b65..822d95bd50 100644 --- a/crates/perry-hir/src/lower/expr_call/module_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_static.rs @@ -77,6 +77,14 @@ pub(super) fn try_module_static_methods( method_ident.sym.as_ref(), "stringWidth" | "hash" + | "deepEquals" + | "stripANSI" + | "wrapAnsi" + | "which" + | "zstdDecompress" + | "zstdDecompressSync" + | "gc" + | "generateHeapSnapshot" | "file" | "write" | "pathToFileURL" diff --git a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs index 1b3f7f9fe9..64ee68890a 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs @@ -112,6 +112,18 @@ pub(super) fn try_imported_module_dispatch( if module_name == "bun:ffi" && imported_method == Some("read") { return Ok(Err(args)); } + // `import { YAML } from "bun"; YAML.parse(...)` and the other + // #9600 value namespaces call closures stored on that value. + // Do not reinterpret `.parse`/`.order`/`.xxHash64` as a + // top-level `bun` native method. + if module_name == "bun" + && matches!( + imported_method, + Some("YAML" | "TOML" | "semver" | "JSONL" | "hash") + ) + { + return Ok(Err(args)); + } if module_name.strip_prefix("node:").unwrap_or(module_name) == "vm" && imported_method.is_none() && method_name == "Module" diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 5d2e67b391..23dd10c385 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -326,6 +326,18 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re | "stderr" | "stringWidth" | "hash" + | "YAML" + | "TOML" + | "semver" + | "JSONL" + | "deepEquals" + | "stripANSI" + | "wrapAnsi" + | "which" + | "zstdDecompress" + | "zstdDecompressSync" + | "gc" + | "generateHeapSnapshot" | "file" | "write" | "Glob" 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/Cargo.toml b/crates/perry-runtime/Cargo.toml index cfece76e13..2307cbc61f 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -22,7 +22,7 @@ crate-type = ["rlib"] # actually needs (see optimized_libs.rs), so the heavy subsystems below # (regex engine, Temporal, URL/IDNA, normalize, segmenter) are *opt-in per # app* and absent from binaries that never use them. -default = ["full", "regex-engine", "temporal", "url-engine", "string-normalize", "intl-segmenter", "intl-namespace", "global-math", "global-json", "global-reflect", "global-atomics", "global-url", "global-text", "global-websocket", "global-webcrypto", "global-webfetch", "proc-ipc", "intl-locale", "intl-datetime", "diagnostics", "mod-dgram", "mod-http2-constants", "mod-node-test", "dyn-eval", "keepalive-anchors", "alloc-mimalloc"] +default = ["full", "regex-engine", "temporal", "url-engine", "string-normalize", "intl-segmenter", "intl-namespace", "global-math", "global-json", "global-reflect", "global-atomics", "global-url", "global-text", "global-websocket", "global-webcrypto", "global-webfetch", "proc-ipc", "intl-locale", "intl-datetime", "diagnostics", "mod-dgram", "mod-http2-constants", "mod-node-test", "bun-cli-utils", "dyn-eval", "keepalive-anchors", "alloc-mimalloc"] # Compile the ~490 `#[used]` keep-alive anchor statics (`KEEP_*`, `keep!`, # `K*`/`G*` fn-pointer statics) that pin codegen-facing `#[no_mangle]` entry # points so they survive into the staticlib archive. Without these anchors, @@ -105,6 +105,17 @@ mod-http2-constants = [] # `process.getBuiltinModule` (whose target is only known at runtime), the same # way `mod-http2-constants` does. Pure cfg gate, no extra deps. mod-node-test = [] +# Bun's small data/CLI utility pack (#9600): YAML/TOML parsing, node-semver +# compatible ranges, xxHash64, and zstd decompression. The compiler enables +# this as a unit whenever the `"bun"` module or the opt-in Bun global is used; +# unrelated auto-optimized binaries do not compile or link these backends. +bun-cli-utils = [ + "dep:node-semver", + "dep:toml", + "dep:unsafe-libyaml", + "dep:xxhash-rust", + "dep:zstd", +] # Cold-path diagnostic JSON serializers (~67 KB of code + the `serde_json` # pulled only by them, which dead-strips when unreferenced): GC cycle telemetry # (`PERRY_GC_DIAG`), typed-feedback trace dump (`PERRY_TYPED_FEEDBACK`), the v8 @@ -310,6 +321,11 @@ lazy_static.workspace = true # only on swc + perry-diagnostics — no cycle back into perry-runtime. perry-parser = { path = "../perry-parser", optional = true } perry-diagnostics = { path = "../perry-diagnostics", optional = true } +node-semver = { version = "2.2.0", optional = true } +toml = { workspace = true, optional = true } +unsafe-libyaml = { version = "0.2.11", optional = true } +xxhash-rust = { version = "0.8.15", features = ["xxh64"], optional = true } +zstd = { workspace = true, optional = true } # TC39 Temporal API (#4686). Pure-Rust date/time engine (the same one V8 # uses). We write no calendar/duration/timezone math — temporal_rs owns it; diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs new file mode 100644 index 0000000000..3e75b4a25d --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -0,0 +1,1323 @@ +//! Bun's small CLI/data utility surface (#9600). +//! +//! Object-valued exports (`YAML`, `TOML`, `semver`, and `JSONL`) are ordinary +//! Perry objects containing native closures. This deliberately keeps their +//! nested method calls on the normal dynamic-property path, while the direct +//! exports use the native module call table. + +use super::{ + bool_value, boxed_str, is_string_value, is_undefined_or_null, key_ptr, object_field, + payload_bytes, promise_rejected, promise_value, value_to_string, +}; +use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; +use crate::gc::{RootedValues, RuntimeHandle, RuntimeHandleScope}; +use crate::object::{js_object_alloc, js_object_set_field_by_name}; +use crate::string::js_string_from_bytes; +use crate::value::JSValue; +use std::cmp::Ordering; + +fn null_value() -> f64 { + f64::from_bits(JSValue::null().bits()) +} + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn error_value(message: &str) -> f64 { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_error_new_with_message(message); + f64::from_bits(JSValue::pointer(error as *const u8).bits()) +} + +fn syntax_error_value(message: &str) -> f64 { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_syntaxerror_new(message); + f64::from_bits(JSValue::pointer(error as *const u8).bits()) +} + +fn throw_type_error(message: &str) -> ! { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_typeerror_new(message); + crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) +} + +fn number_arg(value: f64) -> Option { + let value = JSValue::from_bits(value.to_bits()); + if value.is_int32() { + Some(value.as_int32() as f64) + } else if value.is_number() { + Some(value.as_number()) + } else { + None + } +} + +fn closure1(name: &str, func: extern "C" fn(*const ClosureHeader, f64) -> f64) -> f64 { + js_register_closure_arity(func as *const u8, 1); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, 1); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn closure2( + name: &str, + func: extern "C" fn(*const ClosureHeader, f64, f64) -> f64, + length: u32, +) -> f64 { + js_register_closure_arity(func as *const u8, 2); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, length); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn closure3( + name: &str, + func: extern "C" fn(*const ClosureHeader, f64, f64, f64) -> f64, + length: u32, +) -> f64 { + js_register_closure_arity(func as *const u8, 3); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, length); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn rooted_object_ptr(handle: &RuntimeHandle<'_>) -> *mut crate::object::ObjectHeader { + JSValue::from_bits(handle.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _ +} + +/// Store a property while keeping both the target and value current if key +/// allocation triggers a moving collection. +fn set_rooted_field( + scope: &RuntimeHandleScope, + object: &RuntimeHandle<'_>, + key: &[u8], + value: f64, +) { + let value = scope.root_nanbox_f64(value); + let key = key_ptr(key); + js_object_set_field_by_name(rooted_object_ptr(object), key, value.get_nanbox_f64()); +} + +fn namespace_object(fields: &[(&[u8], f64)]) -> f64 { + let scope = RuntimeHandleScope::new(); + let object = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer(js_object_alloc(0, fields.len() as u32) as *const u8).bits(), + )); + for (key, value) in fields { + set_rooted_field(&scope, &object, key, *value); + } + object.get_nanbox_f64() +} + +// --------------------------------------------------------------------------- +// Object-valued exports +// --------------------------------------------------------------------------- + +extern "C" fn yaml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { + yaml_parse(input) +} + +extern "C" fn yaml_stringify_closure( + _closure: *const ClosureHeader, + input: f64, + replacer: f64, + space: f64, +) -> f64 { + yaml_stringify(input, replacer, space) +} + +pub fn js_bun_yaml() -> f64 { + namespace_object(&[ + (b"parse", closure1("parse", yaml_parse_closure)), + ( + b"stringify", + closure3("stringify", yaml_stringify_closure, 1), + ), + ]) +} + +extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { + let source = value_to_string(input); + // `Value::from_str` in toml 1.x parses a single TOML value expression; + // Bun.TOML.parse consumes a complete document, whose root is a table. + let parsed = match toml::from_str::(&source) { + Ok(parsed) => parsed, + Err(error) => crate::exception::js_throw(syntax_error_value(&format!( + "Failed to parse TOML: {error}" + ))), + }; + let json = match serde_json::to_string(&parsed) { + Ok(json) => json, + Err(error) => crate::exception::js_throw(syntax_error_value(&format!( + "Failed to convert TOML value: {error}" + ))), + }; + let source = js_string_from_bytes(json.as_ptr(), json.len() as u32); + match unsafe { crate::json::js_json_parse_result(source) } { + Ok(value) => f64::from_bits(value.bits()), + Err(error) => crate::exception::js_throw(error), + } +} + +pub fn js_bun_toml() -> f64 { + namespace_object(&[(b"parse", closure1("parse", toml_parse_closure))]) +} + +fn normalize_semver_version(input: &str) -> &str { + input + .trim() + .strip_prefix('v') + .or_else(|| input.trim().strip_prefix('=')) + .unwrap_or_else(|| input.trim()) +} + +extern "C" fn semver_order_closure(_closure: *const ClosureHeader, left: f64, right: f64) -> f64 { + let left_source = value_to_string(left); + let right_source = value_to_string(right); + let left = match node_semver::Version::parse(normalize_semver_version(&left_source)) { + Ok(version) => version, + Err(_) => crate::exception::js_throw(error_value(&format!( + "Invalid SemVer: {}", + left_source.trim() + ))), + }; + let right = match node_semver::Version::parse(normalize_semver_version(&right_source)) { + Ok(version) => version, + Err(_) => crate::exception::js_throw(error_value(&format!( + "Invalid SemVer: {}", + right_source.trim() + ))), + }; + match left.cmp(&right) { + Ordering::Less => -1.0, + Ordering::Equal => 0.0, + Ordering::Greater => 1.0, + } +} + +extern "C" fn semver_satisfies_closure( + _closure: *const ClosureHeader, + version: f64, + range: f64, +) -> f64 { + let version = value_to_string(version); + let range = value_to_string(range); + let Ok(version) = node_semver::Version::parse(normalize_semver_version(&version)) else { + return bool_value(false); + }; + let range = range.trim(); + if range.is_empty() || range == "*" || range.eq_ignore_ascii_case("latest") { + return bool_value(!version.is_prerelease()); + } + let satisfied = node_semver::Range::parse(range) + .map(|range| range.satisfies(&version)) + .unwrap_or(false); + bool_value(satisfied) +} + +pub fn js_bun_semver() -> f64 { + namespace_object(&[ + (b"order", closure2("order", semver_order_closure, 2)), + ( + b"satisfies", + closure2("satisfies", semver_satisfies_closure, 2), + ), + ]) +} + +extern "C" fn jsonl_parse_chunk_closure( + _closure: *const ClosureHeader, + input: f64, + start: f64, + end: f64, +) -> f64 { + jsonl_parse_chunk(input, start, end) +} + +pub fn js_bun_jsonl() -> f64 { + namespace_object(&[( + b"parseChunk", + closure3("parseChunk", jsonl_parse_chunk_closure, 1), + )]) +} + +// --------------------------------------------------------------------------- +// Equality, terminal strings, executable lookup +// --------------------------------------------------------------------------- + +#[no_mangle] +pub extern "C" fn js_bun_deep_equals(left: f64, right: f64, strict: f64) -> f64 { + if !is_undefined_or_null(strict) && crate::value::js_is_truthy(strict) != 0 { + crate::builtins::js_util_is_deep_strict_equal(left, right) + } else { + crate::builtins::js_util_is_deep_strict_equal_skip_prototype(left, right) + } +} + +fn strip_ansi_text(input: &str) -> String { + let chars: Vec = input.chars().collect(); + let mut out = String::with_capacity(input.len()); + let mut index = 0usize; + while index < chars.len() { + if chars[index] != '\u{1b}' { + out.push(chars[index]); + index += 1; + continue; + } + if index + 1 >= chars.len() { + break; + } + match chars[index + 1] { + '[' => { + index += 2; + while index < chars.len() { + let final_byte = chars[index] as u32; + index += 1; + if (0x40..=0x7e).contains(&final_byte) { + break; + } + } + } + ']' => { + index += 2; + while index < chars.len() { + if matches!(chars[index], '\u{7}' | '\u{9c}') { + index += 1; + break; + } + if chars[index] == '\u{1b}' + && index + 1 < chars.len() + && chars[index + 1] == '\\' + { + index += 2; + break; + } + index += 1; + } + } + _ => index += 1, + } + } + out +} + +#[no_mangle] +pub extern "C" fn js_bun_strip_ansi(input: f64) -> f64 { + boxed_str(strip_ansi_text(&value_to_string(input)).as_bytes()) +} + +fn visible_width(input: &str, ambiguous_is_narrow: bool) -> usize { + let cps: Vec = input.chars().map(u32::from).collect(); + super::bun_string_width(&cps, false, ambiguous_is_narrow) +} + +/// Split at display columns without cutting a CSI/OSC escape sequence. +fn hard_wrap_word(input: &str, columns: usize, ambiguous_is_narrow: bool) -> Vec { + if columns == 0 || visible_width(input, ambiguous_is_narrow) <= columns { + return vec![input.to_string()]; + } + let chars: Vec = input.chars().collect(); + let mut chunks = vec![String::new()]; + let mut width = 0usize; + let mut index = 0usize; + while index < chars.len() { + if chars[index] == '\u{1b}' { + let start = index; + index += 1; + if index < chars.len() && chars[index] == '[' { + index += 1; + while index < chars.len() { + let c = chars[index] as u32; + index += 1; + if (0x40..=0x7e).contains(&c) { + break; + } + } + } else if index < chars.len() && chars[index] == ']' { + index += 1; + while index < chars.len() { + let c = chars[index]; + index += 1; + if matches!(c, '\u{7}' | '\u{9c}') { + break; + } + if c == '\u{1b}' && index < chars.len() && chars[index] == '\\' { + index += 1; + break; + } + } + } + chunks.last_mut().unwrap().extend(&chars[start..index]); + continue; + } + let char_width = visible_width(&chars[index].to_string(), ambiguous_is_narrow); + if width > 0 && width + char_width > columns { + chunks.push(String::new()); + width = 0; + } + chunks.last_mut().unwrap().push(chars[index]); + width += char_width; + index += 1; + } + chunks +} + +fn wrap_word_paragraph( + input: &str, + columns: usize, + hard: bool, + trim: bool, + ambiguous_is_narrow: bool, +) -> String { + if input.is_empty() { + return String::new(); + } + if !trim { + let mut out = String::new(); + let mut width = 0usize; + for chunk in hard_wrap_word(input, columns, ambiguous_is_narrow) { + let chunk_width = visible_width(&chunk, ambiguous_is_narrow); + if width > 0 && width + chunk_width > columns { + out.push('\n'); + width = 0; + } + out.push_str(&chunk); + width += chunk_width; + } + return out; + } + + let words: Vec<&str> = input.split_whitespace().collect(); + let mut out = String::new(); + let mut line_width = 0usize; + for word in words { + let pieces = if hard { + hard_wrap_word(word, columns, ambiguous_is_narrow) + } else { + vec![word.to_string()] + }; + for (piece_index, piece) in pieces.into_iter().enumerate() { + let piece_width = visible_width(&piece, ambiguous_is_narrow); + let separator = usize::from(line_width > 0 && piece_index == 0); + if line_width > 0 && line_width + separator + piece_width > columns { + out.push('\n'); + line_width = 0; + } else if separator != 0 { + out.push(' '); + line_width += 1; + } + out.push_str(&piece); + line_width += piece_width; + } + } + out +} + +fn wrap_columns_paragraph( + input: &str, + columns: usize, + trim: bool, + ambiguous_is_narrow: bool, +) -> String { + let source = if trim { input.trim() } else { input }; + hard_wrap_word(source, columns, ambiguous_is_narrow).join("\n") +} + +#[no_mangle] +pub extern "C" fn js_bun_wrap_ansi(input: f64, columns: f64, options: f64) -> f64 { + let input = value_to_string(input); + let columns = number_arg(columns).unwrap_or(0.0).max(0.0) as usize; + if columns == 0 { + return boxed_str(input.as_bytes()); + } + let hard = + object_field(options, b"hard").is_some_and(|value| crate::value::js_is_truthy(value) != 0); + let trim = object_field(options, b"trim") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let word_wrap = object_field(options, b"wordWrap") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let ambiguous_is_narrow = object_field(options, b"ambiguousIsNarrow") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let output = input + .split('\n') + .map(|paragraph| { + if word_wrap { + wrap_word_paragraph(paragraph, columns, hard, trim, ambiguous_is_narrow) + } else { + wrap_columns_paragraph(paragraph, columns, trim, ambiguous_is_narrow) + } + }) + .collect::>() + .join("\n"); + boxed_str(output.as_bytes()) +} + +#[cfg(unix)] +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &std::path::Path) -> bool { + std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) +} + +#[no_mangle] +pub extern "C" fn js_bun_which(command: f64, options: f64) -> f64 { + let command = value_to_string(command); + if command.is_empty() { + return null_value(); + } + let command_path = std::path::Path::new(&command); + if command_path.components().count() > 1 { + return if is_executable(command_path) { + boxed_str(command.as_bytes()) + } else { + null_value() + }; + } + + let path = object_field(options, b"PATH") + .or_else(|| object_field(options, b"path")) + .map(value_to_string) + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + #[cfg(windows)] + let extensions: Vec = std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + .split(';') + .map(str::to_string) + .collect(); + for directory in std::env::split_paths(&path) { + let candidate = directory.join(&command); + if is_executable(&candidate) { + return boxed_str(candidate.to_string_lossy().as_bytes()); + } + #[cfg(windows)] + for extension in &extensions { + let candidate = directory.join(format!("{command}{extension}")); + if is_executable(&candidate) { + return boxed_str(candidate.to_string_lossy().as_bytes()); + } + } + } + null_value() +} + +// --------------------------------------------------------------------------- +// zstd, xxHash64, GC and heap snapshots +// --------------------------------------------------------------------------- + +fn zstd_decode(input: f64) -> Result { + let bytes = payload_bytes(input)?; + match zstd::stream::decode_all(bytes.as_slice()) { + Ok(decoded) => Ok(crate::node_submodules::consumers::bytes_to_buffer_value( + &decoded, + )), + Err(_) => Err(error_value("Decompression failed: InvalidZstdData")), + } +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress_sync(input: f64) -> f64 { + match zstd_decode(input) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress(input: f64) -> f64 { + match zstd_decode(input) { + Ok(value) => promise_value(value), + Err(error) => promise_rejected(error), + } +} + +extern "C" fn xxhash64_closure(_closure: *const ClosureHeader, input: f64, seed: f64) -> f64 { + let bytes = payload_bytes(input).unwrap_or_default(); + let hash = xxhash_rust::xxh64::xxh64(&bytes, super::hash_seed(seed)); + let bigint = crate::bigint::js_bigint_from_u64(hash); + crate::value::js_nanbox_bigint(bigint as i64) +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let hash = scope.root_nanbox_f64(value); + let xxhash = scope.root_nanbox_f64(closure2("xxHash64", xxhash64_closure, 1)); + let raw = JSValue::from_bits(hash.get_nanbox_f64().to_bits()).as_pointer::() as usize; + crate::closure::closure_set_dynamic_prop(raw, "xxHash64", xxhash.get_nanbox_f64()); + hash.get_nanbox_f64() +} + +#[no_mangle] +pub extern "C" fn js_bun_gc(_full: f64) -> f64 { + crate::gc::js_gc_collect(); + undefined() +} + +#[no_mangle] +pub extern "C" fn js_bun_generate_heap_snapshot(format: f64, encoding: f64) -> f64 { + if is_undefined_or_null(format) { + throw_type_error( + "Bun.generateHeapSnapshot() uses JavaScriptCore's snapshot object; Perry supports the explicit 'v8' format", + ); + } + if value_to_string(format) != "v8" { + throw_type_error("Bun.generateHeapSnapshot format must be 'v8'"); + } + #[cfg(feature = "diagnostics")] + { + let json = crate::gc::gc_build_v8_heap_snapshot_json(); + if !is_undefined_or_null(encoding) && value_to_string(encoding) == "arraybuffer" { + return crate::node_submodules::consumers::bytes_to_array_buffer_value(json.as_bytes()); + } + if !is_undefined_or_null(encoding) && value_to_string(encoding) != "string" { + throw_type_error("Bun.generateHeapSnapshot encoding must be 'string' or 'arraybuffer'"); + } + boxed_str(json.as_bytes()) + } + #[cfg(not(feature = "diagnostics"))] + { + let _ = encoding; + throw_type_error("Heap snapshot diagnostics are not enabled in this Perry runtime") + } +} + +// --------------------------------------------------------------------------- +// Bun.JSONL.parseChunk +// --------------------------------------------------------------------------- + +fn jsonl_parse_chunk(input: f64, start: f64, end: f64) -> f64 { + let (bytes, string_input) = if is_string_value(input) { + (value_to_string(input).into_bytes(), true) + } else { + match payload_bytes(input) { + Ok(bytes) => (bytes, false), + Err(error) => crate::exception::js_throw(error), + } + }; + let mut cursor = number_arg(start) + .unwrap_or(0.0) + .max(0.0) + .min(bytes.len() as f64) as usize; + let limit = number_arg(end) + .unwrap_or(bytes.len() as f64) + .max(cursor as f64) + .min(bytes.len() as f64) as usize; + let initial = cursor; + let mut spans = Vec::<(usize, usize)>::new(); + let mut last_read = initial; + let mut done = true; + let mut syntax_error = false; + + while cursor < limit { + let line_end = bytes[cursor..limit] + .iter() + .position(|byte| *byte == b'\n') + .map(|offset| cursor + offset) + .unwrap_or(limit); + let mut content_start = cursor; + let mut content_end = line_end; + while content_start < content_end && bytes[content_start].is_ascii_whitespace() { + content_start += 1; + } + while content_end > content_start && bytes[content_end - 1].is_ascii_whitespace() { + content_end -= 1; + } + if content_start == content_end { + cursor = if line_end < limit { + line_end + 1 + } else { + limit + }; + continue; + } + + match serde_json::from_slice::(&bytes[content_start..content_end]) { + Ok(_) => { + spans.push((content_start, content_end)); + // Bun reports an absolute character/byte offset immediately + // after the final parsed JSON token, excluding the delimiter. + last_read = if string_input { + String::from_utf8_lossy(&bytes[..content_end]) + .encode_utf16() + .count() + } else { + content_end + }; + cursor = if line_end < limit { + line_end + 1 + } else { + limit + }; + } + Err(error) if error.is_eof() => { + done = false; + break; + } + Err(_) => { + done = false; + syntax_error = true; + break; + } + } + } + + let scope = RuntimeHandleScope::new(); + let values = crate::array::js_array_alloc_with_length(spans.len() as u32); + let values = + scope.root_nanbox_f64(f64::from_bits(JSValue::pointer(values as *const u8).bits())); + for (index, (start, end)) in spans.into_iter().enumerate() { + let source = js_string_from_bytes(bytes[start..end].as_ptr(), (end - start) as u32); + let value = match unsafe { crate::json::js_json_parse_result(source) } { + Ok(value) => f64::from_bits(value.bits()), + Err(error) => crate::exception::js_throw(error), + }; + let value = scope.root_nanbox_f64(value); + let array = JSValue::from_bits(values.get_nanbox_f64().to_bits()) + .as_pointer::(); + crate::array::js_array_set_f64(array as *mut _, index as u32, value.get_nanbox_f64()); + } + + let error = if syntax_error { + syntax_error_value("Failed to parse JSONL") + } else { + null_value() + }; + let error = scope.root_nanbox_f64(error); + let result = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer(js_object_alloc(0, 4) as *const u8).bits(), + )); + set_rooted_field(&scope, &result, b"values", values.get_nanbox_f64()); + set_rooted_field(&scope, &result, b"read", last_read as f64); + set_rooted_field(&scope, &result, b"done", bool_value(done)); + set_rooted_field(&scope, &result, b"error", error.get_nanbox_f64()); + result.get_nanbox_f64() +} + +// --------------------------------------------------------------------------- +// Bun.YAML.parse — libyaml node graph -> rooted Perry graph +// --------------------------------------------------------------------------- + +unsafe fn yaml_scalar_bytes<'a>(node: *const unsafe_libyaml::yaml_node_t) -> &'a [u8] { + let scalar = (*node).data.scalar; + std::slice::from_raw_parts(scalar.value, scalar.length as usize) +} + +unsafe fn yaml_scalar_text(node: *const unsafe_libyaml::yaml_node_t) -> String { + String::from_utf8_lossy(yaml_scalar_bytes(node)).into_owned() +} + +fn yaml_plain_number(text: &str) -> Option { + let compact = text.replace('_', ""); + let (negative, unsigned) = compact + .strip_prefix('-') + .map(|rest| (true, rest)) + .or_else(|| compact.strip_prefix('+').map(|rest| (false, rest))) + .unwrap_or((false, compact.as_str())); + let integer = if let Some(value) = unsigned.strip_prefix("0x") { + i128::from_str_radix(value, 16).ok() + } else if let Some(value) = unsigned.strip_prefix("0o") { + i128::from_str_radix(value, 8).ok() + } else if let Some(value) = unsigned.strip_prefix("0b") { + i128::from_str_radix(value, 2).ok() + } else if !unsigned.contains(['.', 'e', 'E']) { + unsigned.parse::().ok() + } else { + None + }; + if let Some(integer) = integer { + let value = integer as f64; + return Some(if negative { -value } else { value }); + } + match compact.to_ascii_lowercase().as_str() { + ".inf" | "+.inf" => Some(f64::INFINITY), + "-.inf" => Some(f64::NEG_INFINITY), + ".nan" | "+.nan" | "-.nan" => Some(f64::NAN), + _ => compact.parse::().ok(), + } +} + +unsafe fn yaml_scalar_to_js(node: *const unsafe_libyaml::yaml_node_t, source: &[u8]) -> f64 { + let scalar = (*node).data.scalar; + let text = yaml_scalar_text(node); + let start = ((*node).start_mark.index as usize).min(source.len()); + let end = ((*node).end_mark.index as usize).min(source.len()); + let spelling = String::from_utf8_lossy(&source[start..end]); + let explicitly_string = spelling.trim_start().starts_with("!!str") + || spelling + .trim_start() + .starts_with("!"); + let plain = scalar.style == unsafe_libyaml::YAML_PLAIN_SCALAR_STYLE; + if plain && !explicitly_string { + if text.is_empty() || text == "~" || text.eq_ignore_ascii_case("null") { + return null_value(); + } + if text.eq_ignore_ascii_case("true") { + return bool_value(true); + } + if text.eq_ignore_ascii_case("false") { + return bool_value(false); + } + if let Some(number) = yaml_plain_number(&text) { + return number; + } + } + boxed_str(text.as_bytes()) +} + +unsafe fn yaml_node_count(document: *const unsafe_libyaml::yaml_document_t) -> usize { + (*document).nodes.top.offset_from((*document).nodes.start) as usize +} + +unsafe fn yaml_node( + document: *const unsafe_libyaml::yaml_document_t, + id: i32, +) -> *const unsafe_libyaml::yaml_node_t { + (*document).nodes.start.add(id.saturating_sub(1) as usize) +} + +unsafe fn yaml_mapping_key( + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + id: i32, +) -> String { + let node = yaml_node(document, id); + if (*node).type_ == unsafe_libyaml::YAML_SCALAR_NODE { + yaml_scalar_text(node) + } else { + value_to_string(roots.get(id.saturating_sub(1) as usize)) + } +} + +unsafe fn yaml_set_mapping_pair( + scope: &RuntimeHandleScope, + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + target_index: usize, + key_id: i32, + value_id: i32, +) { + if key_id <= 0 || value_id <= 0 { + return; + } + let key = yaml_mapping_key(document, roots, key_id); + let key_ptr = key_ptr(key.as_bytes()); + let target = JSValue::from_bits(roots.get(target_index).to_bits()) + .as_pointer::() as *mut _; + let value = scope.root_nanbox_f64(roots.get(value_id as usize - 1)); + js_object_set_field_by_name(target, key_ptr, value.get_nanbox_f64()); +} + +unsafe fn yaml_apply_merge( + scope: &RuntimeHandleScope, + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + target_index: usize, + source_id: i32, + depth: usize, +) { + if source_id <= 0 || depth > 64 { + return; + } + let source = yaml_node(document, source_id); + if (*source).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*source).data.mapping.pairs; + let count = pairs.top.offset_from(pairs.start) as usize; + for index in 0..count { + let pair = *pairs.start.add(index); + if yaml_mapping_key(document, roots, pair.key) != "<<" { + yaml_set_mapping_pair(scope, document, roots, target_index, pair.key, pair.value); + } + } + } else if (*source).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*source).data.sequence.items; + let count = items.top.offset_from(items.start) as usize; + // Earlier merge sources have precedence in YAML. Apply in reverse so + // their values are the final ones before explicit keys overwrite them. + for index in (0..count).rev() { + yaml_apply_merge( + scope, + document, + roots, + target_index, + *items.start.add(index), + depth + 1, + ); + } + } +} + +unsafe fn yaml_document_to_js( + document: *mut unsafe_libyaml::yaml_document_t, + source: &[u8], + scope: &RuntimeHandleScope, +) -> f64 { + let count = yaml_node_count(document); + let mut roots = RootedValues::with_capacity(scope, count); + for index in 0..count { + let node = (*document).nodes.start.add(index); + let value = if (*node).type_ == unsafe_libyaml::YAML_SCALAR_NODE { + yaml_scalar_to_js(node, source) + } else if (*node).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*node).data.sequence.items; + let length = items.top.offset_from(items.start).max(0) as u32; + f64::from_bits( + JSValue::pointer(crate::array::js_array_alloc_with_length(length) as *const u8) + .bits(), + ) + } else if (*node).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*node).data.mapping.pairs; + let length = pairs.top.offset_from(pairs.start).max(0) as u32; + f64::from_bits(JSValue::pointer(js_object_alloc(0, length) as *const u8).bits()) + } else { + null_value() + }; + roots.push(value); + } + + for index in 0..count { + let node = (*document).nodes.start.add(index); + if (*node).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*node).data.sequence.items; + let length = items.top.offset_from(items.start).max(0) as usize; + for item_index in 0..length { + let child_id = *items.start.add(item_index); + if child_id <= 0 { + continue; + } + let child = scope.root_nanbox_f64(roots.get(child_id as usize - 1)); + let target = JSValue::from_bits(roots.get(index).to_bits()) + .as_pointer::() + as *mut _; + crate::array::js_array_set_f64(target, item_index as u32, child.get_nanbox_f64()); + } + } else if (*node).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*node).data.mapping.pairs; + let length = pairs.top.offset_from(pairs.start).max(0) as usize; + for pair_index in 0..length { + let pair = *pairs.start.add(pair_index); + if yaml_mapping_key(document, &roots, pair.key) == "<<" { + yaml_apply_merge(scope, document, &roots, index, pair.value, 0); + } + } + for pair_index in 0..length { + let pair = *pairs.start.add(pair_index); + if yaml_mapping_key(document, &roots, pair.key) != "<<" { + yaml_set_mapping_pair(scope, document, &roots, index, pair.key, pair.value); + } + } + } + } + roots.get(0) +} + +fn yaml_parse(input: f64) -> f64 { + let source = value_to_string(input).into_bytes(); + unsafe { + let mut parser = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_parser_initialize(parser.as_mut_ptr()).fail { + crate::exception::js_throw(syntax_error_value("Failed to initialize YAML parser")); + } + let mut parser = parser.assume_init(); + unsafe_libyaml::yaml_parser_set_input_string( + &mut parser, + source.as_ptr(), + source.len() as u64, + ); + let scope = RuntimeHandleScope::new(); + let mut documents = RootedValues::new(&scope); + loop { + let mut document = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_parser_load(&mut parser, document.as_mut_ptr()).fail { + let problem = if parser.problem.is_null() { + "invalid YAML".to_string() + } else { + std::ffi::CStr::from_ptr(parser.problem) + .to_string_lossy() + .into_owned() + }; + let line = parser.problem_mark.line + 1; + let column = parser.problem_mark.column + 1; + unsafe_libyaml::yaml_parser_delete(&mut parser); + crate::exception::js_throw(syntax_error_value(&format!( + "Failed to parse YAML at {line}:{column}: {problem}" + ))); + } + let mut document = document.assume_init(); + let root = unsafe_libyaml::yaml_document_get_root_node(&mut document); + if root.is_null() { + unsafe_libyaml::yaml_document_delete(&mut document); + break; + } + let value = yaml_document_to_js(&mut document, &source, &scope); + documents.push(value); + unsafe_libyaml::yaml_document_delete(&mut document); + } + unsafe_libyaml::yaml_parser_delete(&mut parser); + + if documents.is_empty() { + return null_value(); + } + if documents.len() == 1 { + return documents.get(0); + } + let output = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer( + crate::array::js_array_alloc_with_length(documents.len() as u32) as *const u8, + ) + .bits(), + )); + for index in 0..documents.len() { + let value = scope.root_nanbox_f64(documents.get(index)); + let array = JSValue::from_bits(output.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _; + crate::array::js_array_set_f64(array, index as u32, value.get_nanbox_f64()); + } + output.get_nanbox_f64() + } +} + +// --------------------------------------------------------------------------- +// Bun.YAML.stringify — rooted Perry graph -> libyaml document graph +// --------------------------------------------------------------------------- + +const YAML_STR_TAG: &[u8] = b"tag:yaml.org,2002:str\0"; +const YAML_INT_TAG: &[u8] = b"tag:yaml.org,2002:int\0"; +const YAML_FLOAT_TAG: &[u8] = b"tag:yaml.org,2002:float\0"; +const YAML_BOOL_TAG: &[u8] = b"tag:yaml.org,2002:bool\0"; +const YAML_NULL_TAG: &[u8] = b"tag:yaml.org,2002:null\0"; + +unsafe fn yaml_add_scalar( + document: *mut unsafe_libyaml::yaml_document_t, + tag: &[u8], + text: &str, +) -> i32 { + unsafe_libyaml::yaml_document_add_scalar( + document, + tag.as_ptr(), + text.as_ptr(), + text.len() as i32, + unsafe_libyaml::YAML_ANY_SCALAR_STYLE, + ) +} + +fn yaml_number_text(value: f64) -> String { + if value.is_nan() { + return ".nan".to_string(); + } + if value == f64::INFINITY { + return ".inf".to_string(); + } + if value == f64::NEG_INFINITY { + return "-.inf".to_string(); + } + if value == 0.0 && value.is_sign_negative() { + return "-0".to_string(); + } + let mut buffer = ryu::Buffer::new(); + buffer.format(value).to_string() +} + +unsafe fn yaml_add_js_value( + document: *mut unsafe_libyaml::yaml_document_t, + scope: &RuntimeHandleScope, + roots: &mut RootedValues<'_>, + value_index: usize, + memo: &mut Vec<(usize, i32)>, + flow: bool, + depth: usize, +) -> i32 { + if depth > 1_000 { + throw_type_error("YAML.stringify: value nested deeper than 1000 levels"); + } + let raw = roots.get(value_index); + let value = JSValue::from_bits(raw.to_bits()); + if value.is_undefined() { + return 0; + } + if value.is_null() { + return yaml_add_scalar(document, YAML_NULL_TAG, "null"); + } + if value.is_bool() { + return yaml_add_scalar( + document, + YAML_BOOL_TAG, + if value.as_bool() { "true" } else { "false" }, + ); + } + if value.is_int32() { + return yaml_add_scalar(document, YAML_INT_TAG, &value.as_int32().to_string()); + } + if value.is_number() { + let number = value.as_number(); + let tag = if number.is_finite() && number.fract() == 0.0 { + YAML_INT_TAG + } else { + YAML_FLOAT_TAG + }; + return yaml_add_scalar(document, tag, &yaml_number_text(number)); + } + if value.is_string() || value.is_short_string() { + return yaml_add_scalar(document, YAML_STR_TAG, &value_to_string(raw)); + } + if crate::value::js_nanbox_is_bigint(raw) != 0 { + return yaml_add_scalar(document, YAML_INT_TAG, &value_to_string(raw)); + } + if !value.is_pointer() { + return 0; + } + let address = value.as_pointer::() as usize; + if !crate::json::ptr_is_tracked_heap_object(address as *const u8) { + return 0; + } + let Some(header) = crate::value::addr_class::try_read_gc_header(address) else { + return 0; + }; + if !matches!( + header.obj_type, + crate::gc::GC_TYPE_ARRAY | crate::gc::GC_TYPE_OBJECT + ) { + return 0; + } + for (memo_index, node_id) in memo.iter().copied() { + if roots.get(memo_index).to_bits() == roots.get(value_index).to_bits() { + return node_id; + } + } + + if header.obj_type == crate::gc::GC_TYPE_ARRAY { + let style = if flow { + unsafe_libyaml::YAML_FLOW_SEQUENCE_STYLE + } else { + unsafe_libyaml::YAML_BLOCK_SEQUENCE_STYLE + }; + let node_id = unsafe_libyaml::yaml_document_add_sequence(document, std::ptr::null(), style); + if node_id == 0 { + return 0; + } + memo.push((value_index, node_id)); + let array = roots.get(value_index); + let array = JSValue::from_bits(array.to_bits()).as_pointer::(); + let length = crate::array::js_array_length(array); + for index in 0..length { + let array = roots.get(value_index); + let array = + JSValue::from_bits(array.to_bits()).as_pointer::(); + let child = crate::array::js_array_get_f64(array, index); + roots.push(child); + let child_index = roots.len() - 1; + let child_id = + yaml_add_js_value(document, scope, roots, child_index, memo, flow, depth + 1); + if child_id != 0 { + let _ = + unsafe_libyaml::yaml_document_append_sequence_item(document, node_id, child_id); + } + } + return node_id; + } + + let style = if flow { + unsafe_libyaml::YAML_FLOW_MAPPING_STYLE + } else { + unsafe_libyaml::YAML_BLOCK_MAPPING_STYLE + }; + let node_id = unsafe_libyaml::yaml_document_add_mapping(document, std::ptr::null(), style); + if node_id == 0 { + return 0; + } + memo.push((value_index, node_id)); + let object = roots.get(value_index); + let object = JSValue::from_bits(object.to_bits()).as_pointer::(); + let keys = crate::object::js_object_keys(object); + roots.push(f64::from_bits(JSValue::pointer(keys as *const u8).bits())); + let keys_index = roots.len() - 1; + let keys = JSValue::from_bits(roots.get(keys_index).to_bits()) + .as_pointer::(); + let length = crate::array::js_array_length(keys); + for index in 0..length { + let keys = JSValue::from_bits(roots.get(keys_index).to_bits()) + .as_pointer::(); + let key_value = crate::array::js_array_get_f64(keys, index); + let key = value_to_string(key_value); + let lookup_key = key_ptr(key.as_bytes()); + let object = JSValue::from_bits(roots.get(value_index).to_bits()) + .as_pointer::(); + let child = crate::object::js_object_get_field_by_name_f64(object, lookup_key); + roots.push(child); + let child_index = roots.len() - 1; + let child_id = + yaml_add_js_value(document, scope, roots, child_index, memo, flow, depth + 1); + if child_id == 0 { + continue; + } + let key_id = yaml_add_scalar(document, YAML_STR_TAG, &key); + if key_id != 0 { + let _ = unsafe_libyaml::yaml_document_append_mapping_pair( + document, node_id, key_id, child_id, + ); + } + } + let _ = scope; + node_id +} + +unsafe fn yaml_vec_write( + data: *mut libc::c_void, + buffer: *mut libc::c_uchar, + size: u64, +) -> libc::c_int { + let output = &mut *(data as *mut Vec); + output.extend_from_slice(std::slice::from_raw_parts(buffer, size as usize)); + 1 +} + +fn compact_flow_yaml(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut quote = None; + let mut escaped = false; + while let Some(character) = chars.next() { + if let Some(active) = quote { + out.push(character); + if active == '"' && character == '\\' && !escaped { + escaped = true; + continue; + } + if character == active && !escaped { + quote = None; + } + escaped = false; + continue; + } + if matches!(character, '\'' | '"') { + quote = Some(character); + out.push(character); + continue; + } + out.push(character); + if matches!(character, ',' | ':') { + while chars.peek() == Some(&' ') { + chars.next(); + } + } + } + out +} + +fn yaml_stringify(input: f64, replacer: f64, space: f64) -> f64 { + let _ = replacer; + if JSValue::from_bits(input.to_bits()).is_undefined() { + return undefined(); + } + let indent = if is_undefined_or_null(space) { + 0 + } else if is_string_value(space) { + value_to_string(space).chars().count().clamp(1, 9) as i32 + } else { + number_arg(space).unwrap_or(0.0).clamp(0.0, 9.0) as i32 + }; + let flow = indent == 0; + + unsafe { + let mut document = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_document_initialize( + document.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + true, + true, + ) + .fail + { + throw_type_error("Failed to initialize YAML document"); + } + let mut document = document.assume_init(); + let scope = RuntimeHandleScope::new(); + let mut roots = RootedValues::new(&scope); + roots.push(input); + let root_id = yaml_add_js_value( + &mut document, + &scope, + &mut roots, + 0, + &mut Vec::new(), + flow, + 0, + ); + if root_id == 0 { + unsafe_libyaml::yaml_document_delete(&mut document); + return undefined(); + } + + let mut emitter = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_emitter_initialize(emitter.as_mut_ptr()).fail { + unsafe_libyaml::yaml_document_delete(&mut document); + throw_type_error("Failed to initialize YAML emitter"); + } + let mut emitter = emitter.assume_init(); + let mut output = Vec::::new(); + unsafe_libyaml::yaml_emitter_set_output( + &mut emitter, + yaml_vec_write, + &mut output as *mut Vec as *mut libc::c_void, + ); + unsafe_libyaml::yaml_emitter_set_unicode(&mut emitter, true); + unsafe_libyaml::yaml_emitter_set_width(&mut emitter, -1); + if indent > 0 { + unsafe_libyaml::yaml_emitter_set_indent(&mut emitter, indent.max(2)); + } + if unsafe_libyaml::yaml_emitter_dump(&mut emitter, &mut document).fail { + unsafe_libyaml::yaml_emitter_delete(&mut emitter); + throw_type_error("Failed to emit YAML"); + } + let _ = unsafe_libyaml::yaml_emitter_close(&mut emitter); + unsafe_libyaml::yaml_emitter_delete(&mut emitter); + + let mut output = String::from_utf8_lossy(&output).into_owned(); + if let Some(without_end) = output.strip_suffix("...\n") { + output = without_end.to_string(); + } + while output.ends_with('\n') { + output.pop(); + } + if flow { + output = compact_flow_yaml(&output); + } + boxed_str(output.as_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_handles_csi_and_osc() { + assert_eq!(strip_ansi_text("\x1b[31ma\x1b[0mb"), "ab"); + assert_eq!( + strip_ansi_text("\x1b]8;;https://x\x1b\\link\x1b]8;;\x07"), + "link" + ); + } + + #[test] + fn flow_compaction_does_not_touch_quoted_spaces() { + assert_eq!(compact_flow_yaml("{a: 1, b: 'x, y'}"), "{a:1,b:'x, y'}"); + } +} diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs new file mode 100644 index 0000000000..8cebf7a233 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs @@ -0,0 +1,77 @@ +//! Link-safe stubs for auto-optimized runtimes that do not include #9600's +//! Bun CLI utility backends. The compiler turns `bun-cli-utils` on whenever a +//! Bun import/global is reachable, so these are defensive diagnostics only. + +use crate::string::js_string_from_bytes; +use crate::value::JSValue; + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn feature_disabled() -> ! { + let message = b"Bun CLI utilities are not enabled in this optimized Perry runtime"; + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_error_new_with_message(message); + crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) +} + +pub fn js_bun_yaml() -> f64 { + undefined() +} + +pub fn js_bun_toml() -> f64 { + undefined() +} + +pub fn js_bun_semver() -> f64 { + undefined() +} + +pub fn js_bun_jsonl() -> f64 { + undefined() +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + value +} + +#[no_mangle] +pub extern "C" fn js_bun_deep_equals(_left: f64, _right: f64, _strict: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_strip_ansi(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_wrap_ansi(_input: f64, _columns: f64, _options: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_which(_command: f64, _options: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress_sync(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_gc(_full: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_generate_heap_snapshot(_format: f64, _encoding: f64) -> f64 { + feature_disabled() +} diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 60c370aff6..d46c56543d 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -20,7 +20,12 @@ //! - `Bun.hash` is Zig-std Wyhash (see `wyhash.rs`) returning a BigInt, so //! `.toString(16)` cache keys match bun-run installs. +#[cfg(feature = "bun-cli-utils")] +mod cli_utils; +#[cfg(not(feature = "bun-cli-utils"))] +mod cli_utils_stub; mod glob; +mod spawn; mod string_width; mod width_tables; mod wyhash; @@ -36,7 +41,12 @@ use crate::string::{js_string_from_bytes, StringHeader}; use crate::value::{js_jsvalue_to_string, JSValue}; use std::io::{Read, Write}; +#[cfg(feature = "bun-cli-utils")] +pub use cli_utils::*; +#[cfg(not(feature = "bun-cli-utils"))] +pub use cli_utils_stub::*; 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..6158315af0 100644 --- a/crates/perry-runtime/src/node_submodules/mod.rs +++ b/crates/perry-runtime/src/node_submodules/mod.rs @@ -109,7 +109,7 @@ macro_rules! thunk { } pub(crate) mod blob; -mod consumers; +pub(crate) mod consumers; mod fs_promises; mod hono_jsx; mod stream_promises; @@ -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.rs b/crates/perry-runtime/src/object/native_module.rs index 54d679c35c..a8e7095588 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1007,6 +1007,15 @@ unsafe fn native_module_property_by_name_impl( "stdin" => return crate::bun_compat::js_bun_stdin(), "stdout" => return crate::bun_compat::js_bun_stdout(), "stderr" => return crate::bun_compat::js_bun_stderr(), + "YAML" => return crate::bun_compat::js_bun_yaml(), + "TOML" => return crate::bun_compat::js_bun_toml(), + "semver" => return crate::bun_compat::js_bun_semver(), + "JSONL" => return crate::bun_compat::js_bun_jsonl(), + "hash" => { + return crate::bun_compat::decorate_bun_hash(bound_native_callable_export_value( + "bun", "hash", + )) + } "version" => return native_string_value(env!("CARGO_PKG_VERSION")), "isStandaloneExecutable" => return crate::embedded::is_standalone_executable_value(), _ => {} @@ -1196,7 +1205,12 @@ pub extern "C" fn js_native_module_bind_method( { return bound; } - return bound_native_callable_export_value(&module_name, property_name); + let value = bound_native_callable_export_value(&module_name, property_name); + return if module_name == "bun" && property_name == "hash" { + crate::bun_compat::decorate_bun_hash(value) + } else { + value + }; } // Try V8 JS runtime fallback for unknown properties (e.g., ethers.Contract) @@ -1841,9 +1855,13 @@ unsafe fn vt_get_own_field( if let Some(bound) = instance_bound_perf_method(&module_name, property_name, nb_ptr) { return Some(JSValue::from_bits(bound.to_bits())); } - return Some(JSValue::from_bits( - bound_native_callable_export_value(&module_name, property_name).to_bits(), - )); + let value = bound_native_callable_export_value(&module_name, property_name); + let value = if module_name == "bun" && property_name == "hash" { + crate::bun_compat::decorate_bun_hash(value) + } else { + value + }; + return Some(JSValue::from_bits(value.to_bits())); } // Object-valued exports (e.g. `perf_hooks.performance` / `.constants`) are // resolved by the shared per-property dispatch but are not covered by the 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..0dcf89cf3f 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,13 @@ 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" | "gc" | "hash" | "pathToFileURL" + | "stringWidth" | "stripANSI" | "which" | "zstdDecompress" | "zstdDecompressSync", + ) => Some(1), + ("bun", "deepEquals" | "generateHeapSnapshot" | "spawn" | "write") => Some(2), + ("bun", "wrapAnsi") => Some(3), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), ("bun:ffi", "ptr" | "CString" | "CFunction" | "linkSymbols") => Some(1), @@ -287,12 +290,22 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "bun", &[ ("Glob", 1), + ("Terminal", 1), + ("deepEquals", 2), ("file", 1), ("fileURLToPath", 1), + ("gc", 1), + ("generateHeapSnapshot", 2), ("hash", 1), ("pathToFileURL", 1), + ("spawn", 2), ("stringWidth", 1), + ("stripANSI", 1), + ("which", 1), + ("wrapAnsi", 3), ("write", 2), + ("zstdDecompress", 1), + ("zstdDecompressSync", 1), ], ), ( 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..c48e1a5a3b 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,23 @@ 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" + | "deepEquals" + | "file" + | "fileURLToPath" + | "gc" + | "generateHeapSnapshot" + | "hash" + | "pathToFileURL" + | "spawn" + | "stringWidth" + | "stripANSI" + | "which" + | "wrapAnsi" + | "write" + | "zstdDecompress" + | "zstdDecompressSync" ) { 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..c09d5dbc2f 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,12 +85,22 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "bun", &[ "Glob", + "Terminal", + "deepEquals", "file", "fileURLToPath", + "gc", + "generateHeapSnapshot", "hash", "pathToFileURL", + "spawn", "stringWidth", + "stripANSI", + "which", + "wrapAnsi", "write", + "zstdDecompress", + "zstdDecompressSync", ], ), ( 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..47eb293406 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1630,17 +1630,31 @@ 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"JSONL", + b"TOML", + b"Terminal", + b"YAML", + b"deepEquals", b"file", b"fileURLToPath", + b"gc", + b"generateHeapSnapshot", b"hash", b"isStandaloneExecutable", b"pathToFileURL", + b"semver", + b"spawn", b"stderr", b"stdin", b"stdout", b"stringWidth", + b"stripANSI", b"version", + b"which", + b"wrapAnsi", b"write", + b"zstdDecompress", + b"zstdDecompressSync", ]), // bun:ffi (#6562) — stage-1 surface plus the declared-but-throwing // exports (their reads resolve to callables that raise the stage-1 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..a4dfad2cea 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,8 +272,20 @@ 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", "deepEquals") => crate::bun_compat::js_bun_deep_equals(arg(0), arg(1), arg(2)), + ("bun", "stripANSI") => crate::bun_compat::js_bun_strip_ansi(arg(0)), + ("bun", "wrapAnsi") => crate::bun_compat::js_bun_wrap_ansi(arg(0), arg(1), arg(2)), + ("bun", "which") => crate::bun_compat::js_bun_which(arg(0), arg(1)), + ("bun", "zstdDecompress") => crate::bun_compat::js_bun_zstd_decompress(arg(0)), + ("bun", "zstdDecompressSync") => crate::bun_compat::js_bun_zstd_decompress_sync(arg(0)), + ("bun", "gc") => crate::bun_compat::js_bun_gc(arg(0)), + ("bun", "generateHeapSnapshot") => { + crate::bun_compat::js_bun_generate_heap_snapshot(arg(0), arg(1)) + } ("bun", "file") => crate::bun_compat::js_bun_file(arg(0)), ("bun", "write") => crate::bun_compat::js_bun_write(arg(0), arg(1)), ("bun", "Glob") => crate::bun_compat::js_bun_glob_new(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/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index faa86eec8a..82d0dc6ace 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1298,6 +1298,12 @@ fn collect_module_one( // panic = "unwind" when this is set. ctx.needs_thread = true; } + // Runtime-only Bun imports do not satisfy `requires_stdlib`, but + // #9600's parser/compression backends are an optional runtime + // feature and therefore still need an auto-optimize marker. + if import.source == "bun" { + ctx.native_module_imports.insert("bun".to_string()); + } if perry_hir::requires_stdlib(&import.source) { ctx.needs_stdlib = true; // Track for `--minimal-stdlib` feature computation. Strip diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index c281ef1ec7..a62db7c938 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -333,6 +333,12 @@ pub(super) fn detect_optional_feature_usage( "{:?}{:?}{:?}", &hir_module.init, &hir_module.functions, &hir_module.classes ); + // Bare `Bun.*` calls have no import declaration. Lowering gives them + // the same `module: "bun"` marker as imported calls; retain it so the + // optimized runtime includes #9600's utility backends. + if hir_debug.contains("module: \"bun\"") || hir_debug.contains("NativeModuleRef(\"bun\")") { + ctx.native_module_imports.insert("bun".to_string()); + } for native_module in [ "lru-cache", "big.js", @@ -522,6 +528,7 @@ pub(super) fn detect_optional_feature_usage( ); if hir_debug.contains("method: \"getHeapSnapshot\"") || hir_debug.contains("method: \"writeHeapSnapshot\"") + || hir_debug.contains("method: \"generateHeapSnapshot\"") || hir_debug.contains("property: \"report\"") { ctx.uses_diagnostics = true; diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index e5f0a76eab..6e34d3783c 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -221,6 +221,9 @@ pub(crate) fn auto_optimized_cross_features( if !ctx.native_addons.is_empty() { cross_features.push("perry-runtime/node-api-host".to_string()); } + if ctx.bun_platform || ctx.native_module_imports.contains("bun") { + cross_features.push("perry-runtime/bun-cli-utils".to_string()); + } // Binary-size feature gating (kept in sync with the inline list on `main`): // each engine/table is linked only when the program actually uses it. if ctx.uses_regex { diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 88ef05726c..8743028e78 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1176,6 +1176,32 @@ fn auto_optimize_always_includes_keepalive_anchors() { ); } +#[test] +fn bun_usage_enables_cli_utility_runtime_pack() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty_features: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + let mut imported = CompilationContext::new(dir.path().to_path_buf()); + imported.native_module_imports.insert("bun".to_string()); + let imported_features = auto_optimized_cross_features(&imported, &empty_features, &[]); + assert!( + imported_features + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils"), + "a bun import must retain #9600's runtime backends: {imported_features:?}" + ); + + let mut platform = CompilationContext::new(dir.path().to_path_buf()); + platform.bun_platform = true; + let platform_features = auto_optimized_cross_features(&platform, &empty_features, &[]); + assert!( + platform_features + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils"), + "--platform bun must retain #9600's runtime backends: {platform_features:?}" + ); +} + #[test] fn data_url_dynamic_import_enables_dyn_eval_and_changes_cache_key() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/tests/issue_9600_bun_cli_utilities.rs b/crates/perry/tests/issue_9600_bun_cli_utilities.rs new file mode 100644 index 0000000000..da81d29ae0 --- /dev/null +++ b/crates/perry/tests/issue_9600_bun_cli_utilities.rs @@ -0,0 +1,134 @@ +//! #9600 — Bun's compact CLI utility shim pack, exercised through a compiled +//! standalone binary. Expected values are from Bun 1.3.14. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\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_cli_utility_oracle_and_edge_cases() { + let stdout = compile_and_run( + r#" +import { + YAML, TOML, semver, deepEquals, stripANSI, wrapAnsi, which, + zstdDecompress, zstdDecompressSync, hash, JSONL, gc, + generateHeapSnapshot, +} from "bun"; + +async function main() { + console.log(JSON.stringify(YAML.parse("a: 1\nb:\n - x"))); + console.log(JSON.stringify(TOML.parse('a = 1\n[b]\nc = "x"'))); + console.log(semver.order("1.0.0", "2.0.0"), semver.satisfies("1.2.3", "^1.0.0")); + try { semver.order("bad", "1.0.0"); } catch (error: any) { + console.log(error.name, error.message.trim()); + } + const semverCases = [ + ["1.2.3", "1.2.3"], ["v1.2.3", "^1.0.0"], + ["1.2.5", "~1.2.3"], ["1.3.0", "~1.2.3"], + ["0.2.5", "^0.2.3"], ["0.3.0", "^0.2.3"], + ["1.5.0", "1.2.3 - 2.0.0"], ["2.1.0", "1.2.3 - 2.0.0"], + ["1.4.9", "1.4.x"], ["1.5.0", "1.4.x"], + ["2.0.0", "<1 || >=2"], ["1.5.0", "<1 || >=2"], + ["1.2.3-beta.1", "^1.2.3"], + ["1.2.3-beta.2", ">=1.2.3-beta.1 <1.2.3"], + ["1.2.3", ""], ["1.2.3", "latest"], + ]; + console.log(JSON.stringify(semverCases.map(([version, range]) => semver.satisfies(version, range)))); + console.log(deepEquals({ a: [1, 2] }, { a: [1, 2] })); + console.log(JSON.stringify(stripANSI("\x1b[31ma\x1b[0mb"))); + console.log(JSON.stringify(wrapAnsi("one two three", 7))); + console.log(which("definitely-not-a-real-command-9600")); + console.log(hash.xxHash64("abc").toString()); + + const compressed = new Uint8Array([40,181,47,253,4,88,81,0,0,104,101,108,108,111,32,122,115,116,100,207,219,96,156]); + console.log(new TextDecoder().decode(zstdDecompressSync(compressed))); + console.log(new TextDecoder().decode(await zstdDecompress(compressed))); + + const parsed = JSONL.parseChunk('{"a":1}\n{"b":2}'); + console.log(JSON.stringify({ values: parsed.values, read: parsed.read, done: parsed.done, error: parsed.error })); + const incomplete = JSONL.parseChunk('{"a":1}\n{"b":'); + console.log(JSON.stringify({ values: incomplete.values, read: incomplete.read, done: incomplete.done, error: incomplete.error })); + const invalid = JSONL.parseChunk('{"a":1}\nnope\n'); + console.log(JSON.stringify({ values: invalid.values, read: invalid.read, done: invalid.done, error: invalid.error?.name })); + + const alias = YAML.parse("a: &shared\n x: 1\nb: *shared"); + console.log(alias.a === alias.b, JSON.stringify(alias)); + const cycle = YAML.parse("root: &root\n self: *root"); + console.log(cycle.root === cycle.root.self); + console.log(JSON.stringify(YAML.parse("---\na: 1\n---\nb: 2"))); + const roundTrip = YAML.parse(YAML.stringify({ a: 1, b: ["x"] }, null, 2)); + console.log(JSON.stringify(roundTrip)); + try { YAML.parse("a: ["); } catch (error: any) { console.log(error.name); } + + console.log(gc() === undefined); + try { generateHeapSnapshot(); } catch (error: any) { + console.log(error.name, error.message.includes("v8")); + } +} +main(); +"#, + ); + assert_eq!( + stdout, + concat!( + "{\"a\":1,\"b\":[\"x\"]}\n", + "{\"a\":1,\"b\":{\"c\":\"x\"}}\n", + "-1 true\n", + "Error Invalid SemVer: bad\n", + "[true,true,true,false,true,false,true,false,true,false,true,false,false,true,true,true]\n", + "true\n", + "\"ab\"\n", + "\"one two\\nthree\"\n", + "null\n", + "4952883123889572249\n", + "hello zstd\n", + "hello zstd\n", + "{\"values\":[{\"a\":1},{\"b\":2}],\"read\":15,\"done\":true,\"error\":null}\n", + "{\"values\":[{\"a\":1}],\"read\":7,\"done\":false,\"error\":null}\n", + "{\"values\":[{\"a\":1}],\"read\":7,\"done\":false,\"error\":\"SyntaxError\"}\n", + "true {\"a\":{\"x\":1},\"b\":{\"x\":1}}\n", + "true\n", + "[{\"a\":1},{\"b\":2}]\n", + "{\"a\":1,\"b\":[\"x\"]}\n", + "SyntaxError\n", + "true\n", + "TypeError true\n", + ) + ); +} 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 18f4566b54..e79243618d 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 0bbcbe9457..e545300df3 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",