diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index cba9648c..991e74cb 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1339,6 +1339,18 @@ let () = Printf.sprintf "(__as_childProcess().spawnSync(%s, %s, { stdio: \"inherit\" }).status ?? 1)" (arg 0 a) (arg 1 a)); + (* Captured run: stdin closed, stdout/stderr piped as UTF-8. [cwd] is an + AffineScript Option ({ tag, value }). A host spawn error (ENOENT, + ENOBUFS) has no status, so it maps to 1 with its message on stderr. *) + b "bun_run_capture" + (fun a -> + Printf.sprintf + "((__r) => ({ status: __r.status ?? 1, stdout: __r.stdout ?? \"\", \ + stderr: (__r.stderr ?? \"\") + (__r.error ? String(__r.error.message) : \"\") }))\ + (__as_childProcess().spawnSync(%s, %s, { encoding: \"utf8\", \ + stdio: [\"ignore\", \"pipe\", \"pipe\"], \ + cwd: ((__c) => __c.tag === \"Some\" ? __c.value : undefined)(%s) }))" + (arg 0 a) (arg 1 a) (arg 2 a)); b "bun_stdin_text" (fun _ -> "__as_fs().readFileSync(0, \"utf8\")"); b "bun_stdout_write" diff --git a/stdlib/Bun.affine b/stdlib/Bun.affine index 6e4e3dc7..9720b15a 100644 --- a/stdlib/Bun.affine +++ b/stdlib/Bun.affine @@ -16,6 +16,23 @@ pub extern fn bun_env_get(name: String) -> Option; /// its exit status (or 1 when the host supplies no status). pub extern fn bun_run(command: String, arguments: [String]) -> Int; +/// The captured outcome of a child process: its exit status (1 when the host +/// supplies none, e.g. on a signal or a spawn failure) and its standard output +/// and standard error decoded as UTF-8. +pub type RunResult = { + status: Int, + stdout: String, + stderr: String +} + +/// Run a child process synchronously and capture its standard output and +/// standard error instead of inheriting them. Standard input is closed. +/// `cwd` sets the child's working directory; `None` keeps the caller's. A +/// spawn failure (missing program, output over the host buffer limit) yields +/// status 1 with the host's error message appended to `stderr`. +pub extern fn bun_run_capture(command: String, arguments: [String], + cwd: Option) -> RunResult; + /// Read standard input to EOF as UTF-8. pub extern fn bun_stdin_text() -> String; diff --git a/tests/codegen-bun/host_profile.affine b/tests/codegen-bun/host_profile.affine index e9bd8b23..144eba72 100644 --- a/tests/codegen-bun/host_profile.affine +++ b/tests/codegen-bun/host_profile.affine @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 // Bun-ESM host-profile acceptance fixture (issue #734). -use Bun::{ bun_env_get, bun_run, bun_stdin_text, bun_stdout_write, bun_stderr_write, bun_exit }; +use Bun::{ RunResult, bun_env_get, bun_run, bun_run_capture, bun_stdin_text, bun_stdout_write, bun_stderr_write, bun_exit }; use prelude::{Some, None}; extern type Bytes; @@ -49,3 +49,14 @@ pub fn first_byte(path: String) -> Int { let value = readFileBytes(path); if bytesLength(value) == 0 { 0 } else { bytesByteAt(value, 0) } } + +/// Run `sh -c script` in `cwd` (empty string = inherit) and capture the result. +pub fn run_captured(script: String, cwd: String) -> RunResult { + let dir = if cwd == "" { None } else { Some(cwd) }; + bun_run_capture("sh", ["-c", script], dir) +} + +/// Capture-run a program that does not exist, to exercise the spawn-error path. +pub fn run_missing_program() -> RunResult { + bun_run_capture("affinescript-no-such-program", [], None) +} diff --git a/tests/codegen-bun/host_profile.harness.mjs b/tests/codegen-bun/host_profile.harness.mjs index e09589f2..b8566078 100644 --- a/tests/codegen-bun/host_profile.harness.mjs +++ b/tests/codegen-bun/host_profile.harness.mjs @@ -24,6 +24,19 @@ try { throw new Error("missing environment value failed"); } if (subject.run_successful_child() !== 0) throw new Error("subprocess lowering failed"); + // Captured run (bun_run_capture): planted controls with known answers. + const captured = subject.run_captured("echo hi; echo err >&2; exit 3", ""); + if (captured.status !== 3) throw new Error(`capture status failed: ${captured.status}`); + if (captured.stdout !== "hi\n") throw new Error(`capture stdout failed: ${JSON.stringify(captured.stdout)}`); + if (captured.stderr !== "err\n") throw new Error(`capture stderr failed: ${JSON.stringify(captured.stderr)}`); + const inDir = subject.run_captured("pwd", nested); + if (inDir.status !== 0 || inDir.stdout.trim() !== nested) { + throw new Error(`capture cwd failed: ${JSON.stringify(inDir)}`); + } + const missing = subject.run_missing_program(); + if (missing.status !== 1 || !missing.stderr.includes("affinescript-no-such-program")) { + throw new Error(`capture spawn-error failed: ${JSON.stringify(missing)}`); + } subject.remove_path(file); let missingPathThrew = false; try {