nupp.io.process

Starts a child process and drains its streams without deadlocking.

local process = nupp.io.process

local child = new process.Process({args = {"cc", "--version"}})
local result = assert(child:communicate())
print(result.output)
child:close()

A child and its streams are owners. See ownership.md for the contract they are handed out under, and NEP 5: Suspension for why waiting is a suspension rather than a block.

Module contents

Types

TypeKindDescription
CommunicateOptionstypeControls a complete duplex exchange.
ErrorModetypeWhere standard error goes, which adds the option to join standard output.
ExitrecordHow a child ended.
OptionstypeHow a child was asked to be started.
ProcessrecordA running child.
ReaderrecordOne of a child's readable streams.
ResultrecordA completed duplex exchange.
StreamModetypeWhere a stream goes: a pipe this process reads or writes, the parent's own stream, or nothing at all.
WriterrecordA child's writable stream.

Functions

FunctionKindDescription
asReaderfunctionBorrows a process reader through the shared completion-oriented contract.
asWriterfunctionBorrows a process writer through the shared completion-oriented contract.

Types#

CommunicateOptionstype#

type CommunicateOptions = {
    --- Complete standard input.
    ---
    --- Omitted input sends EOF immediately.
    input: (string | Buffer | ByteView)?,

    --- Maximum stdout and stderr bytes together.
    ---
    --- Defaults to 256 MiB.
    maxOutputBytes: integer?
}

Controls a complete duplex exchange.

ErrorModetype#

type ErrorMode = "pipe" | "inherit" | "null" | "stdout"

Where standard error goes, which adds the option to join standard output.

Exitrecord#

record Exit
    exitCode: integer
    killed: boolean
    timedOut: boolean
    succeeded: function(Exit): boolean
end

How a child ended.

Methods

succeeded#
succeeded: function(process.Exit): boolean

Exited on its own with status zero. A killed child never succeeded, whatever status the platform reported for it.

Arguments
NameTypeDescription
?process.Exit
Returns
TypeDescription
boolean

Fields

exitCode#
exitCode: integer

The status it exited with. Zero conventionally means success, and succeeded is the question worth asking instead.

killed#
killed: boolean

Whether it was terminated rather than exiting on its own.

timedOut#
timedOut: boolean

Whether it was terminated because its deadline passed.

Optionstype#

type Options = {
    --- The program in `args[1]`, then its arguments. A program with no separator in
    --- it is resolved through `PATH` by the platform, not by this module.
    args: {string},

    --- The child's working directory, or nil to inherit this one.
    cwd: (string | Path)?,

    --- Variables overlaid on the inherited environment, or the whole environment
    --- when `clearEnv` is set.
    env: {[string]: string}?,

    --- Whether to start from an empty environment rather than this process's.
    clearEnv: boolean?,

    --- Where the child's standard input comes from, `"pipe"` by default.
    stdin: process.StreamMode?,

    --- Where the child's standard output goes, `"pipe"` by default.
    stdout: process.StreamMode?,

    --- Where the child's standard error goes, `"pipe"` by default.
    stderr: process.ErrorMode?,

    --- Kill the child after this many milliseconds. The clock starts when it is
    --- created, not when it is first waited on.
    timeoutMs: integer?
}

How a child was asked to be started.

Processrecord#

record process.Process
    drop: nosuspend function(takes self: process.Process): nil
    pid: integer
    stdin: process.Writer?
    stdout: process.Reader?
    stderr: process.Reader?
    constructor(self, options: process.Options): affine(process.Process, process.Process.destroy) end

    function destroy(takes self): nil end

    function isRunning(self): boolean end

    function wait(self): process.Exit end

    function kill(self, force: boolean?): (boolean, string?) end

    function communicate(self, options: process.CommunicateOptions?): (process.Result?, string?) end

    function close(self): (boolean, string?) end
end

A running child.

Methods

drop#
drop: nosuspend function(takes self: process.Process): nil
Arguments
NameTypeDescription
takes selfprocess.Process
Returns
TypeDescription
nil
constructor#
constructor: function constructor(self, options: process.Options): affine(process.Process, process.Process.destroy)

Starts a child process.

The child is an owner: closing it ends the process, waits for the exit, and releases every stream, and the same runs when its scope ends. Reading a stream or waiting for the exit suspends, so a caller under a scheduler keeps its frame.

local child = new process.Process({
    args = {"grep", "-c", "nupp"},
    stderr = "stdout",
})
local result = assert(child:communicate({input = text}))
child:close()
Arguments
NameTypeDescription
selfany

the process being initialized

optionsprocess.Options

what to run and how to connect it

Returns
TypeDescription
affine(process.Process, process.Process.destroy)
Raises
  • when the options are invalid or the child cannot be started

destroy#
destroy: function destroy(takes self): nil

Releases the process when its owner leaves scope.

Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil
isRunning#
isRunning: function isRunning(self): boolean

Whether it is still running.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
wait#
wait: function wait(self): process.Exit

Waits for it to end and answers how.

Suspends while it runs.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
process.Exit
kill#
kill: function kill(self, force: boolean?): boolean, string?

Asks the direct child to end; force insists. Descendants keep their ordinary operating-system lifetime. Answers whether the request was made.

Arguments
NameTypeDescription
selfany
forceboolean?
Returns
TypeDescription
boolean
string?
communicate#
communicate: function communicate(self, options: process.CommunicateOptions?): process.Result?, string?

Writes input, closes stdin, drains stdout and stderr, and waits for the exit. Answers the complete exchange or a reason it could not be completed.

One combined step per pass: offer some input, take whatever each output has, suspend only when none of the three moved.

Arguments
NameTypeDescription
selfany
optionsprocess.CommunicateOptions?
Returns
TypeDescription
process.Result?
string?
close#
close: function close(self): boolean, string?

Closes every stream, ends the child if it is still running, waits for it to actually finish, and releases it. Idempotent, and what drop runs when the owner goes out of lexical scope.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?
Raises
  • when closing one of the child streams fails

Fields

pid#
pid: integer

Operating-system process identifier.

stdin#
stdin: process.Writer?

Its standard input, when it was piped.

stdout#
stdout: process.Reader?

Its standard output, when it was piped.

stderr#
stderr: process.Reader?

Its standard error, when it was piped.

Readerrecord#

record process.Reader is Reader2
    timeoutMs: integer
    function isEOF(self): boolean end

    function isClosed(self): boolean end

    function poll(self, limit: integer?): string? end

    function next(self): string? end

    function readCompletion(self, limit: integer): (string?, string?) end

    function setTimeout(self, timeoutMs: integer): nil end

    function read(self, count: integer): (string?, string?) end

    function readSpan(self, exclusive destination: span.Writable<uint8>): (integer?, string?) end

    function readInto(self, exclusive destination: Buffer, offset: integer?, count: integer?): (integer?, string?) end

    function transferTo(self, exclusive destination: Writer2): (integer?, string?) end

    function close(takes self): nil end
end

One of a child's readable streams.

Its blocking read and its nonblocking poll are both here on purpose. A caller draining several streams at once cannot use the blocking form on any of them, because waiting on one is exactly what starves the others, which is the whole reason communicate can drain three pipes without deadlocking.

The completion-oriented nupp.io.Reader methods are the public tecs-compatible surface. poll remains alongside them as the concrete nonblocking operation the combined drain needs; generic readers are not widened with readiness operations.

The tolerance that does exist is narrow and worth naming exactly: closing the same live opaque handle twice is harmless, because the second call finds the stream already released and says so. Destroying a handle while a borrowed reference still exists leaves that reference reading freed memory, which no amount of care at the call site can make safe.

Borrowing the record through asReader keeps a single owner and makes its lifetime a question about Nupp values, which the checker can answer.

Methods

isEOF#
isEOF: function isEOF(self): boolean

Whether the far end has finished.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
isClosed#
isClosed: function isClosed(self): boolean

Whether this end has been closed.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
poll#
poll: function poll(self, limit: integer?): string?

Takes whatever is available without waiting: the bytes, "" when nothing is ready yet, or nil at end of stream.

The non-blocking half of reading, which communicate needs so that one quiet stream does not stop it serving another. limit caps how many bytes to take, defaulting to a whole pipe's worth. It is here because the shared nupp.io.Reader promises "at most count". Without the limit its completion-oriented method would have to keep surplus bytes beside this record, giving end of stream and closedness two homes. One place decides both, and the caller says how much it wants.

Zero and negative polling limits read one byte. This concrete nonblocking operation keeps that defensive behavior even though the shared Reader contract requires a positive count. Settled here rather than at the platform because the platform is where it stops being a number and becomes a buffer size: a signed zero or minus one arriving at a native size conversion is either an empty read that looks like end of stream or an enormous one, and neither is worth being able to ask for. read passes its caller's count straight through.

Arguments
NameTypeDescription
selfany
limitinteger?
Returns
TypeDescription
string?
next#
next: function next(self): string?

Reads the next available bytes, suspending until there are some. Answers nil at end of stream.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
string?
readCompletion#
readCompletion: function readCompletion(self, limit: integer): string?, string?

The shared Reader operation uses the concrete bounded poll but still waits through the same state machine as every other completion-oriented call. Keeping the limit here avoids a surplus buffer and its second notion of EOF.

Arguments
NameTypeDescription
selfany
limitinteger
Returns
TypeDescription
string?
string?
setTimeout#
setTimeout: function setTimeout(self, timeoutMs: integer): nil

Bounds the next completion-oriented read.

Arguments
NameTypeDescription
selfany
timeoutMsinteger
Returns
TypeDescription
nil
Raises
  • when timeoutMs is outside 0 through 2147483647

read#
read: function read(self, count: integer): string?, string?
Arguments
NameTypeDescription
selfany
countinteger
Returns
TypeDescription
string?
string?
readSpan#
readSpan: function readSpan(self, exclusive destination: span.Writable<uint8>): integer?, string?
Arguments
NameTypeDescription
selfany
exclusive destinationspan.Writable<uint8>
Returns
TypeDescription
integer?
string?
readInto#
readInto: function readInto(self, exclusive destination: Buffer, offset: integer?, count: integer?): integer?, string?
Arguments
NameTypeDescription
selfany
exclusive destinationBuffer
offsetinteger?
countinteger?
Returns
TypeDescription
integer?
string?
transferTo#
transferTo: function transferTo(self, exclusive destination: Writer2): integer?, string?
Arguments
NameTypeDescription
selfany
exclusive destinationWriter2
Returns
TypeDescription
integer?
string?
close#
close: function close(takes self): nil
Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil

Fields

timeoutMs#
timeoutMs: integer

Maximum time one completion-oriented read may wait.

Resultrecord#

record Result
    exit: process.Exit
    output: string
    errorOutput: string
    function succeeded(self): boolean end
end

A completed duplex exchange.

Methods

succeeded#
succeeded: function succeeded(self): boolean

Whether the child exited normally with status zero.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

Fields

exit#
exit: process.Exit

How the child ended.

output#
output: string

Captured standard output.

errorOutput#
errorOutput: string

Captured standard error.

StreamModetype#

type StreamMode = "pipe" | "inherit" | "null"

Where a stream goes: a pipe this process reads or writes, the parent's own stream, or nothing at all.

Writerrecord#

record process.Writer is Writer2
    timeoutMs: integer
    function isGone(self): boolean end

    function isClosed(self): boolean end

    function offer(self, data: string): integer end

    function send(self, data: string, stopAt: number?, stallFor: integer?): integer end

    function setTimeout(self, timeoutMs: integer): nil end

    function write(exclusive self, bytes: string): (boolean, string?) end

    function writeSpan(exclusive self, borrows source: span.ByteSpan): (integer?, string?) end

    function flush(self): (boolean, string?) end

    function close(takes self): nil end
end

A child's writable stream.

offer and isGone are the nonblocking half, for the same reason the reader has poll: the prelude's nupp.io.Writer.write writes the whole value, which a drain loop serving three pipes cannot afford to wait for. The completion-oriented write remains the ordinary nupp.io.Writer operation; offer is concrete and additional.

Methods

isGone#
isGone: function isGone(self): boolean

Whether the far end has gone: the child is no longer reading this, and no amount of waiting will change that.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
isClosed#
isClosed: function isClosed(self): boolean

Whether this end has been closed.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
offer#
offer: function offer(self, data: string): integer

Writes what the pipe will take without waiting. Answers how many bytes went, which may be none and may be fewer than offered.

Arguments
NameTypeDescription
selfany
datastring
Returns
TypeDescription
integer
send#
send: function send(self, data: string, stopAt: number?, stallFor: integer?): integer

Writes every byte, suspending as often as the pipe makes it. Answers how many went, which is all of them unless the far end went away first.

Arguments
NameTypeDescription
selfany
datastring
stopAtnumber?
stallForinteger?
Returns
TypeDescription
integer
setTimeout#
setTimeout: function setTimeout(self, timeoutMs: integer): nil

Bounds the next completion-oriented write.

Arguments
NameTypeDescription
selfany
timeoutMsinteger
Returns
TypeDescription
nil
Raises
  • when timeoutMs is outside 0 through 2147483647

write#
write: function write(exclusive self, bytes: string): boolean, string?
Arguments
NameTypeDescription
exclusive selfany
bytesstring
Returns
TypeDescription
boolean
string?
writeSpan#
writeSpan: function writeSpan(exclusive self, borrows source: span.ByteSpan): integer?, string?
Arguments
NameTypeDescription
exclusive selfany
borrows sourcespan.ByteSpan
Returns
TypeDescription
integer?
string?
flush#
flush: function flush(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?
close#
close: function close(takes self): nil
Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil

Fields

timeoutMs#
timeoutMs: integer

Maximum time one completion-oriented write may wait.

Functions#

process.asReaderfunction#

function process.asReader(borrows source: process.Reader): Reader2

Borrows a process reader through the shared completion-oriented contract.

Arguments

NameTypeDescription
borrows sourceprocess.Reader

Returns

TypeDescription
Reader2

process.asWriterfunction#

function process.asWriter(borrows source: process.Writer): Writer2

Borrows a process writer through the shared completion-oriented contract.

Arguments

NameTypeDescription
borrows sourceprocess.Writer

Returns

TypeDescription
Writer2