nupp.io.process

Starts a child process and drains its streams without deadlocking.

local process = nupp.io.process

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

spawn returns nil and a reason when the child cannot be started, the way every other acquisition of an operating-system resource does, and raises only for options that could never describe a child.

A child and its streams are owners. See ownership.md for the contract they are handed out under.

Submodules

ModuleDescription
nupp.io.process.spiImplementation interface for process transports.

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, and the obligation to end it.
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
spawnfunctionStarts a child process.

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 ...

How a child ended.

Members

NameKindDescription
exitCodefieldThe platform status code.
killedfieldWhether it was terminated rather than exiting on its own.
timedOutfieldWhether it was terminated because its deadline passed.
succeededmethodExited on its own with status zero.

exitCodefield#

exitCode: integer

The platform status code. A Unix signal is reported as 128 plus its number. Zero conventionally means success; use succeeded to account for termination.

killedfield#

killed: boolean

Whether it was terminated rather than exiting on its own.

timedOutfield#

timedOut: boolean

Whether it was terminated because its deadline passed.

succeededmethod#

succeeded: function(Exit): boolean

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

Arguments
NameTypeDescription
?Exit
Returns
TypeDescription
boolean

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, as anything [](nupp.io.files) accepts, or nil
    --- to inherit this one.
    cwd: Path?,

    --- Variables overlaid on the inherited environment, or the whole environment
    --- when `clearEnv` is set. A name must be non-empty and contain no `=`.
    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 is nupp.Closeable ...

A running child, and the obligation to end it.

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.

Members

NameKindDescription
statefield
pidfieldOperating-system process identifier.
stdinfieldIts standard input, when it was piped.
stdoutfieldIts standard output, when it was piped.
stderrfieldIts standard error, when it was piped.
isRunningmethodWhether it is still running, enforcing an elapsed process deadline first.
waitmethodWaits for it to end and returns how.
killmethodAsks the direct child to end; force insists.
communicatemethodWrites input, closes stdin, drains stdout and stderr, and waits for the exit.
teardownmethodCloses every stream, ends the child if it is still running, waits for it to actually finish, and releases it.
closemethodCloses every stream, ends the child if it is still running, waits for it to actually finish, and releases it.

statefield#

@private

pidfield#

pid: integer

Operating-system process identifier.

stdinfield#

stdin: process.Writer?

Its standard input, when it was piped.

stdoutfield#

stdout: process.Reader?

Its standard output, when it was piped.

stderrfield#

stderr: process.Reader?

Its standard error, when it was piped.

isRunningmethod#

isRunning: function isRunning(self): boolean

Whether it is still running, enforcing an elapsed process deadline first.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

waitmethod#

wait: function wait(self): process.Exit

Waits for it to end and returns how.

Suspends while it runs.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
process.Exit

killmethod#

kill: function kill(self, force: boolean?): boolean, io.Error?

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

Arguments
NameTypeDescription
selfany
forceboolean?
Returns
TypeDescription
boolean
io.Error?

communicatemethod#

communicate: function communicate(self, options: process.CommunicateOptions?): process.Result?, io.Error?

Writes input, closes stdin, drains stdout and stderr, and waits for the exit. Returns 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?
io.Error?

teardownmethod#

teardown: function(self: process.Process): nil
@private

Closes every stream, ends the child if it is still running, waits for it to actually finish, and releases it. Idempotent, and retryable: a teardown that could not finish leaves each piece's own flag, and the next call skips whatever is already given back.

Arguments
NameTypeDescription
selfprocess.Process
Returns
TypeDescription
nil
Raises
TypeCondition
string

when closing a stream or releasing the child fails

closemethod#

close: function close(takes self): nil

Closes every stream, ends the child if it is still running, waits for it to actually finish, and releases it. What runs when the owner leaves its scope.

Every step is attempted, and the first failure is raised once all of them have been: a close that stopped at the first would leave the rest held.

Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil
Raises
TypeCondition
string

when closing a stream or releasing the child fails

Readerrecord#

record process.Reader is Reader2 ...

One of a child's readable streams.

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

read is the nupp.io.Reader operation and waits as long as the stream stays open: the child's deadline in Options.timeoutMs, and the enclosing task scope's deadline, are what bound it. poll is 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.

Members

NameKindDescription
ownerfield
handlefield
closedfield
eoffield
takeNowmethodTakes what a stream has without waiting, as poll returns it: the bytes, or nil and whether the stream has ended.
isEndedmethodWhether the far end has finished and every byte it sent has been taken, or this end has been closed.
isReleasedmethodWhether this end has been closed.
pollmethodTakes whatever is available: the bytes, or nil when there are none, with whether the stream has ended as the second...
readmethodReads up to count bytes, suspending until some arrive.
readSpanmethod
readIntomethod
transferTomethod
asReadermethodBorrows this stream through the shared completion-oriented contract.
closemethodCloses this end.

ownerfield#

owner: any
@private

handlefield#

handle: any
@private

closedfield#

closed: boolean
@private

eoffield#

eof: boolean
@private

takeNowmethod#

takeNow: function(self: process.Reader, wanted: integer): (string?, boolean)
@private

Takes what a stream has without waiting, as poll returns it: the bytes, or nil and whether the stream has ended. Shared by every read, so end of stream has one place that decides it.

Arguments
NameTypeDescription
selfprocess.Reader
wantedinteger
Returns
TypeDescription
string?
boolean
Raises
TypeCondition
string

when the provider returns an invalid result

isEndedmethod#

isEnded: function isEnded(self): boolean

Whether the far end has finished and every byte it sent has been taken, or this end has been closed. A read returns empty exactly when this is true.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

isReleasedmethod#

isReleased: function isReleased(self): boolean

Whether this end has been closed.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

pollmethod#

poll: function poll(self, limit: integer?, timeoutMs: integer?): string?, boolean

Takes whatever is available: the bytes, or nil when there are none, with whether the stream has ended as the second result.

Nil with false is "nothing yet" and nil with true is the end, so the two are never the same answer. Without timeoutMs this does not wait, which is the half of reading communicate needs so that one quiet stream does not stop it serving another. With it, this waits up to that many milliseconds for bytes or the end, for a caller that has other work to come back to.

limit caps how many bytes to take, defaulting to a whole pipe's worth. Zero and negative limits read one byte: the platform is where a count stops being a number and becomes a buffer size, and 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. read passes its caller's count straight through.

Arguments
NameTypeDescription
selfany

this stream

limitinteger?

the most bytes to take

timeoutMsinteger?

how long to wait for bytes or the end, or nothing to not wait

Returns
TypeDescription
string?

the bytes, or nil when none are available

boolean

whether the stream has ended

Raises
TypeCondition
string

when the provider returns an invalid result, or timeoutMs is outside 0 through 2147483647

readmethod#

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

Reads up to count bytes, suspending until some arrive. Returns empty at the end of the stream and nil with a reason once this end is closed.

Arguments
NameTypeDescription
exclusive selfany
countinteger
Returns
TypeDescription
string?
string?
Raises
TypeCondition
string

when count is not a positive integer

readSpanmethod#

readSpan: function readSpan(exclusive self, exclusive destination: span.Writable<uint8>): integer?, string?
Arguments
NameTypeDescription
exclusive selfany
exclusive destinationspan.Writable<uint8>
Returns
TypeDescription
integer?
string?
Raises
TypeCondition
string

when the destination is empty

readIntomethod#

readInto: function readInto(exclusive self, exclusive destination: Buffer, offset: integer?, count: integer?): integer?, string?
Arguments
NameTypeDescription
exclusive selfany
exclusive destinationBuffer
offsetinteger?
countinteger?
Returns
TypeDescription
integer?
string?
Raises
TypeCondition
string

when offset is negative or count is not a positive integer

transferTomethod#

transferTo: function transferTo(exclusive self, exclusive destination: Writer2): integer?, string?
Arguments
NameTypeDescription
exclusive selfany
exclusive destinationWriter2
Returns
TypeDescription
integer?
string?

asReadermethod#

asReader: function asReader(borrows self): Reader2

Borrows this stream through the shared completion-oriented contract.

Arguments
NameTypeDescription
borrows selfany
Returns
TypeDescription
Reader2

closemethod#

close: function close(takes self): nil

Closes this end.

Idempotent. Closed-ness follows ownership rather than success. A backend that gave the stream up and then complained has still given it up, so this end is shut and the complaint is only reported; one that kept the stream leaves this end open.

Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil
Raises
TypeCondition
string

when the platform reported a problem, released or not

Resultrecord#

record Result ...

A completed duplex exchange.

Members

NameKindDescription
exitfieldHow the child ended.
outputfieldCaptured standard output.
errorOutputfieldCaptured standard error.
succeededmethodWhether the child exited normally with status zero.

exitfield#

exit: process.Exit

How the child ended.

outputfield#

output: string

Captured standard output.

errorOutputfield#

errorOutput: string

Captured standard error.

succeededmethod#

succeeded: function succeeded(self): boolean

Whether the child exited normally with status zero.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

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 ...

A child's writable stream.

offer is 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.

Members

NameKindDescription
ownerfield
handlefield
closedfield
gonefieldWhether the far end has gone, which is not the same as this end being shut.
isEndedmethodWhether the far end has gone, so that no amount of waiting will deliver another byte, or this end has been closed.
isReleasedmethodWhether this end has been closed.
offermethodWrites what the pipe will take without waiting.
sendmethodWrites every byte, suspending as often as the pipe makes it.
writemethodWrites every byte, suspending until the pipe has taken them all.
writeSpanmethod
flushmethod
asWritermethodBorrows this stream through the shared completion-oriented contract.
closemethodCloses this end.

ownerfield#

owner: any
@private

handlefield#

handle: any
@private

closedfield#

closed: boolean
@private

gonefield#

gone: boolean
@private

Whether the far end has gone, which is not the same as this end being shut. closed means the platform has released this stream; gone means nobody is reading the far end any more. A broken pipe sets only gone, because this end is still ours to close.

isEndedmethod#

isEnded: function isEnded(self): boolean

Whether the far end has gone, so that no amount of waiting will deliver another byte, or this end has been closed.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

isReleasedmethod#

isReleased: function isReleased(self): boolean

Whether this end has been closed.

Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean

offermethod#

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

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

Arguments
NameTypeDescription
selfany
datastring
Returns
TypeDescription
integer
Raises
TypeCondition
string

when the provider returns an invalid result

sendmethod#

send: function send(self, data: string, deadline: number?, stallTimeoutMs: integer?): integer

Writes every byte, suspending as often as the pipe makes it. Returns how many went, which is all of them unless the far end went away or a bound ran out first.

deadline is a time in the nupp.time.now domain after which no more is sent, however the child is keeping up. stallTimeoutMs bounds a period with no progress instead, so a child that keeps taking bytes keeps the write going until the deadline. Without either, only the child's own deadline and the enclosing task scope's bound the wait.

Arguments
NameTypeDescription
selfany

this stream

datastring

the bytes to send

deadlinenumber?

when to stop, in the monotonic clock's milliseconds

stallTimeoutMsinteger?

how long the pipe may take nothing before giving up

Returns
TypeDescription
integer

how many bytes went

writemethod#

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

Writes every byte, suspending until the pipe has taken them all.

Bounded the way read is: by the child's deadline and the enclosing task scope's. send takes an explicit deadline or stall bound for a caller that needs one of its own.

Arguments
NameTypeDescription
exclusive selfany
bytesstring
Returns
TypeDescription
boolean
string?

writeSpanmethod#

writeSpan: function writeSpan(exclusive self, borrows source: span.ByteSpan): integer?, string?
Arguments
NameTypeDescription
exclusive selfany
borrows sourcespan.ByteSpan
Returns
TypeDescription
integer?
string?

flushmethod#

flush: function flush(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?

asWritermethod#

asWriter: function asWriter(borrows self): Writer2

Borrows this stream through the shared completion-oriented contract.

Arguments
NameTypeDescription
borrows selfany
Returns
TypeDescription
Writer2

closemethod#

close: function close(takes self): nil

Closes this end. Idempotent, on the same terms as the reader's: closed-ness follows what the platform released, not whether it complained, so a stream already given up is never offered to a retry. Closing a child's stdin is how it is told there is no more input.

Arguments
NameTypeDescription
takes selfany
Returns
TypeDescription
nil
Raises
TypeCondition
string

when the platform reported a problem, released or not

Functions#

process.spawnfunction#

function process.spawn(options: process.Options): affine(process.Process)?, io.Error?

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. A child that cannot be started, such as a program that is not there, returns nil and why rather than raising, as files.open and net.connect do.

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

Arguments

NameTypeDescription
optionsprocess.Options

what to run and how to connect it

Returns

TypeDescription
affine(process.Process)?

the child, which the caller owns and must close

io.Error?

why it could not be started, when unsuccessful

Raises

TypeCondition
string

when the options could never describe a child