From c4374a0e1829a38abd7709b868875bd4e83b104d Mon Sep 17 00:00:00 2001 From: Filip Tibell Date: Thu, 24 Apr 2025 16:28:34 +0200 Subject: [PATCH] Complete migration of lune-std-process to use async-process instead of tokio --- Cargo.lock | 4 +- crates/lune-std-process/Cargo.toml | 4 +- crates/lune-std-process/src/create/child.rs | 86 +++++++++++++ .../src/create/child_reader.rs | 117 ++++++++++++++++++ .../src/create/child_writer.rs | 79 ++++++++++++ crates/lune-std-process/src/create/mod.rs | 7 ++ crates/lune-std-process/src/exec/mod.rs | 51 ++++++++ .../src/{ => exec}/tee_writer.rs | 0 .../src/{ => exec}/wait_for_child.rs | 3 +- crates/lune-std-process/src/lib.rs | 90 ++++---------- crates/lune-std-process/src/stream.rs | 65 ---------- tests/process/create/kill.luau | 22 ++-- tests/process/create/non_blocking.luau | 7 +- tests/process/create/status.luau | 28 +++-- tests/process/create/stream.luau | 32 +++-- types/process.luau | 74 ++++++----- 16 files changed, 466 insertions(+), 203 deletions(-) create mode 100644 crates/lune-std-process/src/create/child.rs create mode 100644 crates/lune-std-process/src/create/child_reader.rs create mode 100644 crates/lune-std-process/src/create/child_writer.rs create mode 100644 crates/lune-std-process/src/create/mod.rs create mode 100644 crates/lune-std-process/src/exec/mod.rs rename crates/lune-std-process/src/{ => exec}/tee_writer.rs (100%) rename crates/lune-std-process/src/{ => exec}/wait_for_child.rs (95%) delete mode 100644 crates/lune-std-process/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index e8e1dc9..90fb508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1709,13 +1709,15 @@ dependencies = [ name = "lune-std-process" version = "0.1.3" dependencies = [ - "async-io", + "async-channel", + "async-lock", "async-process", "blocking", "bstr", "bytes", "directories", "futures-lite", + "futures-util", "lune-utils", "mlua", "mlua-luau-scheduler", diff --git a/crates/lune-std-process/Cargo.toml b/crates/lune-std-process/Cargo.toml index 2b4d5ee..f83cae0 100644 --- a/crates/lune-std-process/Cargo.toml +++ b/crates/lune-std-process/Cargo.toml @@ -23,9 +23,11 @@ os_str_bytes = { version = "7.0", features = ["conversions"] } bstr = "1.9" bytes = "1.6.0" -async-io = "2.4" +async-channel = "2.3" +async-lock = "3.4" async-process = "2.3" blocking = "1.6" futures-lite = "2.6" +futures-util = "0.3" # Needed for select! macro... lune-utils = { version = "0.1.3", path = "../lune-utils" } diff --git a/crates/lune-std-process/src/create/child.rs b/crates/lune-std-process/src/create/child.rs new file mode 100644 index 0000000..6b40447 --- /dev/null +++ b/crates/lune-std-process/src/create/child.rs @@ -0,0 +1,86 @@ +use std::process::ExitStatus; + +use async_channel::{unbounded, Receiver, Sender}; +use async_process::Child as AsyncChild; +use futures_util::{select, FutureExt}; + +use mlua::prelude::*; +use mlua_luau_scheduler::LuaSpawnExt; + +use lune_utils::TableBuilder; + +use super::{ChildReader, ChildWriter}; + +#[derive(Debug, Clone)] +pub struct Child { + stdin: ChildWriter, + stdout: ChildReader, + stderr: ChildReader, + kill_tx: Sender<()>, + status_rx: Receiver>, +} + +impl Child { + pub fn new(lua: &Lua, mut child: AsyncChild) -> Self { + let stdin = ChildWriter::from(child.stdin.take()); + let stdout = ChildReader::from(child.stdout.take()); + let stderr = ChildReader::from(child.stderr.take()); + + // NOTE: Kill channel is zero size, status is very small + // and implements Copy, unbounded will be just fine here + let (kill_tx, kill_rx) = unbounded(); + let (status_tx, status_rx) = unbounded(); + lua.spawn(handle_child(child, kill_rx, status_tx)).detach(); + + Self { + stdin, + stdout, + stderr, + kill_tx, + status_rx, + } + } +} + +impl LuaUserData for Child { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("stdin", |_, this| Ok(this.stdin.clone())); + fields.add_field_method_get("stdout", |_, this| Ok(this.stdout.clone())); + fields.add_field_method_get("stderr", |_, this| Ok(this.stderr.clone())); + } + + fn add_methods>(methods: &mut M) { + methods.add_method("kill", |_, this, (): ()| { + let _ = this.kill_tx.try_send(()); + Ok(()) + }); + methods.add_async_method("status", |lua, this, (): ()| { + let rx = this.status_rx.clone(); + async move { + let status = rx.recv().await.ok().flatten(); + let code = status.and_then(|c| c.code()).unwrap_or(9); + TableBuilder::new(lua.clone())? + .with_value("ok", code == 0)? + .with_value("code", code)? + .build_readonly() + } + }); + } +} + +async fn handle_child( + mut child: AsyncChild, + kill_rx: Receiver<()>, + status_tx: Sender>, +) { + let status = select! { + s = child.status().fuse() => s.ok(), // FUTURE: Propagate this error somehow? + _ = kill_rx.recv().fuse() => { + let _ = child.kill(); // Will only error if already killed + None + } + }; + + // Will only error if there are no receivers waiting for the status + let _ = status_tx.send(status).await; +} diff --git a/crates/lune-std-process/src/create/child_reader.rs b/crates/lune-std-process/src/create/child_reader.rs new file mode 100644 index 0000000..c9b16e9 --- /dev/null +++ b/crates/lune-std-process/src/create/child_reader.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use async_lock::Mutex as AsyncMutex; +use async_process::{ChildStderr as AsyncChildStderr, ChildStdout as AsyncChildStdout}; +use futures_lite::prelude::*; + +use mlua::prelude::*; + +const DEFAULT_BUFFER_SIZE: usize = 1024; + +// Inner (plumbing) implementation + +#[derive(Debug)] +enum ChildReaderInner { + None, + Stdout(AsyncChildStdout), + Stderr(AsyncChildStderr), +} + +impl ChildReaderInner { + async fn read(&mut self, size: usize) -> Result, std::io::Error> { + if matches!(self, ChildReaderInner::None) { + return Ok(Vec::new()); + } + + let mut buf = vec![0; size]; + + let read = match self { + ChildReaderInner::None => unreachable!(), + ChildReaderInner::Stdout(stdout) => stdout.read(&mut buf).await?, + ChildReaderInner::Stderr(stderr) => stderr.read(&mut buf).await?, + }; + + buf.truncate(read); + + Ok(buf) + } + + async fn read_to_end(&mut self) -> Result, std::io::Error> { + let mut buf = Vec::new(); + + let read = match self { + ChildReaderInner::None => 0, + ChildReaderInner::Stdout(stdout) => stdout.read_to_end(&mut buf).await?, + ChildReaderInner::Stderr(stderr) => stderr.read_to_end(&mut buf).await?, + }; + + buf.truncate(read); + + Ok(buf) + } +} + +impl From for ChildReaderInner { + fn from(stdout: AsyncChildStdout) -> Self { + Self::Stdout(stdout) + } +} + +impl From for ChildReaderInner { + fn from(stderr: AsyncChildStderr) -> Self { + Self::Stderr(stderr) + } +} + +impl From> for ChildReaderInner { + fn from(stdout: Option) -> Self { + stdout.map_or(Self::None, Into::into) + } +} + +impl From> for ChildReaderInner { + fn from(stderr: Option) -> Self { + stderr.map_or(Self::None, Into::into) + } +} + +// Outer (lua-accessible, clonable) implementation + +#[derive(Debug, Clone)] +pub struct ChildReader { + inner: Arc>, +} + +impl LuaUserData for ChildReader { + fn add_methods>(methods: &mut M) { + methods.add_async_method("read", |lua, this, size: Option| { + let inner = this.inner.clone(); + let size = size.unwrap_or(DEFAULT_BUFFER_SIZE); + async move { + let mut inner = inner.lock().await; + let bytes = inner.read(size).await.into_lua_err()?; + if bytes.is_empty() { + Ok(LuaValue::Nil) + } else { + Ok(LuaValue::String(lua.create_string(bytes)?)) + } + } + }); + methods.add_async_method("readToEnd", |lua, this, (): ()| { + let inner = this.inner.clone(); + async move { + let mut inner = inner.lock().await; + let bytes = inner.read_to_end().await.into_lua_err()?; + Ok(lua.create_string(bytes)) + } + }); + } +} + +impl> From for ChildReader { + fn from(inner: T) -> Self { + Self { + inner: Arc::new(AsyncMutex::new(inner.into())), + } + } +} diff --git a/crates/lune-std-process/src/create/child_writer.rs b/crates/lune-std-process/src/create/child_writer.rs new file mode 100644 index 0000000..8b276d0 --- /dev/null +++ b/crates/lune-std-process/src/create/child_writer.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use async_lock::Mutex as AsyncMutex; +use async_process::ChildStdin as AsyncChildStdin; +use futures_lite::prelude::*; + +use bstr::BString; +use mlua::prelude::*; + +// Inner (plumbing) implementation + +#[derive(Debug)] +enum ChildWriterInner { + None, + Stdin(AsyncChildStdin), +} + +impl ChildWriterInner { + async fn write(&mut self, data: Vec) -> Result<(), std::io::Error> { + match self { + ChildWriterInner::None => Ok(()), + ChildWriterInner::Stdin(stdin) => stdin.write_all(&data).await, + } + } + + async fn close(&mut self) -> Result<(), std::io::Error> { + match self { + ChildWriterInner::None => Ok(()), + ChildWriterInner::Stdin(stdin) => stdin.close().await, + } + } +} + +impl From for ChildWriterInner { + fn from(stdin: AsyncChildStdin) -> Self { + ChildWriterInner::Stdin(stdin) + } +} + +impl From> for ChildWriterInner { + fn from(stdin: Option) -> Self { + stdin.map_or(Self::None, Into::into) + } +} + +// Outer (lua-accessible, clonable) implementation + +#[derive(Debug, Clone)] +pub struct ChildWriter { + inner: Arc>, +} + +impl LuaUserData for ChildWriter { + fn add_methods>(methods: &mut M) { + methods.add_async_method("write", |_, this, data: BString| { + let inner = this.inner.clone(); + let data = data.to_vec(); + async move { + let mut inner = inner.lock().await; + inner.write(data).await.into_lua_err() + } + }); + methods.add_async_method("close", |_, this, (): ()| { + let inner = this.inner.clone(); + async move { + let mut inner = inner.lock().await; + inner.close().await.into_lua_err() + } + }); + } +} + +impl> From for ChildWriter { + fn from(inner: T) -> Self { + Self { + inner: Arc::new(AsyncMutex::new(inner.into())), + } + } +} diff --git a/crates/lune-std-process/src/create/mod.rs b/crates/lune-std-process/src/create/mod.rs new file mode 100644 index 0000000..9433584 --- /dev/null +++ b/crates/lune-std-process/src/create/mod.rs @@ -0,0 +1,7 @@ +mod child; +mod child_reader; +mod child_writer; + +pub use self::child::Child; +pub use self::child_reader::ChildReader; +pub use self::child_writer::ChildWriter; diff --git a/crates/lune-std-process/src/exec/mod.rs b/crates/lune-std-process/src/exec/mod.rs new file mode 100644 index 0000000..aaa0895 --- /dev/null +++ b/crates/lune-std-process/src/exec/mod.rs @@ -0,0 +1,51 @@ +use async_process::Child; +use futures_lite::prelude::*; + +use mlua::prelude::*; + +use lune_utils::TableBuilder; + +use super::options::ProcessSpawnOptionsStdioKind; + +mod tee_writer; +mod wait_for_child; + +use self::wait_for_child::wait_for_child; + +pub async fn exec( + lua: Lua, + mut child: Child, + stdin: Option>, + stdout: ProcessSpawnOptionsStdioKind, + stderr: ProcessSpawnOptionsStdioKind, +) -> LuaResult { + // Write to stdin before anything else - if we got it + if let Some(stdin) = stdin { + let mut child_stdin = child.stdin.take().unwrap(); + child_stdin.write_all(&stdin).await.into_lua_err()?; + } + + let res = wait_for_child(child, stdout, stderr).await?; + + /* + NOTE: If an exit code was not given by the child process, + we default to 1 if it yielded any error output, otherwise 0 + + An exit code may be missing if the process was terminated by + some external signal, which is the only time we use this default + */ + let code = res + .status + .code() + .unwrap_or(i32::from(!res.stderr.is_empty())); + + // Construct and return a readonly lua table with results + let stdout = lua.create_string(&res.stdout)?; + let stderr = lua.create_string(&res.stderr)?; + TableBuilder::new(lua)? + .with_value("ok", code == 0)? + .with_value("code", code)? + .with_value("stdout", stdout)? + .with_value("stderr", stderr)? + .build_readonly() +} diff --git a/crates/lune-std-process/src/tee_writer.rs b/crates/lune-std-process/src/exec/tee_writer.rs similarity index 100% rename from crates/lune-std-process/src/tee_writer.rs rename to crates/lune-std-process/src/exec/tee_writer.rs diff --git a/crates/lune-std-process/src/wait_for_child.rs b/crates/lune-std-process/src/exec/wait_for_child.rs similarity index 95% rename from crates/lune-std-process/src/wait_for_child.rs rename to crates/lune-std-process/src/exec/wait_for_child.rs index 87be80e..194e20a 100644 --- a/crates/lune-std-process/src/wait_for_child.rs +++ b/crates/lune-std-process/src/exec/wait_for_child.rs @@ -6,7 +6,8 @@ use async_process::Child; use blocking::Unblock; use futures_lite::{io, prelude::*}; -use super::{options::ProcessSpawnOptionsStdioKind, tee_writer::AsyncTeeWriter}; +use super::tee_writer::AsyncTeeWriter; +use crate::options::ProcessSpawnOptionsStdioKind; #[derive(Debug, Clone)] pub(super) struct WaitForChildResult { diff --git a/crates/lune-std-process/src/lib.rs b/crates/lune-std-process/src/lib.rs index 26d87a9..09e86dd 100644 --- a/crates/lune-std-process/src/lib.rs +++ b/crates/lune-std-process/src/lib.rs @@ -10,21 +10,17 @@ use std::{ }; use mlua::prelude::*; -use mlua_luau_scheduler::{Functions, LuaSpawnExt}; +use mlua_luau_scheduler::Functions; -use async_process::Child; -use futures_lite::prelude::*; use os_str_bytes::RawOsString; use lune_utils::{path::get_current_dir, TableBuilder}; +mod create; +mod exec; mod options; -mod stream; -mod tee_writer; -mod wait_for_child; use self::options::ProcessSpawnOptions; -use self::wait_for_child::wait_for_child; /** Creates the `process` standard library module. @@ -42,6 +38,7 @@ pub fn module(lua: Lua) -> LuaResult { if !cwd_str.ends_with(MAIN_SEPARATOR) { cwd_str.push(MAIN_SEPARATOR); } + // Create constants for OS & processor architecture let os = lua.create_string(OS.to_lowercase())?; let arch = lua.create_string(ARCH.to_lowercase())?; @@ -50,6 +47,7 @@ pub fn module(lua: Lua) -> LuaResult { } else { "little" })?; + // Create readonly args array let args_vec = lua .app_data_ref::>() @@ -58,6 +56,7 @@ pub fn module(lua: Lua) -> LuaResult { let args_tab = TableBuilder::new(lua.clone())? .with_sequential_values(args_vec)? .build_readonly()?; + // Create proxied table for env that gets & sets real env vars let env_tab = TableBuilder::new(lua.clone())? .with_metatable( @@ -68,9 +67,11 @@ pub fn module(lua: Lua) -> LuaResult { .build_readonly()?, )? .build_readonly()?; + // Create our process exit function, the scheduler crate provides this let fns = Functions::new(lua.clone())?; let process_exit = fns.exit; + // Create the full process table TableBuilder::new(lua)? .with_value("os", os)? @@ -142,66 +143,9 @@ fn process_env_iter(lua: &Lua, (_, ()): (LuaValue, ())) -> LuaResult>, ProcessSpawnOptions), + (program, args, mut options): (String, Option>, ProcessSpawnOptions), ) -> LuaResult { - 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, - we default to 1 if it yielded any error output, otherwise 0 - - An exit code may be missing if the process was terminated by - some external signal, which is the only time we use this default - */ - let code = res - .status - .code() - .unwrap_or(i32::from(!res.stderr.is_empty())); - - // Construct and return a readonly lua table with results - TableBuilder::new(lua.clone())? - .with_value("ok", code == 0)? - .with_value("code", code)? - .with_value("stdout", lua.create_string(&res.stdout)?)? - .with_value("stderr", lua.create_string(&res.stderr)?)? - .build_readonly() -} - -#[allow(clippy::await_holding_refcell_ref)] -fn process_create( - _lua: &Lua, - (_program, _args, _options): (String, Option>, ProcessSpawnOptions), -) -> LuaResult { - Err(LuaError::runtime("unimplemented")) -} - -async fn spawn_command_with_stdin( - program: String, - args: Option>, - mut options: ProcessSpawnOptions, -) -> LuaResult { let stdin = options.stdio.stdin.take(); - - 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()?; - } - - Ok(child) -} - -fn spawn_command( - program: String, - args: Option>, - options: ProcessSpawnOptions, -) -> LuaResult { let stdout = options.stdio.stdout; let stderr = options.stdio.stderr; @@ -212,5 +156,19 @@ fn spawn_command( .stderr(stderr.as_stdio()) .spawn()?; - Ok(child) + exec::exec(lua, child, stdin, stdout, stderr).await +} + +fn process_create( + lua: &Lua, + (program, args, options): (String, Option>, ProcessSpawnOptions), +) -> LuaResult { + let child = options + .into_command(program, args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + create::Child::new(lua, child).into_lua(lua) } diff --git a/crates/lune-std-process/src/stream.rs b/crates/lune-std-process/src/stream.rs deleted file mode 100644 index 8b0702f..0000000 --- a/crates/lune-std-process/src/stream.rs +++ /dev/null @@ -1,65 +0,0 @@ -use bstr::BString; -use futures_lite::prelude::*; -use mlua::prelude::*; - -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 = vec![0u8; chunk_size.unwrap_or(CHUNK_SIZE)]; - - let read = self.0.read(&mut buf).await?; - buf.truncate(read); - - Ok(buf) - } - - 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>(methods: &mut M) { - methods.add_async_method_mut( - "read", - |lua, mut 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, mut this, ()| async move { - 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>(methods: &mut M) { - methods.add_async_method_mut("write", |_, mut this, data| async move { - this.write(data).await - }); - } -} diff --git a/tests/process/create/kill.luau b/tests/process/create/kill.luau index e0cbbc7..9ddacd3 100644 --- a/tests/process/create/kill.luau +++ b/tests/process/create/kill.luau @@ -1,21 +1,17 @@ local process = require("@lune/process") --- Killing a child process should work as expected +local expected = "Hello, world!" -local message = "Hello, world!" -local child = process.create("cat") +local catChild = process.create("cat") +catChild.stdin:write(expected) +catChild:kill() +local catStatus = catChild:status() +local catStdout = catChild.stdout:readToEnd() -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" -) +assert(catStatus.code == 9, "Child process should have an exit code of 9 (SIGKILL)") +assert(catStdout == expected, "Reading from stdout of child process should work even after kill") local stdinWriteOk = pcall(function() - child.stdin:write(message) + catChild.stdin:write(expected) 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 index 82352a7..3c7b58c 100644 --- a/tests/process/create/non_blocking.luau +++ b/tests/process/create/non_blocking.luau @@ -1,13 +1,8 @@ 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" -) +assert(coroutine.status(childThread) == "dead", "Child process should not yield the thread it is created on") diff --git a/tests/process/create/status.luau b/tests/process/create/status.luau index 418c132..4e461dc 100644 --- a/tests/process/create/status.luau +++ b/tests/process/create/status.luau @@ -1,15 +1,25 @@ local process = require("@lune/process") --- The exit code of an child process should be correct +local testCode = math.random(0, 255) +local testOk = testCode == 0 -local randomExitCode = math.random(0, 255) -local isOk = randomExitCode == 0 -local child = process.create("exit", { tostring(randomExitCode) }, { shell = true }) -local status = child.status() +local exitChild = process.create("exit", { tostring(testCode) }, { shell = true }) +local exitStatus = exitChild:status() + +assert(type(exitStatus) == "table", "Child status should be a table") +assert(type(exitStatus.ok) == "boolean", "Child status.ok should be a boolean") +assert(type(exitStatus.code) == "number", "Child status.code should be a number") assert( - status.code == randomExitCode, - `Child process exited with wrong exit code, expected {randomExitCode}` + exitStatus.ok == testOk, + "Child status should be " + .. (if exitStatus.ok then "ok" else "not ok") + .. ", was " + .. (if exitStatus.ok then "not ok" else "ok") +) +assert( + exitStatus.code == testCode, + "Child process exited with an unexpected exit code!" + .. `\nExpected: ${testCode}` + .. `\nReceived: ${exitStatus.code}` ) - -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 index 89bb61a..883165d 100644 --- a/tests/process/create/stream.luau +++ b/tests/process/create/stream.luau @@ -1,18 +1,32 @@ local process = require("@lune/process") --- Should be able to write and read from child process streams +local expected = "hello, world" -local msg = "hello, world" +-- Stdout test local catChild = process.create("cat") -catChild.stdin:write(msg) +catChild.stdin:write(expected) +catChild.stdin:close() +local catOutput = catChild.stdout:read(#expected) + assert( - msg == catChild.stdout:read(#msg), - "Failed to write to stdin or read from stdout of child process" + expected == catOutput, + "Failed to write to stdin or read from stdout of child process!" + .. `\nExpected: "{expected}"` + .. `\nReceived: "{catOutput}"` ) -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 }) +-- Stderr test, needs to run in shell because there is no +-- other good cross-platform way to simply write to stdout -assert(msg == echoChild.stderr:read(#msg), "Failed to read from stderr of child process") +local echoChild = if process.os == "windows" + then process.create("/c", { "echo", expected, "1>&2" }, { shell = "cmd" }) + else process.create("echo", { expected, ">>/dev/stderr" }, { shell = true }) +local echoOutput = echoChild.stderr:read(#expected) + +assert( + expected == echoOutput, + "Failed to write to stdin or read from stderr of child process!" + .. `\nExpected: "{expected}"` + .. `\nReceived: "{echoOutput}"` +) diff --git a/types/process.luau b/types/process.luau index 9c43600..c76c9c7 100644 --- a/types/process.luau +++ b/types/process.luau @@ -2,18 +2,15 @@ export type OS = "linux" | "macos" | "windows" export type Arch = "x86_64" | "aarch64" export type Endianness = "big" | "little" -export type SpawnOptionsStdioKind = "default" | "inherit" | "forward" | "none" -export type SpawnOptionsStdio = { - stdout: SpawnOptionsStdioKind?, - stderr: SpawnOptionsStdioKind?, -} - -export type ExecuteOptionsStdio = SpawnOptionsStdio & { - stdin: string?, +export type StdioKind = "default" | "inherit" | "forward" | "none" +export type StdioOptions = { + stdin: StdioKind?, + stdout: StdioKind?, + stderr: StdioKind?, } --[=[ - @interface SpawnOptions + @interface CreateOptions @within Process A dictionary of options for `process.create`, with the following available values: @@ -21,16 +18,15 @@ export type ExecuteOptionsStdio = SpawnOptionsStdio & { * `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 ]=] -export type SpawnOptions = { +export type CreateOptions = { cwd: string?, env: { [string]: string }?, shell: (boolean | string)?, } --[=[ - @interface ExecuteOptions + @interface ExecOptions @within Process A dictionary of options for `process.exec`, with the following available values: @@ -38,12 +34,13 @@ export type SpawnOptions = { * `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 + * `stdio` - How to treat output and error streams from the child process - see `StdioKind` and `StdioOptions` for more info ]=] -export type ExecuteOptions = SpawnOptions & { - stdio: (SpawnOptionsStdioKind | SpawnOptionsStdio)?, - stdin: string?, -- TODO: Remove this since it is now available in stdio above, breaking change +export type ExecOptions = { + cwd: string?, + env: { [string]: string }?, + shell: (boolean | string)?, + stdio: (StdioKind | StdioOptions)?, } --[=[ @@ -57,8 +54,9 @@ 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. + Reads a chunk of data up to the specified length, or a default of 1KB at a time. + + 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. @@ -100,6 +98,15 @@ function ChildProcessWriter:write(data: buffer | string): () return nil :: any end +--[=[ + @within ChildProcessWriter + + Closes the underlying I/O stream for the writer. +]=] +function ChildProcessWriter:close(): () + return nil :: any +end + --[=[ @interface ChildProcess @within Process @@ -111,19 +118,22 @@ end * `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 + * `kill` - A method that kills the child process + * `status` - A method 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 }, + kill: (self: ChildProcess) -> (), + status: (self: ChildProcess) -> { + ok: boolean, + code: number, + }, } --[=[ - @interface ExecuteResult + @interface ExecResult @within Process Result type for child processes in `process.exec`. @@ -135,7 +145,7 @@ export type ChildProcess = { * `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 ExecuteResult = { +export type ExecResult = { ok: boolean, code: number, stdout: string, @@ -189,7 +199,7 @@ export type ExecuteResult = { -- Reading from the child process' stdout local data = child.stdout:read() - print(buffer.tostring(data)) + print(data) ``` ]=] local process = {} @@ -284,8 +294,8 @@ end --[=[ @within Process - Spawns a child process in the background that runs the program `program`, and immediately returns - readers and writers to communicate with it. + 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`. @@ -299,14 +309,14 @@ end @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 +function process.create(program: string, params: { string }?, options: CreateOptions?): ChildProcess return nil :: any end --[=[ @within Process - Executes a child process that will execute the command `program`, waiting for it to exit. + 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`. @@ -314,14 +324,14 @@ end 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. + Refer to the documentation for `ExecOptions` 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.exec(program: string, params: { string }?, options: ExecuteOptions?): ExecuteResult +function process.exec(program: string, params: { string }?, options: ExecOptions?): ExecResult return nil :: any end