Module: nupp.io.process

Running a child process.

The lifecycle, the streams, and the draining, written once against a backend that answers in bytes and opaque handles. Platform providers differ in how they spawn and how they read; they do not differ in what a process is, so nothing about what a process is lives in any one of them.

Waiting is a suspension. Under a handler -- a scheduler, a game frame -- reading a pipe parks the caller and the frame keeps running; with none installed the built-in handler drives the pumps and blocks. The same call either way, which is the whole reason nupp.suspension exists.

Three details are worth stating, because they are the ones a first implementation gets wrong and this one got two of them wrong before they were pointed out.

communicate makes one combined step. Not "write all the input, then read stdout to the end, then read stderr". A child that reads its input in pieces while filling stderr deadlocks against every sequential order: the parent blocks writing input the child is not reading, because the child is blocked writing output the parent is not reading. The loop attempts all three every pass and suspends only when none of them moved.

A deadline has to be checked while suspended. Checking it before parking and never again means a quiet child -- one producing nothing at all -- waits past its deadline forever, which is precisely the case a deadline is for.

A killed child is not immediately reapable. Terminating takes as long as it takes, so closing waits for the exit rather than reaping something still dying.

See plans/suspension.md, S5.

Module contents

Constructors

ConstructorDescription
newStarts a child.

Types

TypeKindDescription
CommunicateOptionstypeRe-exported so a caller needs one import.
ExittypeRe-exported so a caller needs one import.
OptionstypeRe-exported so a caller needs one import.
ProcessrecordA running child.
ReaderrecordOne of a child's readable streams.
ResulttypeRe-exported so a caller needs one import.
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.
exitedfunctionThe exit a backend reports, with succeeded attached.
spawnOnfunctionStarts a child on a given backend.
useBackendfunctionInstalls the backend process.new uses.

Constructors#

process.newconstructor#

Starts a child.

function process.new(options: processtypes.Options): process.Process?, string?

Arguments

NameTypeDescription
optionsprocesstypes.Options

what to run and how to connect it

Returns

TypeDescription
process.Process?

the child, which must be closed

string?

Types#

CommunicateOptionstype#

Re-exported so a caller needs one import.

type process.CommunicateOptions = processtypes.CommunicateOptions

Exittype#

Re-exported so a caller needs one import.

type process.Exit = processtypes.Exit

Optionstype#

Re-exported so a caller needs one import.

type process.Options = processtypes.Options

Processrecord#

A running child.

record process.Process
    backend: processtypes.Backend
    handle: any
    deadline: number?
    exit: processtypes.Exit?
    reaped: boolean
    childReleased: boolean
    closing: boolean
    closingBy: any

    pump: any
    timedOut: boolean
    pid: integer
    stdin: process.Writer?
    stdout: process.Reader?
    stderr: process.Reader?
    function isRunning(self): boolean
    end

    function wait(self): processtypes.Exit
    end

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

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

    @drop

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

Methods

isRunning

Whether it is still running.

isRunning: function isRunning(self): boolean
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
wait

Waits for it to end and answers how. Suspends while it runs.

wait: function wait(self): processtypes.Exit
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
processtypes.Exit
kill

Asks it to end; force insists. Answers whether the request was made.

kill: function kill(self, force: boolean?): boolean, string?
Arguments
NameTypeDescription
selfany
forceboolean?
Returns
TypeDescription
boolean
string?
communicate

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.

communicate: function communicate(self, options: processtypes.CommunicateOptions?): processtypes.Result?, string?
Arguments
NameTypeDescription
selfany
optionsprocesstypes.CommunicateOptions?
Returns
TypeDescription
processtypes.Result?
string?
close
close: function close(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?

Fields

NameTypeDescription
backendprocesstypes.Backend
handleany
deadlinenumber?
exitprocesstypes.Exit?
reapedboolean

Whether the whole teardown finished: every stream released, the pump released, and the child handle released. The aggregate, and what makes a repeat call a no-op.

childReleasedboolean

Whether the child handle has been given back, which is a smaller fact than reaped and has to be tracked apart from it. A teardown where a stream close complained still has a child to release, and a retry that had already released it must not ask again -- a pid is reused as readily as a descriptor.

closingboolean

Whether a teardown is in progress. Separate from reaped, which means it finished: a close that failed part way must be retryable rather than silently successful.

closingByany

Which coroutine is running that teardown, so a second call can tell whether it is the same frame coming back around or a different one arriving. Nil on the main coroutine, which is why closing is the flag and this is only the owner.

pumpany
timedOutboolean

Whether the deadline is what ended it, remembered so the exit can say so.

pidinteger

Operating-system process identifier.

stdinprocess.Writer?

Its standard input, when it was piped.

stdoutprocess.Reader?

Its standard output, when it was piped.

stderrprocess.Reader?

Its standard error, when it was piped.

Readerrecord#

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.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 descriptor already gone and says so. Retrying the raw descriptor underneath is the opposite -- that number belongs to whatever the platform has since handed it to. And 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.

record process.Reader is nupp.Reader
    owner: any
    handle: any
    closed: boolean
    eof: boolean
    timeoutMs: integer
    function isEOF(self): boolean
    end

    function isClosed(self): boolean
    end

    function release(self): nil
    end

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

    function next(self): string?
    end

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

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

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

    function transferTo(self, destination: nupp.Writer): (integer?, string?)
    end

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

Methods

isEOF

Whether the far end has finished.

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

Whether this end has been closed.

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

Closes this end. Idempotent.

closed follows ownership rather than success. A platform that gave the descriptor up and then complained has still given it up, so this end is shut and the complaint is only reported; a platform that failed with the descriptor still ours leaves this end open, and another attempt is right. Marking a released descriptor open again is how a retry comes to close whichever unrelated thing has since been given that number.

release: function release(self): nil
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
nil
Raises
  • when the platform reported a problem, released or not

poll

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.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 limits read one byte, which is what nupp.Reader says a non-positive count does. 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.

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

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

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

Bounds the next completion-oriented read.

setTimeout: function setTimeout(self, timeoutMs: integer): nil
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?
readInto
readInto: function readInto(self, destination: nupp.Buffer, offset: integer?, count: integer?): integer?, string?
Arguments
NameTypeDescription
selfany
destinationnupp.Buffer
offsetinteger?
countinteger?
Returns
TypeDescription
integer?
string?
transferTo
transferTo: function transferTo(self, destination: nupp.Writer): integer?, string?
Arguments
NameTypeDescription
selfany
destinationnupp.Writer
Returns
TypeDescription
integer?
string?
close
close: function close(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?

Fields

NameTypeDescription
ownerany
handleany
closedboolean
eofboolean
timeoutMsinteger

Maximum time one completion-oriented read may wait.

Resulttype#

Re-exported so a caller needs one import.

type process.Result = processtypes.Result

Writerrecord#

A child's writable stream.

offer and isGone are the nonblocking half, for the same reason the reader has poll: the prelude's nupp.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.Writer operation; offer is concrete and additional.

record process.Writer is nupp.Writer
    owner: any
    handle: any
    closed: boolean
    gone: boolean
    timeoutMs: integer
    function isGone(self): boolean
    end

    function isClosed(self): boolean
    end

    function release(self): nil
    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(self, bytes: string): (boolean, string?)
    end

    function writeFrom(self, source: nupp.Buffer, offset: integer?, count: integer?): (integer?, string?)
    end

    function writeView(self, source: nupp.ByteView, offset: integer?, count: integer?): (integer?, string?)
    end

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

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

Methods

isGone

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

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

Whether this end has been closed.

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

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

release: function release(self): nil
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
nil
Raises
  • when the platform reported a problem, released or not

offer

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

offer: function offer(self, data: string): integer
Arguments
NameTypeDescription
selfany
datastring
Returns
TypeDescription
integer
send

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.

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

Bounds the next completion-oriented write.

setTimeout: function setTimeout(self, timeoutMs: integer): nil
Arguments
NameTypeDescription
selfany
timeoutMsinteger
Returns
TypeDescription
nil
Raises
  • when timeoutMs is outside 0 through 2147483647

write
write: function write(self, bytes: string): boolean, string?
Arguments
NameTypeDescription
selfany
bytesstring
Returns
TypeDescription
boolean
string?
writeFrom
writeFrom: function writeFrom(self, source: nupp.Buffer, offset: integer?, count: integer?): integer?, string?
Arguments
NameTypeDescription
selfany
sourcenupp.Buffer
offsetinteger?
countinteger?
Returns
TypeDescription
integer?
string?
writeView
writeView: function writeView(self, source: nupp.ByteView, offset: integer?, count: integer?): integer?, string?
Arguments
NameTypeDescription
selfany
sourcenupp.ByteView
offsetinteger?
countinteger?
Returns
TypeDescription
integer?
string?
flush
flush: function flush(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?
close
close: function close(self): boolean, string?
Arguments
NameTypeDescription
selfany
Returns
TypeDescription
boolean
string?

Fields

NameTypeDescription
ownerany
handleany
closedboolean
goneboolean

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

timeoutMsinteger

Maximum time one completion-oriented write may wait.

Functions#

process.asReaderfunction#

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

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

Arguments

NameTypeDescription
sourceprocess.Reader

Returns

TypeDescription
nupp.Reader borrows source

process.asWriterfunction#

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

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

Arguments

NameTypeDescription
sourceprocess.Writer

Returns

TypeDescription
nupp.Writer borrows source

process.exitedfunction#

The exit a backend reports, with succeeded attached.

Backends build these, so the one judgement every caller makes -- did this work -- has one definition rather than one per platform.

function process.exited(exitCode: integer, killed: boolean, timedOut: boolean): processtypes.Exit

Arguments

NameTypeDescription
exitCodeinteger
killedboolean
timedOutboolean

Returns

TypeDescription
processtypes.Exit

process.spawnOnfunction#

Starts a child on a given backend.

Internal, and what the tests use so the state machine can be driven without a platform. Ordinary callers want process.new.

function process.spawnOn(backend: processtypes.Backend, options: processtypes.Options): process.Process

Arguments

NameTypeDescription
backendprocesstypes.Backend

the platform that runs it

optionsprocesstypes.Options

what to run and how to connect it

Returns

TypeDescription
process.Process

the child, which must be closed

process.useBackendfunction#

Installs the backend process.new uses.

Called by a platform module, not by a caller: the point of the seam is that nothing above it names a platform.

function process.useBackend(backend: processtypes.Backend): nil

Arguments

NameTypeDescription
backendprocesstypes.Backend

Returns

TypeDescription
nil