diff --git a/.seal/typedefs/std/io/input.luau b/.seal/typedefs/std/io/input.luau index 2c2635d3..e7db4e03 100644 --- a/.seal/typedefs/std/io/input.luau +++ b/.seal/typedefs/std/io/input.luau @@ -83,16 +83,39 @@ function input.editline(prompt: string, left: string, right: string?): string | return nil :: any end +export type InputReadOptions = { + --- The maximum number of bytes to read before returning; reading stops once this many bytes + --- have been read, even if the stream is still open. Accepts a plain `number` or a `FileSize`. + bytes: (number | FileSize)?, + --- A `Duration` (from `@std/time`) to wait for input before returning; reading stops once the + --- timeout elapses, even if the stream is still open. + timeout: Duration?, +} + --[=[ - Reads all of stdin until EOF, returning the full contents as a string. + Reads from stdin, returning the bytes read and whether there may be more left to read. - Blocks until the stream closes — either a pipe closes or the user presses Ctrl+D in a TTY. + With no `options`, reads all of stdin until EOF and blocks until the stream closes — either a + pipe closes or the user presses Ctrl+D in a TTY. Useful for consuming piped input in scripts, e.g. `echo "hello" | seal script.luau`. + ## Parameters + + - `options.bytes`: the maximum number of bytes to read before returning. Accepts a plain + `number` or a `FileSize` (from `@std/fs/filesize`). Reading stops once this many bytes have + been read, even if the stream is still open. + - `options.timeout`: a `Duration` (from `@std/time`) to wait for input before returning. + Reading stops once the timeout elapses, even if the stream is still open. + ## Returns - - A `string` containing all bytes read from stdin before EOF. + Returns two values: + + - A `string` containing the bytes read from stdin, or `nil` if nothing was read. + - A `boolean` that is `true` if reading stopped before EOF (i.e. because the `bytes` limit was + reached or the `timeout` elapsed and there may be more to read), and `false` if the stream + reached EOF. ## Errors @@ -101,11 +124,21 @@ end ## Usage ```luau + -- read everything until EOF local contents = input.read() - print(`got {#contents} bytes from stdin`) + print(`got {#(contents or "")} bytes from stdin`) + + -- read at most 1 KB, or give up after 5 seconds + local chunk, more = input.read { + bytes = filesize.kilobytes(1), + timeout = time.seconds(5), + } + if more then + print("there's still more to read!") + end ``` ]=] -function input.read(): string +function input.read(options: InputReadOptions?): (string?, boolean) return nil :: any end diff --git a/.seal/typedefs/std/process.luau b/.seal/typedefs/std/process.luau index 3366e195..ed209ced 100644 --- a/.seal/typedefs/std/process.luau +++ b/.seal/typedefs/std/process.luau @@ -373,7 +373,7 @@ export type SpawnOptions = { --- Represents the stdout and stderr streams of a `ChildProcess`, both ran in parallel threads --- and streamed for nonblocking behavior. export type ChildProcessStream = setmetatable<{ - --> stream:read(count: number?, timeout: number?): string? + --> stream:read(count: number?, timeout: (Duration | number)?): string? --[=[ Reads up to `count` bytes from the stream for up to `timeout` seconds, retrying while the stream remains empty. @@ -410,8 +410,8 @@ export type ChildProcessStream = setmetatable<{ local current_data = child.stdout:read(nil, 0.0) ``` ]=] - read: (self: ChildProcessStream, count: number?, timeout: number?) -> string?, - --> stream:read_exact(count: number, timeout: number?): string? + read: (self: ChildProcessStream, count: number?, timeout: (Duration | number)?) -> string?, + --> stream:read_exact(count: number, timeout: (Duration | number)?): string? --[=[ Reads exactly `count` bytes from the stream, retrying until `count` bytes are available or `timeout` seconds elapse. @@ -455,8 +455,8 @@ export type ChildProcessStream = setmetatable<{ end ``` ]=] - read_exact: (self: ChildProcessStream, count: number, timeout: number?) -> string?, - --> stream:read_to(term: string, inclusive: boolean?, timeout: number?, allow_partial: boolean?): string? + read_exact: (self: ChildProcessStream, count: number, timeout: (Duration | number)?) -> string?, + --> stream:read_to(term: string, inclusive: boolean?, timeout: (Duration | number)?, allow_partial: boolean?): string? --[=[ Keep reading from the stream until search `term` is encountered. This is especially useful if you're trying to read line-by-line, or until a specific delimiter is encountered. @@ -477,8 +477,8 @@ export type ChildProcessStream = setmetatable<{ Blocks the current VM until `term` is found, `timeout` seconds elapse, or the reader thread exits. ]=] - read_to: (self: ChildProcessStream, term: string, inclusive: boolean?, timeout: number?, allow_partial: boolean?) -> string?, - --> stream:fill(target: buffer, target_offset: number?, timeout: number?): number + read_to: (self: ChildProcessStream, term: string, inclusive: boolean?, timeout: (Duration | number)?, allow_partial: boolean?) -> string?, + --> stream:fill(target: buffer, target_offset: number?, timeout: (Duration | number)?): number --[=[ Fill the `target` buffer with as many bytes as possible from the stream. Retries until the stream is nonempty or `timeout` seconds elapse. @@ -508,8 +508,8 @@ export type ChildProcessStream = setmetatable<{ end ``` ]=] - fill: (self: ChildProcessStream, target: buffer, target_offset: number?, timeout: number?) -> number, - --> stream:fill_exact(count: number, target: buffer, target_offset: number?, timeout: number?): boolean + fill: (self: ChildProcessStream, target: buffer, target_offset: number?, timeout: (Duration | number)?) -> number, + --> stream:fill_exact(count: number, target: buffer, target_offset: number?, timeout: (Duration | number)?): boolean --[=[ Read exactly `count` bytes into the `target` buffer at `target_offset`, retrying until `count` bytes are available or `timeout` seconds elapse. @@ -529,14 +529,14 @@ export type ChildProcessStream = setmetatable<{ Pass a `timeout` of `0` seconds to prevent this function from blocking! ]=] - fill_exact: (self: ChildProcessStream, count: number, target: buffer, target_offset: number?, timeout: number?) -> boolean, + fill_exact: (self: ChildProcessStream, count: number, target: buffer, target_offset: number?, timeout: (Duration | number)?) -> boolean, --> stream:len(): number --- Returns the current length/size of the stream's inner buffer. len: (self: ChildProcessStream) -> number, --> stream:capacity(): number --- Returns the maximum capacity of the stream's inner buffer. capacity: (self: ChildProcessStream) -> number, - --> for line in stream:lines(timeout: number?) + --> for line in stream:lines(timeout: (Duration | number)?) --[=[ Iterate over the lines in the stream, blocking the current VM (Rust Thread) until all lines are read or the timeout has been reached. @@ -578,8 +578,8 @@ export type ChildProcessStream = setmetatable<{ end ``` ]=] - lines: (self: ChildProcessStream, timeout: number?) -> (() -> string), - --> for message in stream:iter(timeout: number?, write_delay_ms: number?) + lines: (self: ChildProcessStream, timeout: (Duration | number)?) -> (() -> string), + --> for message in stream:iter(timeout: (Duration | number)?, write_delay_ms: number?) --[=[ Iterate over the stream with more granular options: @@ -592,7 +592,7 @@ export type ChildProcessStream = setmetatable<{ This function does *not* strip preceding '\r's and trailing '\n's (unlike `:lines()` and generalized iteration). ]=] - iter: (self: ChildProcessStream, timeout: number?, write_delay_ms: number?) -> () -> string, + iter: (self: ChildProcessStream, timeout: (Duration | number)?, write_delay_ms: number?) -> () -> string, }, { --> for line in stream --[=[ diff --git a/README.md b/README.md index b771655d..72cc35b3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It can surreptitiously replace your shell and single-use Python scripts with Lua - *seal* projects are more scalable than shell scripts. - Built in terminal manipulation for TUIs. - Powerful multithreading. -- 1101 handcrafted error messages. +- 1103 handcrafted error messages. ## Install diff --git a/docs/reference/std/io/input.md b/docs/reference/std/io/input.md index 1f53de7e..805e5828 100644 --- a/docs/reference/std/io/input.md +++ b/docs/reference/std/io/input.md @@ -128,12 +128,60 @@ This function shares most semantics with `input.readline`, including the followi --- -### input.interrupt +## `export type` InputReadOptions

```luau -function input.interrupt(key: "CtrlC" | "CtrlD"): interrupt +export type InputReadOptions = { +``` + +

+ +--- + +### InputReadOptions.bytes + +

+ +```luau + bytes: (number | FileSize)?, +``` + +

+ + The maximum number of bytes to read before returning; reading stops once this many bytes + have been read, even if the stream is still open. Accepts a plain `number` or a `FileSize`. + +--- + +### InputReadOptions.timeout + +

+ +```luau + timeout: Duration?, +``` + +

+ + A `Duration` (from `@std/time`) to wait for input before returning; reading stops once the + timeout elapses, even if the stream is still open. + +--- + +```luau +} -- closes InputReadOptions +``` + +--- + +#### io.input.input.read + +

+ +```luau +function io.input.input.read(options: InputReadOptions?): (string?, boolean) ```

@@ -142,15 +190,29 @@ function input.interrupt(key: "CtrlC" | "CtrlD"): interrupt See the docs Ctrl+D in a TTY. +With no `options`, reads all of stdin until EOF and blocks until the stream closes — either a +pipe closes or the user presses Ctrl+D in a TTY. Useful for consuming piped input in scripts, e.g. `echo "hello" | seal script.luau`. +## Parameters + +- `options.bytes`: the maximum number of bytes to read before returning. Accepts a plain +`number` or a `FileSize` (from `@std/fs/filesize`). Reading stops once this many bytes have +been read, even if the stream is still open. +- `options.timeout`: a `Duration` (from `@std/time`) to wait for input before returning. +Reading stops once the timeout elapses, even if the stream is still open. + ## Returns -- A `string` containing all bytes read from stdin before EOF. +Returns two values: + +- A `string` containing the bytes read from stdin, or `nil` if nothing was read. +- A `boolean` that is `true` if reading stopped before EOF (i.e. because the `bytes` limit was +reached or the `timeout` elapsed and there may be more to read), and `false` if the stream +reached EOF. ## Errors @@ -159,16 +221,38 @@ Useful for consuming piped input in scripts, e.g. `echo "hello" | seal script.lu ## Usage ```luau +-- read everything until EOF local contents = input.read() -print(`got {#contents} bytes from stdin`) +print(`got {#(contents or "")} bytes from stdin`) + +-- read at most 1 KB, or give up after 5 seconds +local chunk, more = input.read { + bytes = filesize.kilobytes(1), + timeout = time.seconds(5), +} +if more then + print("there's still more to read!") +end ``` -Returns an `interrupt` userdata object. For reasons. Maybe control flow. - --- +#### io.input.input.interrupt + +

+ +```luau +function io.input.input.interrupt(key: "CtrlC" | "CtrlD"): interrupt +``` + +

+ +Returns an `interrupt` userdata object. For reasons. Maybe control flow. + +--- + Autogenerated from [std/io/input.luau](/.seal/typedefs/std/io/input.luau). *seal* is best experienced with inline, in-editor documentation. Please see the linked typedefs file if this documentation is confusing, too verbose, or inaccurate. diff --git a/docs/reference/std/process.md b/docs/reference/std/process.md index c1b85b86..c7f48707 100644 --- a/docs/reference/std/process.md +++ b/docs/reference/std/process.md @@ -1011,7 +1011,7 @@ export type ChildProcessStream = setmetatable<{

```luau -function ChildProcessStream.read(self: ChildProcessStream, count: number?, timeout: number?) -> string?, +function ChildProcessStream.read(self: ChildProcessStream, count: number?, timeout: (Duration | number)?) -> string?, ```

@@ -1069,7 +1069,7 @@ local current_data = child.stdout:read(nil, 0.0)

```luau -function ChildProcessStream.read_exact(self: ChildProcessStream, count: number, timeout: number?) -> string?, +function ChildProcessStream.read_exact(self: ChildProcessStream, count: number, timeout: (Duration | number)?) -> string?, ```

@@ -1129,7 +1129,7 @@ end

```luau -function ChildProcessStream.read_to(self: ChildProcessStream, term: string, inclusive: boolean?, timeout: number?, allow_partial: boolean?) -> string?, +function ChildProcessStream.read_to(self: ChildProcessStream, term: string, inclusive: boolean?, timeout: (Duration | number)?, allow_partial: boolean?) -> string?, ```

@@ -1166,7 +1166,7 @@ Blocks the current VM until `term` is found, `timeout` seconds elapse, or the re

```luau -function ChildProcessStream.fill(self: ChildProcessStream, target: buffer, target_offset: number?, timeout: number?) -> number, +function ChildProcessStream.fill(self: ChildProcessStream, target: buffer, target_offset: number?, timeout: (Duration | number)?) -> number, ```

@@ -1212,7 +1212,7 @@ end

```luau -function ChildProcessStream.fill_exact(self: ChildProcessStream, count: number, target: buffer, target_offset: number?, timeout: number?) -> boolean, +function ChildProcessStream.fill_exact(self: ChildProcessStream, count: number, target: buffer, target_offset: number?, timeout: (Duration | number)?) -> boolean, ```

@@ -1276,7 +1276,7 @@ function ChildProcessStream.capacity(self: ChildProcessStream) -> number,

```luau -function ChildProcessStream.lines(self: ChildProcessStream, timeout: number?) -> (() -> string), +function ChildProcessStream.lines(self: ChildProcessStream, timeout: (Duration | number)?) -> (() -> string), ```

@@ -1334,7 +1334,7 @@ end

```luau -function ChildProcessStream.iter(self: ChildProcessStream, timeout: number?, write_delay_ms: number?) -> () -> string, +function ChildProcessStream.iter(self: ChildProcessStream, timeout: (Duration | number)?, write_delay_ms: number?) -> () -> string, ```

diff --git a/src/scripts/signature_generation/discovery.luau b/src/scripts/signature_generation/discovery.luau index dd8cae28..a579638f 100644 --- a/src/scripts/signature_generation/discovery.luau +++ b/src/scripts/signature_generation/discovery.luau @@ -310,13 +310,23 @@ local function extract_fn_sigs(typedef_path: string, is_method: boolean, type_na end -- Fallback: typedef files using `function module_var.fn_name(...)` style - -- (e.g. str.luau, io/input.luau) where type aliases produce nothing. - if next(fns) == nil and module_var_hint then + -- (e.g. str.luau, io/input.luau) where type aliases produce no real function signatures. + -- Non-function type aliases (like an `InputReadOptions` options table) leave only "" + -- entries behind, so check for actual extracted functions rather than an empty table. + local has_extracted_fn = false + for _, sig in fns do + if sig ~= "" then + has_extracted_fn = true + break + end + end + if not has_extracted_fn and module_var_hint then for _, stat in result.root.body do if stat.kind ~= "StatFunction" then continue end local stat_name_expr = (stat :: any).name :: any if not stat_name_expr or stat_name_expr.kind ~= "ExprIndexName" then continue end local fn_name = stat_name_expr.index :: string + if string.sub(fn_name, 1, 2) == "__" then continue end -- skip metamethods, like the alias path local base_expr = stat_name_expr.expr :: any local base_name: string if base_expr.kind == "ExprGlobal" then @@ -330,6 +340,11 @@ local function extract_fn_sigs(typedef_path: string, is_method: boolean, type_na local func = (stat :: any).func :: any + -- Skip `function module.foo(self, ...)` methods — those belong to the module's instance + -- type (e.g. `Semver:satisfies`), not the module library itself. + local first_arg = (func.args :: { any })[1] + if first_arg and (first_arg :: any).name == "self" then continue end + local gen_str = "" local generics = func.generics :: { any } if generics and #generics > 0 then diff --git a/src/signatures.rs b/src/signatures.rs index 528c57de..eb993764 100644 --- a/src/signatures.rs +++ b/src/signatures.rs @@ -220,7 +220,7 @@ pub const STD_IO_INPUT_EDITLINE: &std::ffi::CStr = c"io.input.editline(prompt: s pub const STD_IO_INPUT_GET: &std::ffi::CStr = c"io.input.get(raw_prompt: string) -> string"; pub const STD_IO_INPUT_INTERRUPT: &std::ffi::CStr = c"io.input.interrupt(key: \"CtrlC\" | \"CtrlD\") -> interrupt"; pub const STD_IO_INPUT_RAWLINE: &std::ffi::CStr = c"io.input.rawline(prompt: string?) -> string"; -pub const STD_IO_INPUT_READ: &std::ffi::CStr = c"io.input.read() -> string"; +pub const STD_IO_INPUT_READ: &std::ffi::CStr = c"io.input.read(options: InputReadOptions?) -> (string?, boolean)"; pub const STD_IO_INPUT_READLINE: &std::ffi::CStr = c"io.input.readline(prompt: string) -> string | interrupt | error"; // io.output @@ -282,14 +282,14 @@ pub const STD_PROCESS_RUN_RESULT_UNWRAP_OR: &std::ffi::CStr = c"RunResult:unwrap // ChildProcessStream pub const STD_PROCESS_CHILD_PROCESS_STREAM_CAPACITY: &std::ffi::CStr = c"ChildProcessStream:capacity() -> number"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_FILL: &std::ffi::CStr = c"ChildProcessStream:fill(target: buffer, target_offset: number?, timeout: number?) -> number"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_FILL_EXACT: &std::ffi::CStr = c"ChildProcessStream:fill_exact(count: number, target: buffer, target_offset: number?, timeout: number?) -> boolean"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_ITER: &std::ffi::CStr = c"ChildProcessStream:iter(timeout: number?, write_delay_ms: number?) -> () -> string"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_FILL: &std::ffi::CStr = c"ChildProcessStream:fill(target: buffer, target_offset: number?, timeout: Duration | number?) -> number"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_FILL_EXACT: &std::ffi::CStr = c"ChildProcessStream:fill_exact(count: number, target: buffer, target_offset: number?, timeout: Duration | number?) -> boolean"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_ITER: &std::ffi::CStr = c"ChildProcessStream:iter(timeout: Duration | number?, write_delay_ms: number?) -> () -> string"; pub const STD_PROCESS_CHILD_PROCESS_STREAM_LEN: &std::ffi::CStr = c"ChildProcessStream:len() -> number"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_LINES: &std::ffi::CStr = c"ChildProcessStream:lines(timeout: number?) -> () -> string"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ: &std::ffi::CStr = c"ChildProcessStream:read(count: number?, timeout: number?) -> string?"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ_EXACT: &std::ffi::CStr = c"ChildProcessStream:read_exact(count: number, timeout: number?) -> string?"; -pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ_TO: &std::ffi::CStr = c"ChildProcessStream:read_to(term: string, inclusive: boolean?, timeout: number?, allow_partial: boolean?) -> string?"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_LINES: &std::ffi::CStr = c"ChildProcessStream:lines(timeout: Duration | number?) -> () -> string"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ: &std::ffi::CStr = c"ChildProcessStream:read(count: number?, timeout: Duration | number?) -> string?"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ_EXACT: &std::ffi::CStr = c"ChildProcessStream:read_exact(count: number, timeout: Duration | number?) -> string?"; +pub const STD_PROCESS_CHILD_PROCESS_STREAM_READ_TO: &std::ffi::CStr = c"ChildProcessStream:read_to(term: string, inclusive: boolean?, timeout: Duration | number?, allow_partial: boolean?) -> string?"; // ChildProcess pub const STD_PROCESS_CHILD_PROCESS_ALIVE: &std::ffi::CStr = c"ChildProcess:alive() -> boolean"; @@ -299,6 +299,10 @@ pub const STD_PROCESS_CHILD_PROCESS_KILL: &std::ffi::CStr = c"ChildProcess:kill( pub const STD_PROCESS_PIPED_CHILD_ALIVE: &std::ffi::CStr = c"PipedChild:alive() -> boolean"; pub const STD_PROCESS_PIPED_CHILD_KILL: &std::ffi::CStr = c"PipedChild:kill()"; +// semver +pub const STD_SEMVER_DEFAULT: &std::ffi::CStr = c"semver.default() -> Semver"; +pub const STD_SEMVER_FROM: &std::ffi::CStr = c"semver.from(s: string) -> Semver"; + // serde.base64 pub const STD_SERDE_BASE64_DECODE: &std::ffi::CStr = c"serde.base64.decode(data: string) -> buffer"; pub const STD_SERDE_BASE64_ENCODE: &std::ffi::CStr = c"serde.base64.encode(data: string | buffer) -> string"; diff --git a/src/std_io/input.rs b/src/std_io/input.rs index a4c09d58..0b97fa47 100644 --- a/src/std_io/input.rs +++ b/src/std_io/input.rs @@ -10,6 +10,10 @@ use atty::Stream::{Stdout, Stderr}; use std::io::{self, BufRead, Read, Write}; use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use crate::std_fs::file_size::FileSize; +use crate::std_time::duration::TimeDuration; #[derive(Debug)] pub struct ExpectSaneOutputStream { @@ -209,19 +213,261 @@ pub fn input_editline(luau: &Lua, mut multivalue: LuaMultiValue) -> LuaValueResu ok_string(line, luau) } -fn input_read(luau: &Lua, _: ()) -> LuaValueResult { - let function_name = "input.read()"; - let mut buffy: Vec = Vec::new(); - if let Err(err) = io::stdin().read_to_end(&mut buffy) { - return wrap_err!("{}: unable to read from stdin to EOF or fill buffer: {}", function_name, err); +/// Parsed options for `input.read`; see `InputReadOptions::from_value` for how they're pulled out +/// of the Luau `{ bytes: (number | FileSize)?, timeout: Duration? }` table. +struct InputReadOptions { + /// Maximum number of bytes to read before returning; `None` means read until EOF. + max_bytes: Option, + /// How long to wait for input before returning; `None` means block indefinitely. + timeout: Option, +} + +impl InputReadOptions { + /// Reads the options table out of the Luau value passed to `input.read`. A missing/nil value + /// means "no options" (read everything, block indefinitely). + fn from_value(value: LuaValue, function_name: &'static str) -> LuaResult { + match value { + LuaNil => Ok(Self { max_bytes: None, timeout: None }), + LuaValue::Table(options) => Ok(Self { + max_bytes: Self::parse_bytes(options.raw_get("bytes")?, function_name)?, + timeout: Self::parse_timeout(options.raw_get("timeout")?, function_name)?, + }), + other => { + wrap_err!("{}: expected options to be a table or nil, got: {:?}", function_name, other) + } + } } - if buffy.is_empty() { - Ok(LuaNil) - } else { - ok_string(buffy, luau) + + /// Parses the `bytes` option (a plain number or a FileSize) into a max byte count. + fn parse_bytes(value: LuaValue, function_name: &'static str) -> LuaResult> { + match value { + LuaNil => Ok(None), + LuaValue::Integer(i) => Ok(Some(int_to_u64(i, function_name, "options.bytes")?)), + LuaValue::Number(f) => Ok(Some(float_to_u64(f, function_name, "options.bytes")?)), + LuaValue::UserData(ud) if let Ok(file_size) = ud.borrow::() => { + Ok(Some(file_size.as_bytes())) + }, + LuaValue::UserData(ud) => { + let type_name = ud.type_name()?.unwrap_or("userdata (missing __type metafield)".to_string()); + wrap_err!("{}: expected options.bytes to be a number or FileSize (from @std/fs/filesize), got a different kind of userdata: {}", function_name, type_name) + }, + other => { + wrap_err!("{}: expected options.bytes to be a number, FileSize, or nil, got: {:?}", function_name, other) + } + } + } + + /// Parses the `timeout` option (a Duration userdata) into a std Duration. + fn parse_timeout(value: LuaValue, function_name: &'static str) -> LuaResult> { + match value { + LuaNil => Ok(None), + LuaValue::UserData(ud) if let Ok(duration) = ud.borrow::() => { + let timeout = (*duration).inner; // SignedDuration is Copy, no drop worries + if !timeout.is_positive() { + return wrap_err!("{}: options.timeout must be a positive Duration, got: {:#?}", function_name, timeout); + } + Ok(Some(timeout.unsigned_abs())) + }, + LuaValue::UserData(ud) => { + let type_name = ud.type_name()?.unwrap_or("userdata (missing __type metafield)".to_string()); + wrap_err!("{}: expected options.timeout to be a Duration (from @std/time), got a different kind of userdata: {}", function_name, type_name) + }, + LuaValue::Number(_) | LuaValue::Integer(_) => { + wrap_err!("{}: options.timeout should be a Duration (from @std/time), not a plain number", function_name) + }, + other => { + wrap_err!("{}: expected options.timeout to be a Duration or nil, got: {:?}", function_name, other) + } + } + } +} + +/// Blocking read with no timeout: consume stdin until EOF, or until `max_bytes` have been read. +/// Returns the bytes read plus whether reading stopped before EOF (i.e. because the byte limit was +/// reached and there may be more to read). +fn read_stdin_blocking(max_bytes: Option) -> io::Result<(Vec, bool)> { + let stdin = io::stdin(); + let mut lock = stdin.lock(); + match max_bytes { + None => { + let mut buf: Vec = Vec::new(); + lock.read_to_end(&mut buf)?; + Ok((buf, false)) + }, + Some(max) => { + let mut buf: Vec = Vec::new(); + let mut chunk = [0u8; 8192]; + while (buf.len() as u64) < max { + let remaining = (max - buf.len() as u64).min(chunk.len() as u64) as usize; + let n = lock.read(&mut chunk[..remaining])?; + if n == 0 { + return Ok((buf, false)); // EOF before hitting the limit + } + buf.extend_from_slice(&chunk[..n]); + } + Ok((buf, true)) // hit the byte limit with the stream possibly still open + } } } +/// Unix timeout path: use `poll(2)` to wait for input up to the deadline while reading straight +/// from stdin's file descriptor, so we never leave a blocked reader thread behind. +#[cfg(unix)] +fn read_stdin_until_deadline(max_bytes: Option, deadline: Instant) -> io::Result<(Vec, bool)> { + use std::os::unix::io::AsRawFd; + + let fd = io::stdin().as_raw_fd(); + let mut buf: Vec = Vec::new(); + let mut chunk = [0u8; 8192]; + + loop { + let to_read = match max_bytes { + Some(max) => { + let already = buf.len() as u64; + if already >= max { + return Ok((buf, true)); + } + (max - already).min(chunk.len() as u64) as usize + }, + None => chunk.len(), + }; + + let now = Instant::now(); + if now >= deadline { + return Ok((buf, true)); // timed out with the stream still open + } + let remaining_ms = (deadline - now).as_millis().min(i32::MAX as u128) as i32; + let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; + // SAFETY: `pfd` is a single valid, mutable pollfd we own for the duration of the call, and + // we pass a matching count of 1; `fd` is stdin's descriptor, valid for as long as this + // process owns stdin. poll only reads/writes through the pointer during the call. + let ret = unsafe { libc::poll(&mut pfd, 1, remaining_ms) }; + if ret < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; // interrupted by a signal, retry + } + return Err(err); + } + if ret == 0 { + return Ok((buf, true)); // poll timed out + } + + // SAFETY: `chunk` is a stack buffer of `chunk.len()` bytes and `to_read <= chunk.len()` + // (clamped above), so the pointer is valid and writable for `to_read` bytes for the whole + // call; `fd` is stdin's descriptor, valid for as long as this process owns stdin. + let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, to_read) }; + if n < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(err); + } + if n == 0 { + return Ok((buf, false)); // EOF + } + buf.extend_from_slice(&chunk[..n as usize]); + } +} + +/// Non-unix timeout path: read stdin on a background thread and collect chunks with a deadline. +/// Mirrors the OSC-query reader in `std_terminal`; if the timeout elapses we return what we have +/// and leave the reader thread to finish on its own. +#[cfg(not(unix))] +fn read_stdin_until_deadline(max_bytes: Option, deadline: Instant) -> io::Result<(Vec, bool)> { + use std::sync::mpsc::{self, RecvTimeoutError}; + + enum ReadMsg { + Chunk(Vec), + Eof, + LimitReached, + Err(io::Error), + } + + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let stdin = io::stdin(); + let mut lock = stdin.lock(); + let mut chunk = [0u8; 8192]; + let mut total: u64 = 0; + loop { + let to_read = match max_bytes { + Some(max) => { + if total >= max { + let _ = tx.send(ReadMsg::LimitReached); + return; + } + (max - total).min(chunk.len() as u64) as usize + }, + None => chunk.len(), + }; + match lock.read(&mut chunk[..to_read]) { + Ok(0) => { + let _ = tx.send(ReadMsg::Eof); + return; + }, + Ok(n) => { + total += n as u64; + if tx.send(ReadMsg::Chunk(chunk[..n].to_vec())).is_err() { + return; // receiver hung up (we timed out); stop reading + } + }, + Err(err) => { + let _ = tx.send(ReadMsg::Err(err)); + return; + } + } + } + }); + + let mut buf: Vec = Vec::new(); + loop { + let now = Instant::now(); + if now >= deadline { + return Ok((buf, true)); + } + match rx.recv_timeout(deadline - now) { + Ok(ReadMsg::Chunk(c)) => buf.extend_from_slice(&c), + Ok(ReadMsg::Eof) => return Ok((buf, false)), + Ok(ReadMsg::LimitReached) => return Ok((buf, true)), + Ok(ReadMsg::Err(err)) => return Err(err), + Err(RecvTimeoutError::Timeout) => return Ok((buf, true)), + Err(RecvTimeoutError::Disconnected) => return Ok((buf, false)), + } + } +} + +/// Reads from stdin, stopping at EOF, at `max_bytes` (if set), or once `timeout` (if set) elapses. +/// Returns the bytes read plus whether reading stopped before EOF (there may be more to read). +fn read_from_stdin(max_bytes: Option, timeout: Option) -> io::Result<(Vec, bool)> { + match timeout { + None => read_stdin_blocking(max_bytes), + Some(timeout) => read_stdin_until_deadline(max_bytes, Instant::now() + timeout), + } +} + +fn input_read(luau: &Lua, options: LuaValue) -> LuaResult<(LuaValue, bool)> { + let function_name = "input.read(options: { bytes: (number | FileSize)?, timeout: Duration? }?)"; + + let options = InputReadOptions::from_value(options, function_name)?; + + let (buffy, more_to_read) = match read_from_stdin(options.max_bytes, options.timeout) { + Ok(result) => result, + Err(err) => { + return wrap_err!("{}: unable to read from stdin: {}", function_name, err); + } + }; + + let value = if buffy.is_empty() { + LuaNil + } else { + LuaValue::String(luau.create_string(&buffy)?) + }; + + Ok((value, more_to_read)) +} + fn input_interrupt(luau: &Lua, value: LuaValue) -> LuaValueResult { let function_name = "input.interrupt(code: \"CtrlC\" | \"CtrlD\")"; match value { diff --git a/src/std_process/mod.rs b/src/std_process/mod.rs index 862d18ae..300c7ceb 100644 --- a/src/std_process/mod.rs +++ b/src/std_process/mod.rs @@ -272,6 +272,11 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { let stdin = child.stdin.take(); let child_cell = Rc::new(RefCell::new(child)); + // Shared owner of the child's stdin pipe. We keep it open across writes (so stdin is writable + // multiple times), but drop it — closing the fd — as soon as the child is killed or observed to + // have exited, so we don't retain a dead pipe fd per child until the handle is GC'd (Luau has no + // __gc). `child.stdin:close()` and handle GC also drop it. + let stdin_cell: Rc>> = Rc::new(RefCell::new(stdin)); let child_process_handle = { let stdout_handle = if let Some(stdout) = stdout { @@ -302,9 +307,9 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { None }; - let stdin_handle = if let Some(stdin) = stdin { - let stdin_cell_write = Rc::new(RefCell::new(Some(stdin))); - let stdin_cell_close = Rc::clone(&stdin_cell_write); + let stdin_handle = if stdin_cell.borrow().is_some() { + let stdin_cell_write = Rc::clone(&stdin_cell); + let stdin_cell_close = Rc::clone(&stdin_cell); Some(TableBuilder::create(luau)? .with_function_mut("write", { @@ -322,19 +327,21 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { } }; - let mut stdin = match stdin_cell_write.try_borrow_mut() { - Ok(mut cell) => match cell.take() { - Some(stdin) => stdin, - None => { - return wrap_err!("{}: attempt to write to closed stdin", function_name); - } - }, + let mut cell = match stdin_cell_write.try_borrow_mut() { + Ok(cell) => cell, Err(_) => { unreachable!("{}: stdin already borrowed; this shouldn't happen as Luau VM is single threaded and multithreaded code should never touch this???", function_name); } }; - - match stdin.write_all(&data_to_write) { + // borrow stdin in place (don't take() it) so it stays open for subsequent writes + let stdin = match cell.as_mut() { + Some(stdin) => stdin, + None => { + return wrap_err!("{}: attempt to write to closed stdin", function_name); + } + }; + + match stdin.write_all(&data_to_write).and_then(|_| stdin.flush()) { Ok(_) => Ok(LuaNil), Err(err) => { std_err::WrappedError::from_message(format!("{} can't write to stdin due to err: {}", function_name, err)).get_userdata(luau) @@ -379,6 +386,7 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { .with_value("stdin", stdin_handle.unwrap_or(LuaNil))? .with_function_and_signature("alive", { let child_cell = Rc::clone(&child_cell); + let stdin_cell = Rc::clone(&stdin_cell); move |_luau: &Lua, _value: LuaValue| -> LuaValueResult { let function_name = "ChildProcess:alive()"; let Ok(mut child) = child_cell.try_borrow_mut() else { @@ -386,7 +394,11 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { }; match child.try_wait() { - Ok(Some(_status)) => Ok(LuaValue::Boolean(false)), + Ok(Some(_status)) => { + // child has exited; drop its stdin pipe (if still open) to free the fd + let _ = stdin_cell.borrow_mut().take(); + Ok(LuaValue::Boolean(false)) + }, Ok(None) => Ok(LuaValue::Boolean(true)), Err(err) => { wrap_err!("{}: (heisenseal's child) cannot determine whether child (pid {}) is dead or alive due to err: {}", function_name, child_id, err) @@ -398,10 +410,15 @@ fn process_spawn(luau: &Lua, spawn_options: LuaValue) -> LuaValueResult { .with_function_and_signature("kill", { let function_name = "ChildProcess:kill()"; let child_cell = Rc::clone(&child_cell); + let stdin_cell = Rc::clone(&stdin_cell); move |_luau: &Lua, _value: LuaValue| -> LuaEmptyResult { match child_cell.try_borrow_mut() { Ok(ref mut child) => match child.kill() { - Ok(_) => Ok(()), + Ok(_) => { + // dead child can't read stdin; drop the pipe to free the fd now + let _ = stdin_cell.borrow_mut().take(); + Ok(()) + }, Err(err) => { wrap_err!("{} could not murder child due to err: {}", function_name, err) } diff --git a/src/std_process/stream.rs b/src/std_process/stream.rs index fec95ed1..1ceb19c0 100644 --- a/src/std_process/stream.rs +++ b/src/std_process/stream.rs @@ -10,6 +10,45 @@ use std::collections::VecDeque; use mluau::prelude::*; +use crate::std_time::duration::TimeDuration; + +/// Parses a `timeout` argument that may be a number (in seconds) or a `Duration` (from `@std/time`) +/// into an optional std Duration. A nil/absent value means no timeout (`None`). +fn parse_timeout_value(value: Option, function_name: &'static str) -> LuaResult> { + let seconds = match value { + Some(LuaValue::Number(f)) => f, + Some(LuaValue::Integer(i)) => i as f64, + Some(LuaValue::UserData(ud)) if let Ok(duration) = ud.borrow::() => { + let inner = duration.inner; // SignedDuration is Copy + if inner.is_negative() { + return wrap_err!("{}: timeout can't be negative! got: {:#?}", function_name, inner); + } + return Ok(Some(inner.unsigned_abs())); + }, + Some(LuaValue::UserData(ud)) => { + let type_name = ud.type_name()?.unwrap_or("userdata (missing __type metafield)".to_string()); + return wrap_err!("{} expected timeout to be a number (in seconds), a Duration (from @std/time), or nil, got a different kind of userdata: {}", function_name, type_name); + }, + Some(LuaNil) | None => { + return Ok(None); + }, + Some(other) => { + return wrap_err!("{} expected timeout to be a number (in seconds), a Duration (from @std/time), or nil, got: {:?}", function_name, other); + } + }; + + if seconds.is_nan() || seconds.is_infinite() { + wrap_err!("{}: timeout can't be NaN nor infinite!", function_name) + } else if seconds < 0.0 { + wrap_err!("{}: timeout can't be negative! got: {:?}", function_name, seconds) + } else { + match Duration::try_from_secs_f64(seconds) { + Ok(duration) => Ok(Some(duration)), + Err(err) => wrap_err!("{}: error creating Duration from timeout: {}", function_name, err), + } + } +} + pub enum StreamType { Stdout, Stderr, @@ -155,30 +194,7 @@ impl Stream { } fn pop_timeout(&self, mut multivalue: LuaMultiValue, function_name: &'static str) -> LuaResult> { - let f = match multivalue.pop_front() { - Some(LuaValue::Number(f)) => f, - Some(LuaValue::Integer(i)) => i as f64, - Some(LuaNil) | None => { - return Ok(None); - }, - Some(other) => { - return wrap_err!("{} expected timeout to be a number or nil, got: {:?}", function_name, other); - } - }; - - if f.is_nan() || f.is_infinite() { - wrap_err!("{}: timeout can't be NaN nor infinite!", function_name) - } else if f < 0.0 { - wrap_err!("{}: timeout can't be negative! got: {:?}", function_name, f) - } else { - let duration = match Duration::try_from_secs_f64(f) { - Ok(duration) => duration, - Err(err) => { - return wrap_err!("{}: error creating Duration from timeout: {}", function_name, err); - } - }; - Ok(Some(duration)) - } + parse_timeout_value(multivalue.pop_front(), function_name) } pub fn read_exact(&mut self, luau: &Lua, mut multivalue: LuaMultiValue) -> LuaValueResult { @@ -329,14 +345,7 @@ impl Stream { } }; - let timeout = match multivalue.pop_front() { - Some(LuaValue::Integer(i)) => Some(Duration::from_secs_f64(i as f64)), - Some(LuaValue::Number(f)) => Some(Duration::from_secs_f64(f)), - Some(LuaNil) | None => None, - Some(other) => { - return wrap_err!("{} expected timeout to be a number (in seconds) or nil, got: {:?}", function_name, other); - } - }; + let timeout = parse_timeout_value(multivalue.pop_front(), function_name)?; let start_time = if timeout.is_some() { Some(Instant::now()) @@ -640,24 +649,8 @@ impl Stream { self.alive(function_name)?; pop_self(&mut multivalue, function_name)?; - let timeout = { - let timeout = match multivalue.pop_front() { - Some(LuaValue::Integer(i)) => Some(i as f64), - Some(LuaValue::Number(f)) => Some(f), - Some(LuaNil) | None => None, - Some(other) => { - return wrap_err!("{} expected timeout to be a number or nil, got: {:?}", function_name, other); - } - }; - - if let Some(timeout) = timeout && timeout.is_nan() { - return wrap_err!("{}: timeout is unexpectedly NaN 💀", function_name) - } else if let Some(timeout) = timeout && timeout.is_sign_negative() { - return wrap_err!("{}: timeout should be positive (got: {})", function_name, timeout); - } else { - timeout - } - }; + // as_secs_f64 so the iterator closure can compare against elapsed seconds below + let timeout = parse_timeout_value(multivalue.pop_front(), function_name)?.map(|d| d.as_secs_f64()); let timeout_start_time = if timeout.is_some() { Some(Instant::now()) @@ -716,24 +709,8 @@ impl Stream { self.alive(function_name)?; pop_self(&mut multivalue, function_name)?; - let timeout = { - let timeout = match multivalue.pop_front() { - Some(LuaValue::Integer(i)) => Some(i as f64), - Some(LuaValue::Number(f)) => Some(f), - Some(LuaNil) | None => None, - Some(other) => { - return wrap_err!("{} expected timeout to be a number or nil, got: {:?}", function_name, other); - } - }; - - if let Some(timeout) = timeout && timeout.is_nan() { - return wrap_err!("{}: timeout is unexpectedly NaN 💀", function_name) - } else if let Some(timeout) = timeout && timeout.is_sign_negative() { - return wrap_err!("{}: timeout should be positive (got: {})", function_name, timeout); - } else { - timeout - } - }; + // as_secs_f64 so the iterator closure can compare against elapsed seconds below + let timeout = parse_timeout_value(multivalue.pop_front(), function_name)?.map(|d| d.as_secs_f64()); let timeout_start_time = if timeout.is_some() { Some(Instant::now()) diff --git a/tests/luau/std/io/read.luau b/tests/luau/std/io/read.luau index 2d11de78..49554a23 100644 --- a/tests/luau/std/io/read.luau +++ b/tests/luau/std/io/read.luau @@ -4,24 +4,24 @@ local process = require("@std/process") local time = require("@std/time") local cheese = require("@tests/cheese") -local READTOEND_SRC = [[ -local input = require("@std/io/input") -local result = input.read() -if result == nil then - print("NIL") -else - print(result) -end -]] +local CACHE_DIR = fs.path.join(fs.path.home(), ".cache") +fs.dir.ensure(CACHE_DIR) -fs.dir.ensure(fs.path.join(fs.path.home(), ".cache")) -local CHILD_SCRIPT_PATH = fs.path.join(fs.path.home(), ".cache", "seal_test_input_read_child.luau") -fs.writefile(CHILD_SCRIPT_PATH, READTOEND_SRC) +local written_scripts: { string } = {} -local function spawn_child(): process.PipedChild +-- writes `src` to a uniquely named child script and returns its path +local function make_child(name: string, src: string): string + local path = fs.path.join(CACHE_DIR, `seal_test_input_read_{name}.luau`) + fs.writefile(path, src) + table.insert(written_scripts, path) + return path +end + +local function spawn(path: string, add: { [string]: string }?): process.PipedChild return process.spawn { program = env.executable_path, - args = { CHILD_SCRIPT_PATH }, + args = { path }, + env = if add then { add = add } else nil, } :: process.PipedChild end @@ -31,8 +31,20 @@ local function wait_for(child: process.PipedChild) end end +-- ── input.read() to EOF ─────────────────────────────────────────────────────── + +local READTOEND = make_child("readtoend", [[ +local input = require("@std/io/input") +local result, more = input.read() +if result == nil then + print(`NIL more={tostring(more)}`) +else + print(`{result} more={tostring(more)}`) +end +]]) + local function basic() - local child = spawn_child() + local child = spawn(READTOEND) child.stdin:write("hello\nworld\n") child.stdin:close() wait_for(child) @@ -41,10 +53,12 @@ local function basic() assert(output ~= nil, "input.read: expected output, got nil") assert(output:match("hello"), `input.read: expected "hello" in output, got: {output}`) assert(output:match("world"), `input.read: expected "world" in output, got: {output}`) + -- reading to EOF means there's nothing more to read + assert(output:match("more=false"), `input.read: expected more=false after EOF, got: {output}`) end local function empty_stdin() - local child = spawn_child() + local child = spawn(READTOEND) child.stdin:close() wait_for(child) @@ -52,11 +66,12 @@ local function empty_stdin() assert(stderr == nil or stderr == "", `input.read: empty stdin should not error, got stderr: {tostring(stderr)}`) local output = child.stdout:read() assert(output ~= nil and output:match("NIL"), `input.read: expected nil return for empty stdin, got: {tostring(output)}`) + assert(output ~= nil and output:match("more=false"), `input.read: expected more=false for empty stdin, got: {tostring(output)}`) end local function preserves_content() local expected = "line1\nline2\nline3\n" - local child = spawn_child() + local child = spawn(READTOEND) child.stdin:write(expected) child.stdin:close() wait_for(child) @@ -67,8 +82,126 @@ local function preserves_content() `input.read: all lines should appear in output, got: {tostring(output)}`) end +-- ── input.read { bytes = ... } ──────────────────────────────────────────────── + +-- reports the number of bytes read, whether there's more, and the exact data. +local READBYTES = make_child("readbytes", [[ +local input = require("@std/io/input") +local filesize = require("@std/fs/filesize") +local vars = require("@std/env/vars") + +local limit = tonumber(vars.get("SEAL_TEST_READ_LIMIT") or "5") :: number +local bytes: number | FileSize = if vars.get("SEAL_TEST_READ_FILESIZE") == "1" + then filesize.bytes(limit) + else limit + +local data, more = input.read({ bytes = bytes }) +print(`LEN={#(data or "")} MORE={tostring(more)} DATA={data or ""}`) +]]) + +local function reads_up_to_byte_limit() + local child = spawn(READBYTES, { SEAL_TEST_READ_LIMIT = "5" }) + child.stdin:write("hello world, there's plenty more here\n") + child.stdin:close() + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(bytes): expected output, got nil") + assert(output:match("LEN=5"), `input.read(bytes): expected exactly 5 bytes read, got: {output}`) + assert(output:match("MORE=true"), `input.read(bytes): expected more=true when limit reached, got: {output}`) + assert(output:match("DATA=hello"), `input.read(bytes): expected "hello", got: {output}`) +end + +local function byte_limit_accepts_filesize() + local child = spawn(READBYTES, { SEAL_TEST_READ_LIMIT = "3", SEAL_TEST_READ_FILESIZE = "1" }) + child.stdin:write("abcdefg\n") + child.stdin:close() + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(FileSize): expected output, got nil") + assert(output:match("LEN=3"), `input.read(FileSize): expected exactly 3 bytes read, got: {output}`) + assert(output:match("MORE=true"), `input.read(FileSize): expected more=true when limit reached, got: {output}`) + assert(output:match("DATA=abc"), `input.read(FileSize): expected "abc", got: {output}`) +end + +local function byte_limit_eof_before_limit() + -- limit larger than the input: we should hit EOF first and report more=false + local child = spawn(READBYTES, { SEAL_TEST_READ_LIMIT = "100" }) + child.stdin:write("hi") + child.stdin:close() + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(bytes eof): expected output, got nil") + assert(output:match("LEN=2"), `input.read(bytes eof): expected 2 bytes read, got: {output}`) + assert(output:match("MORE=false"), `input.read(bytes eof): expected more=false when EOF reached before limit, got: {output}`) +end + +-- ── input.read { timeout = ... } ────────────────────────────────────────────── + +-- reads with a short timeout while stdin stays open; reports what came through. +local READTIMEOUT = make_child("readtimeout", [[ +local input = require("@std/io/input") +local time = require("@std/time") +local data, more = input.read({ timeout = time.milliseconds(200) }) +print(`LEN={#(data or "")} MORE={tostring(more)} DATA={data or ""}`) +]]) + +local function timeout_returns_when_no_input() + -- stdin left open with nothing written; read should give up after the timeout + local child = spawn(READTIMEOUT) + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(timeout): expected output, got nil") + assert(output:match("LEN=0"), `input.read(timeout): expected 0 bytes on timeout, got: {output}`) + assert(output:match("MORE=true"), `input.read(timeout): expected more=true on timeout, got: {output}`) + pcall(function() + child.stdin:close() + return nil + end) +end + +local function timeout_returns_partial_data() + -- write some data but keep stdin open; read should return that data then time out + local child = spawn(READTIMEOUT) + child.stdin:write("partial") + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(timeout partial): expected output, got nil") + assert(output:match("DATA=partial"), `input.read(timeout partial): expected "partial", got: {output}`) + assert(output:match("MORE=true"), `input.read(timeout partial): expected more=true, got: {output}`) + pcall(function() + child.stdin:close() + return nil + end) +end + +local function timeout_reads_to_eof() + -- if the stream closes before the timeout, we should report more=false + local child = spawn(READTIMEOUT) + child.stdin:write("done") + child.stdin:close() + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "input.read(timeout eof): expected output, got nil") + assert(output:match("DATA=done"), `input.read(timeout eof): expected "done", got: {output}`) + assert(output:match("MORE=false"), `input.read(timeout eof): expected more=false when stream closes, got: {output}`) +end + cheese.retry(basic, 2) cheese.retry(empty_stdin, 2) cheese.retry(preserves_content, 2) +cheese.retry(reads_up_to_byte_limit, 2) +cheese.retry(byte_limit_accepts_filesize, 2) +cheese.retry(byte_limit_eof_before_limit, 2) +cheese.retry(timeout_returns_when_no_input, 3) +cheese.retry(timeout_returns_partial_data, 3) +cheese.retry(timeout_reads_to_eof, 3) -fs.file.try_remove(CHILD_SCRIPT_PATH) +for _, path in written_scripts do + fs.file.try_remove(path) +end diff --git a/tests/luau/std/process/spawn/read_to.luau b/tests/luau/std/process/spawn/read_to.luau index 21aead18..384b3a0b 100644 --- a/tests/luau/std/process/spawn/read_to.luau +++ b/tests/luau/std/process/spawn/read_to.luau @@ -2,9 +2,17 @@ local process = require("@std/process") local env = require("@std/env") local path = require("@std/fs/path") local str = require("@std/str") +local time = require("@std/time") local cheese = require("@tests/cheese") +-- Timing assertions need slack: debug builds spawn and initialize child processes noticeably +-- slower, and the measured elapsed includes that variable startup overhead. Assert on a tolerance +-- window rather than an exact string prefix (which used to flake in debug mode). +local function assert_elapsed(elapsed: number, lo: number, hi: number, what: string) + assert(elapsed >= lo and elapsed <= hi, `{what}: expected elapsed within [{lo}, {hi}]s, got {elapsed}s`) +end + local function spawn_longrunning(): process.PipedChild local child = process.spawn { program = env.executable_path, @@ -42,18 +50,31 @@ local function interruptreadtowithtimeout() args = { path.join(script:parent(), "long_waiter.luau") } } :: process.PipedChild local start_time = os.clock() - -- long_waiter prints "meow" but only after 0.75 seconds, so if we read with a timeout of 0.15 + -- long_waiter prints "meow" but only after ~2 seconds, so if we read with a timeout of 0.15 -- we shouldn't expect to see "meow", we should see `nil` local meowres = child.stdout:read_to("meow", true, 0.15) assert(meowres == nil, "meowres should be nil") - assert( - str.startswith(tostring(os.clock() - start_time), "0.15"), - "read_to with 0.15s timeout should be very close to 0.15s" - ) + -- should give up near the 0.15s timeout, nowhere near the 2s it takes "meow" to arrive + assert_elapsed(os.clock() - start_time, 0.1, 0.75, "read_to with 0.15s timeout") end cheese.retry(interruptreadtowithtimeout, 3) +local function timeout_accepts_duration() + local child = process.spawn { + program = env.executable_path, + args = { path.join(script:parent(), "long_waiter.luau") } + } :: process.PipedChild + local start_time = os.clock() + -- timeout may be a Duration (from @std/time) in addition to a plain number of seconds + local meowres = child.stdout:read_to("meow", true, time.milliseconds(150)) + assert(meowres == nil, "meowres should be nil (meow arrives ~2s later)") + assert_elapsed(os.clock() - start_time, 0.1, 0.75, "read_to with a 150ms Duration timeout") + child:kill() +end + +cheese.retry(timeout_accepts_duration, 3) + local function interruptedreadallowpartial() local child = process.spawn { program = env.executable_path, @@ -61,26 +82,19 @@ local function interruptedreadallowpartial() } :: process.PipedChild local start_time = os.clock() - -- long_waiter prints "meow" after 0.75 seconds, so if we interrupt with a 0.15 timeout + -- long_waiter prints "meow" after ~2 seconds, so if we interrupt with a 0.15 timeout -- and allow partial reads, we should get hehe\n instead local heheres = child.stdout:read_to("meow", true, 0.15, true) assert(heheres == "hehe\n", "heheres should be hehe\\n") - assert( - str.startswith(tostring(os.clock() - start_time), "0.15"), - "read_to with 0.15s timeout should be very close to 0.15s" - ) + -- partial read should return at the 0.15s timeout, not wait out the full 2s for "meow" + assert_elapsed(os.clock() - start_time, 0.1, 0.75, "read_to partial with 0.15s timeout") -- now that's been read, we should be able to easily get meowres local start_2_time = os.clock() local meowres = child.stdout:read_to("meow", true) assert(typeof(meowres) == "string" and (meowres:match("meow")), "meowres should be meow now and we shouldn't have the trailing \\n included") - if env.os == "Linux" then - assert( - str.startswith(tostring(os.clock() - start_2_time), "1.8"), - "read_to should now find \"meow\" in around 1.8something seconds since the first 0.15 seconds were already used" - ) - else - -- can't really make any guarantees here :/ - end + -- "meow" arrives ~2s after the child starts; ~0.15s was already consumed above, so the resumed + -- read should land in the neighborhood of 1.85s (with generous slack for child startup jitter). + assert_elapsed(os.clock() - start_2_time, 1.4, 2.4, "read_to resuming to find \"meow\"") end cheese.retry(interruptedreadallowpartial, 3) \ No newline at end of file diff --git a/tests/luau/std/process/spawn/stdin_write.luau b/tests/luau/std/process/spawn/stdin_write.luau new file mode 100644 index 00000000..e9e25b91 --- /dev/null +++ b/tests/luau/std/process/spawn/stdin_write.luau @@ -0,0 +1,89 @@ +local fs = require("@std/fs") +local env = require("@std/env") +local process = require("@std/process") +local time = require("@std/time") +local cheese = require("@tests/cheese") + +local CACHE_DIR = fs.path.join(fs.path.home(), ".cache") +fs.dir.ensure(CACHE_DIR) + +local written_scripts: { string } = {} +local function make_child(name: string, src: string): string + local path = fs.path.join(CACHE_DIR, `seal_test_stdin_write_{name}.luau`) + fs.writefile(path, src) + table.insert(written_scripts, path) + return path +end + +local function spawn(path: string): process.PipedChild + return process.spawn { + program = env.executable_path, + args = { path }, + } :: process.PipedChild +end + +local function wait_for(child: process.PipedChild) + while child:alive() do + time.wait(0.05) + end +end + +-- reads two lines from stdin and echoes them back joined, proving both writes arrived +local ECHO_TWO = make_child("echo_two", [[ +local input = require("@std/io/input") +local a = input.rawline() +local b = input.rawline() +print(`GOT:{a}|{b}`) +]]) + +-- stays alive long enough for us to kill it +local SLEEPER = make_child("sleeper", [[ +require("@std/time").wait(30) +]]) + +-- stdin should be writable more than once (regression: write used to close the pipe after one call) +local function multiple_writes_are_delivered() + local child = spawn(ECHO_TWO) + child.stdin:write("hello\n") + child.stdin:write("world\n") + child.stdin:close() + wait_for(child) + + local output = child.stdout:read() + assert(output ~= nil, "stdin multi-write: expected output, got nil") + assert(output:match("GOT:hello|world"), `stdin multi-write: expected both lines, got: {output}`) +end + +-- killing a child should release its stdin fd promptly (Luau has no __gc), so writing after errors +local function kill_releases_stdin() + local child = spawn(SLEEPER) + child.stdin:write("still open\n") + child:kill() + local ok = pcall(function() + child.stdin:write("after kill\n") + return nil + end) + assert(not ok, "stdin kill release: writing after kill should error (fd released)") +end + +-- a child that exits on its own should release stdin once alive() observes the exit, even if we +-- never call close() ourselves (ECHO_TWO exits after two lines, so no EOF is needed) +local function exit_releases_stdin() + local child = spawn(ECHO_TWO) + child.stdin:write("a\n") + child.stdin:write("b\n") + wait_for(child) -- alive() returns false here, which drops stdin + local ok = pcall(function() + child.stdin:write("c\n") + return nil + end) + assert(not ok, "stdin exit release: writing after exit should error (fd released)") +end + +cheese.retry(multiple_writes_are_delivered, 2) +cheese.retry(kill_releases_stdin, 2) +cheese.retry(exit_releases_stdin, 2) + +for _, path in written_scripts do + fs.file.try_remove(path) +end