From 309c461e117cd356dff793cdaedcda9f1b4fa0c5 Mon Sep 17 00:00:00 2001 From: Erica Marigold Date: Wed, 16 Oct 2024 20:48:12 +0100 Subject: [PATCH] Implement a non-blocking child process interface (#211) --- .lune/hello_lune.luau | 2 +- Cargo.lock | 2 + crates/lune-std-process/Cargo.toml | 3 + crates/lune-std-process/src/lib.rs | 126 +++++++++++++--- crates/lune-std-process/src/stream.rs | 58 +++++++ crates/lune/src/tests.rs | 17 ++- scripts/generate_compression_test_files.luau | 4 +- tests/datetime/formatLocalTime.luau | 2 +- tests/process/create/kill.luau | 21 +++ tests/process/create/non_blocking.luau | 13 ++ tests/process/create/status.luau | 15 ++ tests/process/create/stream.luau | 18 +++ tests/process/{spawn => exec}/async.luau | 4 +- tests/process/{spawn => exec}/basic.luau | 4 +- tests/process/{spawn => exec}/cwd.luau | 12 +- tests/process/exec/no_panic.luau | 7 + tests/process/{spawn => exec}/shell.luau | 2 +- tests/process/{spawn => exec}/stdin.luau | 4 +- tests/process/{spawn => exec}/stdio.luau | 4 +- tests/process/spawn/no_panic.luau | 7 - tests/stdio/format.luau | 2 +- types/process.luau | 151 +++++++++++++++++-- 22 files changed, 414 insertions(+), 64 deletions(-) create mode 100644 crates/lune-std-process/src/stream.rs create mode 100644 tests/process/create/kill.luau create mode 100644 tests/process/create/non_blocking.luau create mode 100644 tests/process/create/status.luau create mode 100644 tests/process/create/stream.luau rename tests/process/{spawn => exec}/async.luau (89%) rename tests/process/{spawn => exec}/basic.luau (89%) rename tests/process/{spawn => exec}/cwd.luau (81%) create mode 100644 tests/process/exec/no_panic.luau rename tests/process/{spawn => exec}/shell.luau (94%) rename tests/process/{spawn => exec}/stdin.luau (79%) rename tests/process/{spawn => exec}/stdio.luau (79%) delete mode 100644 tests/process/spawn/no_panic.luau diff --git a/.lune/hello_lune.luau b/.lune/hello_lune.luau index 197fe32..c50fd7b 100644 --- a/.lune/hello_lune.luau +++ b/.lune/hello_lune.luau @@ -129,7 +129,7 @@ end ]] print("Sending 4 pings to google 🌏") -local result = process.spawn("ping", { +local result = process.exec("ping", { "google.com", "-c 4", }) diff --git a/Cargo.lock b/Cargo.lock index 6ba3d92..3470b8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1629,6 +1629,8 @@ dependencies = [ name = "lune-std-process" version = "0.1.3" dependencies = [ + "bstr", + "bytes", "directories", "lune-utils", "mlua", diff --git a/crates/lune-std-process/Cargo.toml b/crates/lune-std-process/Cargo.toml index 8668dc0..86f5440 100644 --- a/crates/lune-std-process/Cargo.toml +++ b/crates/lune-std-process/Cargo.toml @@ -20,6 +20,9 @@ directories = "5.0" pin-project = "1.0" os_str_bytes = { version = "7.0", features = ["conversions"] } +bstr = "1.9" +bytes = "1.6.0" + tokio = { version = "1", default-features = false, features = [ "io-std", "io-util", diff --git a/crates/lune-std-process/src/lib.rs b/crates/lune-std-process/src/lib.rs index 29d73ea..b66bc0d 100644 --- a/crates/lune-std-process/src/lib.rs +++ b/crates/lune-std-process/src/lib.rs @@ -1,27 +1,33 @@ #![allow(clippy::cargo_common_metadata)] use std::{ + cell::RefCell, env::{ self, consts::{ARCH, OS}, }, path::MAIN_SEPARATOR, process::Stdio, + rc::Rc, + sync::Arc, }; use mlua::prelude::*; use lune_utils::TableBuilder; use mlua_luau_scheduler::{Functions, LuaSpawnExt}; +use options::ProcessSpawnOptionsStdio; use os_str_bytes::RawOsString; -use tokio::io::AsyncWriteExt; +use stream::{ChildProcessReader, ChildProcessWriter}; +use tokio::{io::AsyncWriteExt, process::Child, sync::RwLock}; mod options; +mod stream; mod tee_writer; mod wait_for_child; use self::options::ProcessSpawnOptions; -use self::wait_for_child::{wait_for_child, WaitForChildResult}; +use self::wait_for_child::wait_for_child; use lune_utils::path::get_current_dir; @@ -73,7 +79,8 @@ pub fn module(lua: &Lua) -> LuaResult { .with_value("cwd", cwd_str)? .with_value("env", env_tab)? .with_value("exit", process_exit)? - .with_async_function("spawn", process_spawn)? + .with_async_function("exec", process_exec)? + .with_function("create", process_create)? .build_readonly() } @@ -141,11 +148,16 @@ fn process_env_iter<'lua>( }) } -async fn process_spawn( +async fn process_exec( lua: &Lua, (program, args, options): (String, Option>, ProcessSpawnOptions), ) -> LuaResult { - let res = lua.spawn(spawn_command(program, args, options)).await?; + let res = lua + .spawn(async move { + let cmd = spawn_command_with_stdin(program, args, options.clone()).await?; + wait_for_child(cmd, options.stdio.stdout, options.stdio.stderr).await + }) + .await?; /* NOTE: If an exit code was not given by the child process, @@ -168,30 +180,104 @@ async fn process_spawn( .build_readonly() } -async fn spawn_command( +#[allow(clippy::await_holding_refcell_ref)] +fn process_create( + lua: &Lua, + (program, args, options): (String, Option>, ProcessSpawnOptions), +) -> LuaResult { + // We do not want the user to provide stdio options for process.create, + // so we reset the options, regardless of what the user provides us + let mut spawn_options = options.clone(); + spawn_options.stdio = ProcessSpawnOptionsStdio::default(); + + let (code_tx, code_rx) = tokio::sync::broadcast::channel(4); + let code_rx_rc = Rc::new(RefCell::new(code_rx)); + + let child = spawn_command(program, args, spawn_options)?; + + let child_arc = Arc::new(RwLock::new(child)); + + let child_arc_clone = Arc::clone(&child_arc); + let mut child_lock = tokio::task::block_in_place(|| child_arc_clone.blocking_write()); + + let stdin = child_lock.stdin.take().unwrap(); + let stdout = child_lock.stdout.take().unwrap(); + let stderr = child_lock.stderr.take().unwrap(); + + let child_arc_inner = Arc::clone(&child_arc); + + // Spawn a background task to wait for the child to exit and send the exit code + let status_handle = tokio::spawn(async move { + let res = child_arc_inner.write().await.wait().await; + + if let Ok(output) = res { + let code = output.code().unwrap_or_default(); + + code_tx + .send(code) + .expect("ExitCode receiver was unexpectedly dropped"); + } + }); + + TableBuilder::new(lua)? + .with_value("stdout", ChildProcessReader(stdout))? + .with_value("stderr", ChildProcessReader(stderr))? + .with_value("stdin", ChildProcessWriter(stdin))? + .with_async_function("kill", move |_, ()| { + // First, stop the status task so the RwLock is dropped + status_handle.abort(); + let child_arc_clone = Arc::clone(&child_arc); + + // Then get another RwLock to write to the child process and kill it + async move { Ok(child_arc_clone.write().await.kill().await?) } + })? + .with_async_function("status", move |lua, ()| { + let code_rx_rc_clone = Rc::clone(&code_rx_rc); + async move { + // Exit code of 9 corresponds to SIGKILL, which should be the only case where + // the receiver gets suddenly dropped + let code = code_rx_rc_clone.borrow_mut().recv().await.unwrap_or(9); + + TableBuilder::new(lua)? + .with_value("code", code)? + .with_value("ok", code == 0)? + .build_readonly() + } + })? + .build_readonly() +} + +async fn spawn_command_with_stdin( program: String, args: Option>, mut options: ProcessSpawnOptions, -) -> LuaResult { - let stdout = options.stdio.stdout; - let stderr = options.stdio.stderr; +) -> LuaResult { let stdin = options.stdio.stdin.take(); - let mut child = options - .into_command(program, args) - .stdin(if stdin.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(stdout.as_stdio()) - .stderr(stderr.as_stdio()) - .spawn()?; + let mut child = spawn_command(program, args, options)?; if let Some(stdin) = stdin { let mut child_stdin = child.stdin.take().unwrap(); child_stdin.write_all(&stdin).await.into_lua_err()?; } - wait_for_child(child, stdout, stderr).await + Ok(child) +} + +fn spawn_command( + program: String, + args: Option>, + options: ProcessSpawnOptions, +) -> LuaResult { + let stdout = options.stdio.stdout; + let stderr = options.stdio.stderr; + + let child = options + .into_command(program, args) + .stdin(Stdio::piped()) + .stdout(stdout.as_stdio()) + .stderr(stderr.as_stdio()) + .spawn()?; + + Ok(child) } diff --git a/crates/lune-std-process/src/stream.rs b/crates/lune-std-process/src/stream.rs new file mode 100644 index 0000000..830e055 --- /dev/null +++ b/crates/lune-std-process/src/stream.rs @@ -0,0 +1,58 @@ +use bstr::BString; +use bytes::BytesMut; +use mlua::prelude::*; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const CHUNK_SIZE: usize = 8; + +#[derive(Debug, Clone)] +pub struct ChildProcessReader(pub R); +#[derive(Debug, Clone)] +pub struct ChildProcessWriter(pub W); + +impl ChildProcessReader { + pub async fn read(&mut self, chunk_size: Option) -> LuaResult> { + let mut buf = BytesMut::with_capacity(chunk_size.unwrap_or(CHUNK_SIZE)); + self.0.read_buf(&mut buf).await?; + + Ok(buf.to_vec()) + } + + pub async fn read_to_end(&mut self) -> LuaResult> { + let mut buf = vec![]; + self.0.read_to_end(&mut buf).await?; + + Ok(buf) + } +} + +impl LuaUserData for ChildProcessReader { + fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_async_method_mut("read", |lua, this, chunk_size: Option| async move { + let buf = this.read(chunk_size).await?; + + if buf.is_empty() { + return Ok(LuaValue::Nil); + } + + Ok(LuaValue::String(lua.create_string(buf)?)) + }); + + methods.add_async_method_mut("readToEnd", |lua, this, ()| async { + Ok(lua.create_string(this.read_to_end().await?)) + }); + } +} + +impl ChildProcessWriter { + pub async fn write(&mut self, data: BString) -> LuaResult<()> { + self.0.write_all(data.as_ref()).await?; + Ok(()) + } +} + +impl LuaUserData for ChildProcessWriter { + fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_async_method_mut("write", |_, this, data| async { this.write(data).await }); + } +} diff --git a/crates/lune/src/tests.rs b/crates/lune/src/tests.rs index c0a53d8..d5f5640 100644 --- a/crates/lune/src/tests.rs +++ b/crates/lune/src/tests.rs @@ -138,13 +138,16 @@ create_tests! { process_cwd: "process/cwd", process_env: "process/env", process_exit: "process/exit", - process_spawn_async: "process/spawn/async", - process_spawn_basic: "process/spawn/basic", - process_spawn_cwd: "process/spawn/cwd", - process_spawn_no_panic: "process/spawn/no_panic", - process_spawn_shell: "process/spawn/shell", - process_spawn_stdin: "process/spawn/stdin", - process_spawn_stdio: "process/spawn/stdio", + process_exec_async: "process/exec/async", + process_exec_basic: "process/exec/basic", + process_exec_cwd: "process/exec/cwd", + process_exec_no_panic: "process/exec/no_panic", + process_exec_shell: "process/exec/shell", + process_exec_stdin: "process/exec/stdin", + process_exec_stdio: "process/exec/stdio", + process_spawn_non_blocking: "process/create/non_blocking", + process_spawn_status: "process/create/status", + process_spawn_stream: "process/create/stream", } #[cfg(feature = "std-regex")] diff --git a/scripts/generate_compression_test_files.luau b/scripts/generate_compression_test_files.luau index 954929a..ce7ac82 100644 --- a/scripts/generate_compression_test_files.luau +++ b/scripts/generate_compression_test_files.luau @@ -108,7 +108,7 @@ local BIN_ZLIB = if process.os == "macos" then "/opt/homebrew/bin/pigz" else "pi local function checkInstalled(program: string, args: { string }?) print("Checking if", program, "is installed") - local result = process.spawn(program, args) + local result = process.exec(program, args) if not result.ok then stdio.ewrite(string.format("Program '%s' is not installed\n", program)) process.exit(1) @@ -123,7 +123,7 @@ checkInstalled(BIN_ZLIB, { "--version" }) -- Run them to generate files local function run(program: string, args: { string }): string - local result = process.spawn(program, args) + local result = process.exec(program, args) if not result.ok then stdio.ewrite(string.format("Command '%s' failed\n", program)) if #result.stdout > 0 then diff --git a/tests/datetime/formatLocalTime.luau b/tests/datetime/formatLocalTime.luau index 4e2f657..8bd000c 100644 --- a/tests/datetime/formatLocalTime.luau +++ b/tests/datetime/formatLocalTime.luau @@ -31,7 +31,7 @@ if not runLocaleTests then return end -local dateCmd = process.spawn("bash", { "-c", "date +\"%A, %d %B %Y\" --date='@1693068988'" }, { +local dateCmd = process.exec("bash", { "-c", "date +\"%A, %d %B %Y\" --date='@1693068988'" }, { env = { LC_ALL = "fr_FR.UTF-8 ", }, diff --git a/tests/process/create/kill.luau b/tests/process/create/kill.luau new file mode 100644 index 0000000..e0cbbc7 --- /dev/null +++ b/tests/process/create/kill.luau @@ -0,0 +1,21 @@ +local process = require("@lune/process") + +-- Killing a child process should work as expected + +local message = "Hello, world!" +local child = process.create("cat") + +child.stdin:write(message) +child.kill() + +assert(child.status().code == 9, "Child process should have an exit code of 9 (SIGKILL)") + +assert( + child.stdout:readToEnd() == message, + "Reading from stdout of child process should work even after kill" +) + +local stdinWriteOk = pcall(function() + child.stdin:write(message) +end) +assert(not stdinWriteOk, "Writing to stdin of child process should not work after kill") diff --git a/tests/process/create/non_blocking.luau b/tests/process/create/non_blocking.luau new file mode 100644 index 0000000..82352a7 --- /dev/null +++ b/tests/process/create/non_blocking.luau @@ -0,0 +1,13 @@ +local process = require("@lune/process") + +-- Spawning a child process should not block the thread + +local childThread = coroutine.create(process.create) + +local ok, err = coroutine.resume(childThread, "echo", { "hello, world" }) +assert(ok, err) + +assert( + coroutine.status(childThread) == "dead", + "Child process should not block the thread it is running on" +) diff --git a/tests/process/create/status.luau b/tests/process/create/status.luau new file mode 100644 index 0000000..418c132 --- /dev/null +++ b/tests/process/create/status.luau @@ -0,0 +1,15 @@ +local process = require("@lune/process") + +-- The exit code of an child process should be correct + +local randomExitCode = math.random(0, 255) +local isOk = randomExitCode == 0 +local child = process.create("exit", { tostring(randomExitCode) }, { shell = true }) +local status = child.status() + +assert( + status.code == randomExitCode, + `Child process exited with wrong exit code, expected {randomExitCode}` +) + +assert(status.ok == isOk, `Child status should be {if status.ok then "ok" else "not ok"}`) diff --git a/tests/process/create/stream.luau b/tests/process/create/stream.luau new file mode 100644 index 0000000..89bb61a --- /dev/null +++ b/tests/process/create/stream.luau @@ -0,0 +1,18 @@ +local process = require("@lune/process") + +-- Should be able to write and read from child process streams + +local msg = "hello, world" + +local catChild = process.create("cat") +catChild.stdin:write(msg) +assert( + msg == catChild.stdout:read(#msg), + "Failed to write to stdin or read from stdout of child process" +) + +local echoChild = if process.os == "windows" + then process.create("/c", { "echo", msg, "1>&2" }, { shell = "cmd" }) + else process.create("echo", { msg, ">>/dev/stderr" }, { shell = true }) + +assert(msg == echoChild.stderr:read(#msg), "Failed to read from stderr of child process") diff --git a/tests/process/spawn/async.luau b/tests/process/exec/async.luau similarity index 89% rename from tests/process/spawn/async.luau rename to tests/process/exec/async.luau index 2f60f3a..205eccc 100644 --- a/tests/process/spawn/async.luau +++ b/tests/process/exec/async.luau @@ -4,7 +4,7 @@ local task = require("@lune/task") local IS_WINDOWS = process.os == "windows" --- Spawning a process should not block any lua thread(s) +-- Executing a command should not block any lua thread(s) local SLEEP_DURATION = 1 / 4 local SLEEP_SAMPLES = 2 @@ -31,7 +31,7 @@ for i = 1, SLEEP_SAMPLES, 1 do table.insert(args, 1, "-Milliseconds") end -- Windows does not have `sleep` as a process, so we use powershell instead. - process.spawn("sleep", args, if IS_WINDOWS then { shell = true } else nil) + process.exec("sleep", args, if IS_WINDOWS then { shell = true } else nil) sleepCounter += 1 end) end diff --git a/tests/process/spawn/basic.luau b/tests/process/exec/basic.luau similarity index 89% rename from tests/process/spawn/basic.luau rename to tests/process/exec/basic.luau index 012a8ee..41b0847 100644 --- a/tests/process/spawn/basic.luau +++ b/tests/process/exec/basic.luau @@ -2,7 +2,7 @@ local process = require("@lune/process") local stdio = require("@lune/stdio") local task = require("@lune/task") --- Spawning a child process should work, with options +-- Executing a command should work, with options local thread = task.delay(1, function() stdio.ewrite("Spawning a process should take a reasonable amount of time\n") @@ -12,7 +12,7 @@ end) local IS_WINDOWS = process.os == "windows" -local result = process.spawn( +local result = process.exec( if IS_WINDOWS then "cmd" else "ls", if IS_WINDOWS then { "/c", "dir" } else { "-a" } ) diff --git a/tests/process/spawn/cwd.luau b/tests/process/exec/cwd.luau similarity index 81% rename from tests/process/spawn/cwd.luau rename to tests/process/exec/cwd.luau index d9989df..96a7fe4 100644 --- a/tests/process/spawn/cwd.luau +++ b/tests/process/exec/cwd.luau @@ -6,7 +6,7 @@ local pwdCommand = if IS_WINDOWS then "cmd" else "pwd" local pwdArgs = if IS_WINDOWS then { "/c", "cd" } else {} -- Make sure the cwd option actually uses the directory we want -local rootPwd = process.spawn(pwdCommand, pwdArgs, { +local rootPwd = process.exec(pwdCommand, pwdArgs, { cwd = "/", }).stdout rootPwd = string.gsub(rootPwd, "^%s+", "") @@ -27,24 +27,24 @@ end -- Setting cwd should not change the cwd of this process -local pwdBefore = process.spawn(pwdCommand, pwdArgs).stdout -process.spawn("ls", {}, { +local pwdBefore = process.exec(pwdCommand, pwdArgs).stdout +process.exec("ls", {}, { cwd = "/", shell = true, }) -local pwdAfter = process.spawn(pwdCommand, pwdArgs).stdout +local pwdAfter = process.exec(pwdCommand, pwdArgs).stdout assert(pwdBefore == pwdAfter, "Current working directory changed after running child process") -- Setting the cwd on a child process should properly -- replace any leading ~ with the users real home dir -local homeDir1 = process.spawn("echo $HOME", nil, { +local homeDir1 = process.exec("echo $HOME", nil, { shell = true, }).stdout -- NOTE: Powershell for windows uses `$pwd.Path` instead of `pwd` as pwd would return -- a PathInfo object, using $pwd.Path gets the Path property of the PathInfo object -local homeDir2 = process.spawn(if IS_WINDOWS then "$pwd.Path" else "pwd", nil, { +local homeDir2 = process.exec(if IS_WINDOWS then "$pwd.Path" else "pwd", nil, { shell = true, cwd = "~", }).stdout diff --git a/tests/process/exec/no_panic.luau b/tests/process/exec/no_panic.luau new file mode 100644 index 0000000..a7d289f --- /dev/null +++ b/tests/process/exec/no_panic.luau @@ -0,0 +1,7 @@ +local process = require("@lune/process") + +-- Executing a non existent command as a child process +-- should not panic, but should error + +local success = pcall(process.exec, "someProgramThatDoesNotExist") +assert(not success, "Spawned a non-existent program") diff --git a/tests/process/spawn/shell.luau b/tests/process/exec/shell.luau similarity index 94% rename from tests/process/spawn/shell.luau rename to tests/process/exec/shell.luau index 6f64791..729f15a 100644 --- a/tests/process/spawn/shell.luau +++ b/tests/process/exec/shell.luau @@ -5,7 +5,7 @@ local IS_WINDOWS = process.os == "windows" -- Default shell should be /bin/sh on unix and powershell on Windows, -- note that powershell needs slightly different command flags for ls -local shellResult = process.spawn("ls", { +local shellResult = process.exec("ls", { if IS_WINDOWS then "-Force" else "-a", }, { shell = true, diff --git a/tests/process/spawn/stdin.luau b/tests/process/exec/stdin.luau similarity index 79% rename from tests/process/spawn/stdin.luau rename to tests/process/exec/stdin.luau index 56c77a5..f85cd0b 100644 --- a/tests/process/spawn/stdin.luau +++ b/tests/process/exec/stdin.luau @@ -10,8 +10,8 @@ local echoMessage = "Hello from child process!" -- When passing stdin to powershell on windows we must "accept" using the double newline local result = if IS_WINDOWS - then process.spawn("powershell", { "echo" }, { stdin = echoMessage .. "\n\n" }) - else process.spawn("xargs", { "echo" }, { stdin = echoMessage }) + then process.exec("powershell", { "echo" }, { stdin = echoMessage .. "\n\n" }) + else process.exec("xargs", { "echo" }, { stdin = echoMessage }) local resultStdout = if IS_WINDOWS then string.sub(result.stdout, #result.stdout - #echoMessage - 1) diff --git a/tests/process/spawn/stdio.luau b/tests/process/exec/stdio.luau similarity index 79% rename from tests/process/spawn/stdio.luau rename to tests/process/exec/stdio.luau index 0ea5b1c..524713b 100644 --- a/tests/process/spawn/stdio.luau +++ b/tests/process/exec/stdio.luau @@ -5,12 +5,12 @@ local IS_WINDOWS = process.os == "windows" -- Inheriting stdio & environment variables should work local echoMessage = "Hello from child process!" -local echoResult = process.spawn("echo", { +local echoResult = process.exec("echo", { if IS_WINDOWS then '"$Env:TEST_VAR"' else '"$TEST_VAR"', }, { env = { TEST_VAR = echoMessage }, shell = if IS_WINDOWS then "powershell" else "bash", - stdio = "inherit", + stdio = "inherit" :: process.SpawnOptionsStdioKind, -- FIXME: This should just work without a cast? }) -- Windows uses \r\n (CRLF) and unix uses \n (LF) diff --git a/tests/process/spawn/no_panic.luau b/tests/process/spawn/no_panic.luau deleted file mode 100644 index 3a57a9b..0000000 --- a/tests/process/spawn/no_panic.luau +++ /dev/null @@ -1,7 +0,0 @@ -local process = require("@lune/process") - --- Spawning a child process for a non-existent --- program should not panic, but should error - -local success = pcall(process.spawn, "someProgramThatDoesNotExist") -assert(not success, "Spawned a non-existent program") diff --git a/tests/stdio/format.luau b/tests/stdio/format.luau index 7ade5f5..c0cc7cf 100644 --- a/tests/stdio/format.luau +++ b/tests/stdio/format.luau @@ -109,7 +109,7 @@ assertContains( local _, errorMessage = pcall(function() local function innerInnerFn() - process.spawn("PROGRAM_THAT_DOES_NOT_EXIST") + process.exec("PROGRAM_THAT_DOES_NOT_EXIST") end local function innerFn() innerInnerFn() diff --git a/types/process.luau b/types/process.luau index 7b82052..6a4a12e 100644 --- a/types/process.luau +++ b/types/process.luau @@ -5,6 +5,9 @@ export type SpawnOptionsStdioKind = "default" | "inherit" | "forward" | "none" export type SpawnOptionsStdio = { stdout: SpawnOptionsStdioKind?, stderr: SpawnOptionsStdioKind?, +} + +export type ExecuteOptionsStdio = SpawnOptionsStdio & { stdin: string?, } @@ -12,27 +15,117 @@ export type SpawnOptionsStdio = { @interface SpawnOptions @within Process - A dictionary of options for `process.spawn`, with the following available values: + A dictionary of options for `process.create`, with the following available values: * `cwd` - The current working directory for the process * `env` - Extra environment variables to give to the process * `shell` - Whether to run in a shell or not - set to `true` to run using the default shell, or a string to run using a specific shell * `stdio` - How to treat output and error streams from the child process - see `SpawnOptionsStdioKind` and `SpawnOptionsStdio` for more info - * `stdin` - Optional standard input to pass to spawned child process ]=] export type SpawnOptions = { cwd: string?, env: { [string]: string }?, shell: (boolean | string)?, +} + +--[=[ + @interface ExecuteOptions + @within Process + + A dictionary of options for `process.exec`, with the following available values: + + * `cwd` - The current working directory for the process + * `env` - Extra environment variables to give to the process + * `shell` - Whether to run in a shell or not - set to `true` to run using the default shell, or a string to run using a specific shell + * `stdio` - How to treat output and error streams from the child process - see `SpawnOptionsStdioKind` and `ExecuteOptionsStdio` for more info + * `stdin` - Optional standard input to pass to executed child process +]=] +export type ExecuteOptions = SpawnOptions & { stdio: (SpawnOptionsStdioKind | SpawnOptionsStdio)?, stdin: string?, -- TODO: Remove this since it is now available in stdio above, breaking change } --[=[ - @interface SpawnResult + @class ChildProcessReader @within Process - Result type for child processes in `process.spawn`. + A reader class to read data from a child process' streams in realtime. +]=] +local ChildProcessReader = {} + +--[=[ + @within ChildProcessReader + + Reads a chunk of data (specified length or a default of 8 bytes at a time) from + the reader as a string. Returns nil if there is no more data to read. + + This function may yield until there is new data to read from reader, if all data + till present has already been read, and the process has not exited. + + @return The string containing the data read from the reader +]=] +function ChildProcessReader:read(chunkSize: number?): string? + return nil :: any +end + +--[=[ + @within ChildProcessReader + + Reads all the data currently present in the reader as a string. + This function will yield until the process exits. + + @return The string containing the data read from the reader +]=] +function ChildProcessReader:readToEnd(): string + return nil :: any +end + +--[=[ + @class ChildProcessWriter + @within Process + + A writer class to write data to a child process' streams in realtime. +]=] +local ChildProcessWriter = {} + +--[=[ + @within ChildProcessWriter + + Writes a buffer or string of data to the writer. + + @param data The data to write to the writer +]=] +function ChildProcessWriter:write(data: buffer | string): () + return nil :: any +end + +--[=[ + @interface ChildProcess + @within Process + + Result type for child processes in `process.create`. + + This is a dictionary containing the following values: + + * `stdin` - A writer to write to the child process' stdin - see `ChildProcessWriter` for more info + * `stdout` - A reader to read from the child process' stdout - see `ChildProcessReader` for more info + * `stderr` - A reader to read from the child process' stderr - see `ChildProcessReader` for more info + * `kill` - A function that kills the child process + * `status` - A function that yields and returns the exit status of the child process +]=] +export type ChildProcess = { + stdin: typeof(ChildProcessWriter), + stdout: typeof(ChildProcessReader), + stderr: typeof(ChildProcessReader), + kill: () -> (); + status: () -> { ok: boolean, code: number } +} + +--[=[ + @interface ExecuteResult + @within Process + + Result type for child processes in `process.exec`. This is a dictionary containing the following values: @@ -41,7 +134,7 @@ export type SpawnOptions = { * `stdout` - The full contents written to stdout by the child process, or an empty string if nothing was written * `stderr` - The full contents written to stderr by the child process, or an empty string if nothing was written ]=] -export type SpawnResult = { +export type ExecuteResult = { ok: boolean, code: number, stdout: string, @@ -73,8 +166,8 @@ export type SpawnResult = { -- Getting the current os and processor architecture print("Running " .. process.os .. " on " .. process.arch .. "!") - -- Spawning a child process - local result = process.spawn("program", { + -- Executing a command + local result = process.exec("program", { "cli argument", "other cli argument" }) @@ -83,6 +176,19 @@ export type SpawnResult = { else print(result.stderr) end + + -- Spawning a child process + local child = process.create("program", { + "cli argument", + "other cli argument" + }) + + -- Writing to the child process' stdin + child.stdin:write("Hello from Lune!") + + -- Reading from the child process' stdout + local data = child.stdout:read() + print(buffer.tostring(data)) ``` ]=] local process = {} @@ -163,19 +269,44 @@ end --[=[ @within Process - Spawns a child process that will run the program `program`, and returns a dictionary that describes the final status and ouput of the child process. + Spawns a child process in the background that runs the program `program`, and immediately returns + readers and writers to communicate with it. + + In order to execute a command and wait for its output, see `process.exec`. The second argument, `params`, can be passed as a list of string parameters to give to the program. The third argument, `options`, can be passed as a dictionary of options to give to the child process. Refer to the documentation for `SpawnOptions` for specific option keys and their values. - @param program The program to spawn as a child process + @param program The program to Execute as a child process + @param params Additional parameters to pass to the program + @param options A dictionary of options for the child process + @return A dictionary with the readers and writers to communicate with the child process +]=] +function process.create(program: string, params: { string }?, options: SpawnOptions?): ChildProcess + return nil :: any +end + +--[=[ + @within Process + + Executes a child process that will execute the command `program`, waiting for it to exit. + Upon exit, it returns a dictionary that describes the final status and ouput of the child process. + + In order to spawn a child process in the background, see `process.create`. + + The second argument, `params`, can be passed as a list of string parameters to give to the program. + + The third argument, `options`, can be passed as a dictionary of options to give to the child process. + Refer to the documentation for `ExecuteOptions` for specific option keys and their values. + + @param program The program to Execute as a child process @param params Additional parameters to pass to the program @param options A dictionary of options for the child process @return A dictionary representing the result of the child process ]=] -function process.spawn(program: string, params: { string }?, options: SpawnOptions?): SpawnResult +function process.exec(program: string, params: { string }?, options: ExecuteOptions?): ExecuteResult return nil :: any end