Processes
I/O and native services · Previous: Time
Process.run and Process.spawn
Both take an optional second argument, a Hash of options with String keys:
Process.run(["sh", "-c", "echo $NAME; pwd"],
{"env": {"NAME": "demo"}, "cwd": "/tmp"})
Process.run(["wc", "-c"], {"stdin": "bytes to feed"})
"env"— a Hash of String to String, merged over the script'sENV(a name already present is replaced). Names must be non-empty and contain no=."cwd"— the child's working directory. A directory that does not exist raisesIOError, like any other spawn failure."stdin"—Process.run: a String written to the child's stdin followed by EOF. It is fed while the output is drained, so a child that writes before it finishes reading cannot deadlock the call, and a child that exits early is not an error.Process.spawn:truegives the handle a writable pipe athandle.stdin()(see below).
Process.spawn also takes three options that send the child's output to a
file instead of a pipe, for a long-running child nobody is reading from:
"stdout"/"stderr"— a file path; the file is created (mode 0600) and appended to. The matchinghandle.stdout()/handle.stderr()isnil."stderr_to_stdout"—truepoints the child's stderr at wherever its stdout goes (the pipe, or the"stdout"file), so the two interleave in order.handle.stderr()isnil.
A relative output path is resolved against the child's "cwd" when one is given. Giving "stderr" together with
"stderr_to_stdout" raises TypeError. Under DIAMOND_SANDBOX, the "stdout" and "stderr" file options need the
filesystem capability as well as subprocess.
An unknown key, or a value of the wrong type, raises TypeError.
result = Process.run(["ls", "-la", dir])
puts(result.stdout())
puts(result.exit_code())
result.success?() # => exit_code() == 0
result = Process.run(["sh", "-c", "echo out; echo err 1>&2; exit 3"])
result.stdout() # => "out\n"
result.stderr() # => "err\n"
result.exit_code() # => 3
One constructor, one native result type (DIAMOND_OBJECT_PROCESS_RESULT),
compiling to a dedicated opcode the same way Time.at/SQLite3.open do:
Process.run(argv)—argvis anArrayofStrings, the command followed by its arguments (argv[0]is resolved againstPATH, same asexecvp). Blocks until the child exits, with its full stdout and stderr captured. Argv-array-only, deliberately — there is no shell- string form (Process.run("ls -la")) at all, so there is no shell- injection surface to guard against; aStringelement is passed to the child exactly as written, never interpreted by a shell. Run a real shell explicitly (Process.run(["sh", "-c", "..."])) if that's what's needed, same as Ruby's ownProcess.spawn(argv)array form.
Instance methods on the result, all ordinary .method() calls:
.stdout()/.stderr()→String, the child's captured output.exit_code()→Int— the child's real exit status if it exited normally, or128 + signal numberif it was killed by a signal (matching the shell's own convention).success?()→Bool,exit_code() == 0
Process.run's own v1 scope, deliberately minimal:
- By default the child's stdin is
/dev/null: a command that reads stdin sees immediate EOF (Process.run(["cat"])returns empty stdout and exit code0rather than hanging). The"stdin"option, below, feeds it data. - Fully blocking/synchronous — no way to run a child in the background,
poll it, or kill it early (
Process.spawn, below, is exactly that). A long-running or hung child blocks the calling d6 program for as long as it runs. - A trapped
Signal(seeSignal.trapabove) does not get to run while aProcess.runcall is blocked waiting on the child — it runs once the child exits andProcess.runreturns, not immediately. (UnlikeIO.poll, which does handle this — see that section above. This applies toProcess.spawn's own#waittoo, for the same reason: it's a plain blockingwaitpid, not built onIO.poll.) - Command-not-found and other spawn failures (a bad path, no exec
permission, ...) raise
IOErrorsynchronously, the same call that fails, rather than exit code127the way a real shell reports it. Same forProcess.spawn.
Process.spawn: a live, non-blocking handle
handle = Process.spawn(["sh", "-c", "sleep 1; echo done"])
puts(handle.pid())
ready = IO.poll([handle.stdout()], [], -1) # blocks until output arrives
puts(ready["readable"][0]) # => true
puts(handle.stdout().read(100)) # => "done\n"
puts(handle.wait()) # blocks until the child exits, => 0
puts(handle.running?()) # => false
handle = Process.spawn(["sleep", "30"])
handle.terminate() # SIGTERM
handle.wait() # => 143 (128 + SIGTERM)
handle.kill() # raises IOError -- process has already exited
Unlike Process.run, Process.spawn(argv) (same argv-Array-only
argument, same PATH resolution, same /dev/null stdin) returns
immediately with a live Process::Handle — no output capture, no
waiting for exit:
.pid()→Int, the child's process ID..stdout()/.stderr()→ aProcess::Streameach (a new object kind,DIAMOND_OBJECT_PROCESS_STREAM) — the read end of a pipe to the child, always the same object across repeated calls. SetO_NONBLOCKright after spawning, the same reasoningTCPServer.listen_nonblocking's acceptedSockets already have: a poll-driven caller needs a realEAGAIN, not libc stdio buffering silently swallowing "nothing yet"..read(n)returns aStringof however many bytes were actually available (up ton),nilat true EOF, or raisesWouldBlockErrorwhen nothing's ready — exactlySocket#read's own contract (see the non-blocking-sockets section above), and for the same reason: it is that contract, on a pipe fd instead of a network one..gets()returns the next line without its line ending (\nor\r\n), ornilat end of output; it waits for the line to arrive. It reads a byte at a time, so it never takes anything past the newline: a.read(n)afterwards starts right after it..close()is idempotent. No.write()— this end is read-only (the child's stdin end is a separate stream, below). Poll it directly:IO.poll([handle.stdout(), handle.stderr()], [], timeout_ms)accepts aProcess::Streamexactly like aSocket..stdin()→ the child's stdin, a writableProcess::Stream, when spawned with{"stdin": true}, otherwisenil..write(string)writes all of the String (blocking until the child has read it) and returns its length;.close()sends EOF. Close it for a child that reads to EOF before it exits (cat,wc)..wait()→Int, the same exit-code conventionProcess.run's own.exit_code()uses (real exit status, or128 + signal number). Blocks until the child exits,waitpid-style — does not drain.stdout()/.stderr()for you first. A child that writes enough to fill the OS pipe buffer while nobody's reading it can deadlock right here, waiting for a read that will never come while the child itself is blocked writing — the same well-documented gotcha every language's "wait without draining" API has (e.g. Python's ownsubprocess.Popen.wait()). A caller that cares about output drains the streams itself (optionally viaIO.poll) while the child runs, or accepts that.wait()can hang for a sufficiently chatty child. Idempotent — a second.wait()call just returns the same cached exit code instead of re-waitpiding (which would either block forever on an already-reaped pid, or, worse, risk observing an unrelated process that has since reused it)..running?()→Bool. A non-blocking check (waitpidwithWNOHANG) — reaps and caches the exit code if the child has already exited, same as.wait()would, but never blocks either way..terminate()/.kill()→ sendsSIGTERM/SIGKILLto the child. RaisesIOErrorif the handle has already observed the child exit (via.wait()or a.running?()that returnedfalse) — once a pid is actually reaped, the OS is free to recycle it for an unrelated process, and signaling a stale pid at that point would hit whatever that pid means now. A child that has exited but hasn't been reaped yet (a zombie) is still safe to signal — POSIX guarantees a zombie's pid stays reserved until reaped — so this guard triggers only once this handle has actually reaped it, never preemptively.- A handle that's simply dropped, never
.wait()ed on: GC sweep reaps it with a non-blockingwaitpidif it has already exited (so it doesn't sit around as a zombie forever), but never blocks the sweep waiting for a still-running child — that child is left running, detached from any d6-level handle from that point on, the same way a shell backgrounding a job and then exiting leaves it running.
What's deliberately out of scope so far
- Multiple
print/putsarguments:puts(a, b)(Ruby-style, one line per argument) is not supported — exactly one argument, matching the narrowest useful slice. - File mode validation:
File.openpassesmodestraight through tofopenwith no d6-level checking. - Transactions as a dedicated native API: no
.transaction { ... }- style block helper built intoSQLite3/PostgreSQL/MySQLthemselves —db.execute("BEGIN")/"COMMIT"/"ROLLBACK"already work today through plain SQL, andpackages/active_record'sActiveRecord::Transaction.run/.run_nestedalready builds exactly this convenience layer in pure d6 code on top of it (includingSAVEPOINT-based nesting), so there's no native gap here to close. - Named timezones and a separate
Date-only type: no named IANA timezone selection (Time.parseand.localtimesupport explicit fixed UTC offsets; Ruby itself needs thetzinfogem for named zones), and no date-without-time type distinct fromTime.
Each of these is a plausible next slice, sized independently rather than attempted together.