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