ProcessService

Start other programs from your project, read what they print line by line while they run, write to them, and stop them with everything they started.

ProcessService is the first of the services System projects use. The programs it starts get no window of their own and run at below-normal priority, and so does everything they start. A member that waits is named ...Async, and a member that fails returns nil and why.

Reach it

One line gives a program the service:

local ProcessService = require("@shotsystem/ProcessService")

Every example here is the program of a one-file project (see One-file projects): a plain Luau module, Main, that the header exports, run with shot run MyProject.shotsystem (see Running a project). The examples start python, but any program works the same way. The first one, whole:

MyProject.shotsystem

shotsystem 1
export Main

id 6801fb521646e13e5eabca9a6bd84430
Luau Main #e777df8b
    source
        local ProcessService = require("@shotsystem/ProcessService")

        local result = ProcessService.RunAsync("python", { "-c", "print('hello from python')" })
        print(result and result.Stdout)

shot run MyProject.shotsystem prints:

hello from python

Run a program to the end

Hand a program its arguments and get back everything it said. RunAsync starts it, waits for it to end, and returns its exit code and both of its output streams, each stream's lines joined by "\n".

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = [[
import sys
print("2 + 2 =", 2 + 2)
print("careful", file=sys.stderr)
sys.exit(3)
]]

local result, why = ProcessService.RunAsync("python", { "-c", code })
if not result then
    error("couldn't run python: " .. tostring(why))
end
print("code: " .. result.Code)
print("stdout: " .. result.Stdout)
print("stderr: " .. result.Stderr)

prints:

code: 3
stdout: 2 + 2 = 4
stderr: careful

A program that runs and fails still gives you a result: look at Code. nil comes only when the program can't start (WHEN IT CAN'T START, below).

Watch it as it runs

Start a program and hear every line the moment it is printed. StartAsync returns a Child as soon as the program runs. Its Line signal fires with each line and the stream it came from, Exited fires once with the exit code after the last line, and WaitAsync waits for the end and returns the same code.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = [[
import sys, time
for step in range(3):
    print("step", step)
    time.sleep(0.5)
print("done", file=sys.stderr)
]]

local child, why = ProcessService.StartAsync("python", { "-u", "-c", code })
if not child then
    error("couldn't start python: " .. tostring(why))
end
child.Line:Connect(function(line, stream)
    print(stream .. ": " .. line)
end)
child.Exited:Connect(function(exitCode)
    print("Exited fired with " .. exitCode)
end)
print("started, pid " .. tostring(child.Pid))
print("WaitAsync returned " .. child:WaitAsync())

prints, one step every half second:

started, pid 51500
stdout: step 0
stdout: step 1
stdout: step 2
stderr: done
Exited fired with 0
WaitAsync returned 0

Connect Line right after StartAsync, before anything that waits: lines start arriving at your program's next wait, and a line that arrives with nothing connected is gone. -u makes python send each line at once (more on that at the end of the page).

Talk to it

Feed a program its input while it runs. Write sends text to the program's input and CloseInput ends the input, after the writes before it. Neither waits, and a write after CloseInput is dropped. RunAsync takes the whole input as its fourth argument and closes the input for you.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = [[
import sys
for line in sys.stdin:
    print(line.strip().upper(), flush=True)
]]

local child, why = ProcessService.StartAsync("python", { "-c", code })
if not child then
    error("couldn't start python: " .. tostring(why))
end
child.Line:Connect(function(line)
    print("got " .. line)
end)
child:Write("apple\n")
child:Write("pear\n")
child:CloseInput()
print("ended with " .. child:WaitAsync())

local result = ProcessService.RunAsync("python", { "-c", code }, nil, "plum\nfig\n")
if result then
    print("RunAsync got: " .. result.Stdout)
end

prints:

got APPLE
got PEAR
ended with 0
RunAsync got: PLUM
FIG

Choose where it runs

Start a program in another folder, with settings of its own. The options table, the third argument of StartAsync and RunAsync, takes Cwd: the folder the program starts in, which must exist. A relative folder is taken from the folder you ran shot run in. Env adds variables to the ones your program has, or replaces them by name.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = [[
import os
print(os.path.basename(os.getcwd()), os.environ["GREETING"], "PATH" in os.environ)
]]

local result, why = ProcessService.RunAsync("python", { "-c", code }, {
    Cwd = "Notes",
    Env = { GREETING = "hello" },
})
if not result then
    error("couldn't run python: " .. tostring(why))
end
print(result.Stdout)

run from a folder that holds a folder called Notes, prints:

Notes hello True

See what is running

List every process on the computer, with what started it. ListAsync returns one entry per process with its Pid, ParentPid, Name and CommandLine. CommandLine is nil for system processes, which don't show theirs.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local child = ProcessService.StartAsync("python", { "-c", "import time; time.sleep(3)" })
assert(child, "couldn't start python")

local list, why = ProcessService.ListAsync()
if not list then
    error("couldn't list the processes: " .. tostring(why))
end

local byPid = {}
local someHidden = false
for _, info in list do
    byPid[info.Pid] = info
    if info.CommandLine == nil then
        someHidden = true
    end
end

local mine = byPid[child.Pid]
print(mine.Name .. ", started by " .. byPid[mine.ParentPid].Name)
print(mine.CommandLine)
print("some processes hide their command line: " .. tostring(someHidden))

child:KillAsync()

prints:

python.exe, started by shot.exe
"python" -c "import time; time.sleep(3)"
some processes hide their command line: true

Stop it

End a program and everything it started in one call. A Child's KillAsync stops the program and the programs it started, and returns once they are gone. ProcessService.KillAsync(pid) does the same for any process and returns how many processes ended. A program stopped this way exits with code 1.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local sleeper = ProcessService.StartAsync("python", { "-c", "import time; time.sleep(5)" })
assert(sleeper, "couldn't start python")
local started = os.clock()
sleeper:KillAsync()
print(string.format("stopped after %.1f s, exit code %d", os.clock() - started, sleeper.ExitCode))

local code = "import time; print('ready'); time.sleep(5)"
local shell = ProcessService.StartAsync("cmd", { "/c", "python", "-u", "-c", code })
assert(shell and shell.Pid, "couldn't start cmd")
print("cmd's python says " .. shell.Line:Wait())
print("processes ended: " .. ProcessService.KillAsync(shell.Pid))
print("cmd exited with " .. shell:WaitAsync())

prints:

stopped after 0.0 s, exit code 1
cmd's python says ready
processes ended: 2
cmd exited with 1

The second program is cmd running python: stopping cmd's pid ended both, five seconds early.

Signals

Hook a callback to every line, to the next one only, or wait for it. Line and Exited are signals: Connect(callback) calls back on every fire until you call Disconnect() on the connection it returns, Once(callback) calls back on the next fire only, and Wait() waits for the next fire and returns its values.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = "for n in range(1, 5): print(n)"
local child = ProcessService.StartAsync("python", { "-u", "-c", code })
assert(child, "couldn't start python")

child.Line:Once(function(line)
    print("first: " .. line)
end)
local seen = 0
local connection
connection = child.Line:Connect(function(line)
    seen += 1
    print("line " .. line)
    if seen == 2 then
        connection:Disconnect()
    end
end)

local exitCode = child.Exited:Wait()
print("exited with " .. exitCode .. ", ExitCode is " .. tostring(child.ExitCode))

prints:

first: 1
line 1
line 2
exited with 0, ExitCode is 0

The python program printed four lines: Once heard the first, and the connection heard two before it disconnected itself.

What waits

The name tells you where your program stops. It pauses at a member whose name ends in Async, and at a signal's Wait, until that is done, and the callbacks you connected run meanwhile. Write and CloseInput never wait. On a program that already ended, WaitAsync returns its exit code at once and a Child's KillAsync returns at once.

When it can't start

Every member that can fail tells you why. StartAsync, RunAsync and ListAsync return nil and a message when they can't do their job; ProcessService.KillAsync returns 0 when there was nothing to end.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local child, why = ProcessService.StartAsync("no-such-program")
print(child, why)

local result, problem = ProcessService.RunAsync("python", { "--version" }, { Cwd = "Missing" })
print(result, problem)

print(ProcessService.KillAsync(999999))

prints:

nil could not start no-such-program: program not found
nil could not start python: bad argument #3: runtime error: Invalid value for option 'cwd' - path does not exist
0

Two things to know

Some programs hold their output back. A python program run without -u keeps what it prints until it ends, so the lines arrive late and all at once. Ask the program to send each line as it goes (-u for python), or have it flush.

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

local code = "import time; print('first'); time.sleep(1); print('second')"

local function watch(args : {string})
    local child = ProcessService.StartAsync("python", args)
    assert(child, "couldn't start python")
    local last : number? = nil
    child.Line:Connect(function(line)
        local now = os.clock()
        print(if last then string.format("%s, %.1f s later", line, now - last) else line)
        last = now
    end)
    child:WaitAsync()
end

print("python -u:")
watch({ "-u", "-c", code })
print("python:")
watch({ "-c", code })

prints:

python -u:
first
second, 1.0 s later
python:
first
second, 0.0 s later

A program you leave running keeps running after your program ends, also when your program stops with an error. Stop what you start: wait for it, or keep its Child and call KillAsync when you are done. This one doesn't:

Main.luau

local ProcessService = require("@shotsystem/ProcessService")

for _, info in ProcessService.ListAsync() or {} do
    if info.CommandLine and string.find(info.CommandLine, "time.sleep(3)", 1, true) then
        print("still running from the last run: " .. info.Pid)
    end
end

local child = ProcessService.StartAsync("python", { "-c", "import time; time.sleep(3)" })
assert(child, "couldn't start python")
print("started " .. tostring(child.Pid) .. ", not waiting for it")

The first shot run MyProject.shotsystem ends at once and prints:

started 51124, not waiting for it

and a second one, straight after it:

still running from the last run: 51124
started 25384, not waiting for it

Both python programs ended on their own, three seconds after each started.

Every member

The service:

  • StartAsync(program, args?, options?) -> (Child?, why?): starts the program (a name found on the PATH, or a path to it) with args, a list of strings, and returns its Child once it runs. nil and why when it can't start.
  • RunAsync(program, args?, options?, input?) -> (result?, why?): starts the program, writes input if you give one, closes its input (also with no input) and waits for the end. The result is { Code, Stdout, Stderr }, each stream's lines joined by "\n". nil and why when it can't start.
  • ListAsync() -> (list?, why?): every process on the computer, each { Pid, ParentPid, Name, CommandLine } with CommandLine nil for system processes. nil and why when the list can't be read.
  • KillAsync(pid) -> number: ends the process and every process it started, waits until they are gone and returns how many ended (0 when there was nothing to end).
  • options is { Cwd = folder?, Env = { NAME = value }? }, for both StartAsync and RunAsync.

A Child:

  • Pid: the program's process id.
  • ExitCode: nil while the program runs, then its exit code.
  • Line: a signal that fires (line, stream) for every line the program prints, the line without its line end and stream either "stdout" or "stderr".
  • Exited: a signal that fires (code) once, after the last line.
  • Write(text): sends text to the program's input. Never waits.
  • CloseInput(): ends the program's input after the writes before it. Never waits.
  • WaitAsync() -> number: waits for the end and returns the exit code.
  • KillAsync(): ends the program and everything it started, and returns once they are gone.

A signal:

  • Connect(callback) -> connection: calls back on every fire, until connection:Disconnect().
  • Once(callback) -> connection: calls back on the next fire only.
  • Wait(): waits for the next fire and returns its values.