slate:process
Another program, this program’s own environment, and being asked to stop.
import { env, args } from slate:process
print(args, env("PATH") is string, env("NO_SUCH_VARIABLE_HERE"))
[] true null
stderr
print is the answer a program produces and stderr is what it says about producing it, which is
the division every shell already makes: slate app.sl > answers.txt keeps the answers in the file and
lets the complaints through to the terminal.
import { stderr } from slate:process
stderr("could not reach the database, retrying\n")
- It takes as many values as
printdoes and separates them the same way, and answers nothing. - The newline is yours, as node’s
process.stderr.writeleaves it: a program writing a JSON object per line and one drawing a progress bar want different answers, and only one of them can be the default. - It writes straight to the descriptor and does not wait. A complaint is the one thing a program may
need to have said before the next line runs — a crash after an
awaitwould lose it — so this is synchronous, exactly asprintis.
args and exit
#!/usr/bin/env slate
import { args, exit } from slate:process
if args.length == 0
print("usage: greet <name>...")
exit(2)
for name in args
print("Hello, " + name + "!")
argsis a value, not a call, and it holds only what came after the program’s name — soargs[0]is the first thing a person typed. That is where slate parts from C, node and Python, all three of which hand a program its whole command line and begin by skipping past themselves.exit(status)stops the program and tells the shell what it came to. Everything printed before it is still printed, atrybetween it and the top cannot swallow it, and a status outside 0–255 is refused rather than truncated — a shell keeps the low eight bits, so an unexaminedexit(256)is a program that says it failed and is recorded as having succeeded.- slate reads no argument of its own after the program’s name, so a script’s own options are safe to invent.
- Without an
exit, a program that ran answers0and one that faulted answers1.slateitself answers2when it could not work out what it was being asked to do.
env
env(name) answers string | null. ?? "" is what a program writes.
environment
environment() answers every variable in this program’s own environment as an object, name to string —
for a program that has to see the ones it was not told the names of.
import { env, environment } from slate:process
val all = environment()
print(all["PATH"] == env("PATH"), has(all, "NO_SUCH_VARIABLE_HERE"))
all.NO_SUCH_VARIABLE_HERE = "added"
print(env("NO_SUCH_VARIABLE_HERE"), has(environment(), "NO_SUCH_VARIABLE_HERE"))
true false
null false
- Every call answers a new object, so changing it changes nothing: not the environment, not
env, and not the next call. Likeenv, it is reading only. - A browser has no environment and
environment()answers an empty object, asenvanswers every name unset.
pid
pid() answers this process’s own process id, the number the operating system knows it by —
child.pid‘s own question, asked of the program itself rather than of a child it started.
import { pid } from slate:process
print(pid() > 0)
true
A browser has no process id and pid() answers null there.
run
val r = await run(cmd, args, options) // { status, signal, out, err } or an error
Options are { cwd, env, timeout }.
- A child that ran and failed is a success with a non-zero status; an error means there was no child. That is what lets a caller tell “the program said no” from “there is no such program”.
- A signal is
null, never0, since 0 is a status a program can exit with. - Settling waits for three things: the child exiting and both pipes reaching end of file. A child can exit with output still in the pipe, so settling on the exit alone hands back a truncated answer that looks whole.
- The child’s stdin is closed, not inherited. An inherited descriptor has the child competing with slate for the terminal, and a child waiting on input nobody will type is a hang with nothing on screen.
envreplaces the child’s environment. A program that wants to add a variable reads it withenv(name)and passes it through; one that wants an empty environment has no other way to say so.- A timeout sends
SIGKILL, a timeout being the caller saying they will not wait any longer and a signal the child may ignore leaving the promise pending exactly as it was. - Output that is not text fails the call rather than being replaced or mangled.
spawn
run is one question with one answer and spawn is a conversation. run starts a program, waits
for it and hands back what it printed; a worker — something started once, told things over its
lifetime, and watched for the moment it dies — has nothing to wait on and no single answer to be
given. So spawn answers at once, with a child the program keeps.
import { spawn } from slate:process
async main()
val started = spawn("/bin/sh", ["-c", "read line <&3; printf '%s\\n' \"$line\" >&3"],
{ ipc: true })
val worker = started.value
worker.onMessage(m ->
print("the worker sent back job", m.job))
worker.send({ job: 7 })
val gone = await worker.exited
print("it left with", gone.value.status)
main()
the worker sent back job 7
it left with 0
spawn(command, args)andspawn(command, args, options)answer{ ok: true, value: child }or{ ok: false, error }, and they answer it AT ONCE. The child has already been started by the time the call comes back, so “there is no such program” is known there rather than one turn later.- The options are
cwd,envandipc.envreplaces the child’s environment exactly asrun‘s does;ipc: trueopens the message channel. - stdin is closed and stdout and stderr are INHERITED, which is the pair a worker wants: a child
waiting on input nobody will type is a hang with nothing on screen, and a worker’s own log lines
belong wherever its supervisor’s do. A program that wants the output captured instead wants
run. - A live child keeps the program alive, exactly as a socket does. A supervisor that has started a worker and reached its last statement is not finished.
What a child answers to
child.pid | the number the operating system knows it by, readable after it has died |
child.exited | a promise of { status, signal }, settled once — await it to learn a worker died |
child.onExit(fn) | the callback form of the same answer |
child.send(value) | one message, as JSON and a newline — { ok, error } |
child.send(value, socket) | the same message with a connection travelling beside it |
child.onMessage(fn) | what to call for every message the child sends |
child.kill(), child.kill(signal) | ask it to stop; SIGTERM where no signal is named |
exitedandonExitare both there because a supervisor wants each in a different place.await child.exitedis what one worker’s own line of execution waits on;onExitis what a supervisor watching several of them registers, having no line of execution to give to any one.- A signal is
nulland never0, which isrun‘s rule and for its reason: zero is a status a program can exit with, so “killed by signal 0” — which is not a thing — has to be distinguishable from “not killed”. A child stopped bykill()answers15. sendanswers a RESULT and refuses a value that is not a message. A worker dying while its supervisor was writing to it is the ordinary shape of a process ending, so that is{ ok: false, error }; a valuetoJSONcannot render is a defect in the program and faults, intoJSON‘s own words. That is slate’s two-channel rule read twice in one call.- A child started without
ipcis refused a message rather than answering a failure: the program asked for a child with no channel and then wrote to the channel.
Passing a connection to a worker
A supervisor can hand an accepted connection to a child, which is how one port is spread over several
processes. The supervisor is the only program listening; it accepts every connection itself and gives
each one to a worker over the channel it spawned that worker with. This is what node’s cluster module
does by default, and on macOS it is the only way — the kernel refuses reusePort there.
import { spawn } from slate:process
import { listen } from slate:net
var workers = []
var next = 0
for i in 0..<4
workers.push(spawn("slate", ["worker.sl"], { ipc: true }).value)
listen(8080, conn ->
workers[next].send({}, conn)
next = (next + 1) % workers.length)
and the worker, which never listens at all:
import { channel } from slate:process
import { adopt } from slate:http
val app = adopt(req -> "answered by a worker")
channel().onMessage((m, conn) ->
if conn != null then app.handle(conn))
- The second argument is a connection — what
acceptgives a listener and whatconnectanswers. It travels as a duplicate of the descriptor, so the sending side’s copy is closed once the message has gone: two programs reading one connection would each see half of it. That is node’s rule too. - One message carries at most one connection, and the message is what says what to do with it —
which worker, which tenant, which protocol.
{}is an ordinary payload where there is nothing to say. - A socket may only be passed between two programs on the same host, a descriptor crossing a Unix
domain socket under the interpreter and node’s own channel under
slate js. Each end says which host it is on — the parent in the child’s environment, the child in the line its channel opens with — so a pair that cannot pass one is refused with a sentence rather than dropping it. - So a socket goes to a child that has already said something. A worker signalling that it is ready is what a supervisor waits for anyway; before that, the supervisor has started a command and does not yet know what it turned out to be:
import { spawn } from slate:process
import { listen } from slate:net
val worker = spawn("/bin/sh", ["-c", "sleep 1"], { ipc: true }).value
listen(0, conn -> null)
worker.send({}, listen(0, conn -> null))
has not said what it is yet
channel
The other end of the same thing, read from inside the child.
import { channel } from slate:process
val ch = channel()
if ch == null
print("nobody started me")
else
ch.onMessage(job ->
ch.send({ done: job.id }))
channel()answers{ send, onMessage }ornull.nullrather than a fault, because the same file is a worker under a supervisor and a script somebody typed the name of — and asking which it is now is the whole point of the call. Asking twice answers the same channel.- An open channel keeps the program alive, which is what a worker waiting to be told something is. It ends when the other end goes, so a worker whose supervisor has finished finishes too.
onMessageis handed the message and the connection that came with it —fn(value, socket), wheresocketisnullfor an ordinary message. A handler written with one parameter is unchanged, a call dropping the arguments a function did not ask for.channel().send(value, socket)sends one back up, under the same rule aschild.send.- The wire is one JSON value per line on descriptor 3, and the child finds it through
SLATE_CHANNEL_FDin its environment — which is what node’sNODE_CHANNEL_FDis for. It is the same format on both back ends, so a supervisor running under the interpreter can drive a child running under node and the other way round.SLATE_CHANNEL_HOSTbeside it says which host the parent is running on, and the first line a channel writes says which host the child is — the pair that decides whether a connection can cross. - A message arrives whole or not at all. A read is a run of bytes and a message is a line, so half a message waits for the rest of itself — and a line that is not JSON stops the program naming the channel rather than being dropped where nobody would see it.
- There is no
close. A worker ends by ending, and a supervisor closes a channel by letting the child go — or by asking it to stop withkill.
Signals
A program that leaves a socket open never exits, so a server ends only from the inside — and every way a deployment has of asking one to stop is a signal:
import { onSignal } from slate:process
import { serve, close } from slate:http
val server = serve(8080, req -> "hello")
onSignal("SIGTERM", () ->
print("shutting down")
close(server))
SIGTERM is what a container stopping and a systemd unit restarting both send; SIGINT is Ctrl-C.
- The handler is an ordinary function at an ordinary time. It runs between one turn of the loop and the next, so nothing about it is restricted the way a C signal handler is: it may print, allocate, close a socket, start a timer, and take as long as the shutdown needs.
- It is handed nothing, and the registration is what names the signal. slate checks the count of a call’s arguments, so a name passed to every handler would be a parameter almost none of them would read; a program that wants one function for two signals registers it twice.
- A watcher does not keep the program alive. A script that installs a handler and does nothing else still ends. What keeps a server running is the server.
onSignalanswers an id andoffSignal(id)stops that handler. Stopping one twice is not an error.SIGKILLandSIGSTOPare refused by name — the kernel acts on those itself and nothing a program says will run first.