Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Processes

Run other programs from Lua: read a command’s output, launch an app the user keeps, or hold a long-running program across reloads. Call them from event handlers (on_click, on_change, a timer) or a module’s top level. Storage, timers, actions and the utilities are on scripting; Supervisor, Renderer and push are in the glossary.

A temperature from an HTTP API that refreshes on click, with process.run, json, log and a named state:

local temperature = state("temperature", "--")

local function refresh()
    local body = {}
    process.run("curl", { "-fsS", "--max-time", "5",
        "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current_weather=true" },
        function(line, stream)
            if stream == "stdout" then body[#body + 1] = line end
        end,
        function(code)
            local data = code == 0 and json.decode(table.concat(body)) or nil
            if type(data) == "table" and data.current_weather then
                temperature:set(string.format("%.0f°", data.current_weather.temperature))
            else
                log.warn("weather: no reading; curl exited with", code)
            end
        end)
end
refresh()

return panel {
    id = "weather", layer = "top", anchor = { top = true },
    child = rect {
        on_click = refresh,
        children = { text { content = temperature, font_size = 14 } },
    },
}

Which one do I use

process.run / process.capturesession_processprocess.detach
OutputLine by line to on_line; capture: one string at the endInherited stdio: mantle logNone: /dev/null
Exiton_exit(code); capture: on_done(stdout, code)running and exit_code signalsNone
InstancesOne per callOne per name; start is a no-op while it runsOne per call
OwnerThe evaluation that started itThe SupervisorNobody: own session, reparented to init
ReloadKilled, on_exit(nil)Keeps runningUnaffected
Renderer crashKilled, no on_exitKeeps runningUnaffected
Shell stopsKilledStopped with stop_signal, SIGKILL after 5 sUnaffected
Typical usescurl, getent, a poll every N seconds, a --follow streamA screen recorder, a daemon the shell ownsApps, xdg-open, a terminal
  • Whole stdout as one string and the exit code once it ends: process.capture.
  • Lines as they arrive, or a stream that never ends (tail -f): process.run. A long-running one restarts with each save.
  • One instance that must outlive a save or a crash: session_process.
  • A program the user keeps after the shell: process.detach.

What else survives a reload, a crash and a stop: runtime.

process.run

Spawns a helper, streams its output line by line, and reports its exit.

PartContract
Signatureprocess.run(cmd, args, on_line, on_exit) → handle
cmdProgram name, looked up on PATH. No shell: no globbing, pipes, ~, $VAR or quoting
argsList of already-split strings; "a b" is one argument. Numbers coerce. nil or {} for none
on_line(line, stream)Once per line, newline stripped. stream is "stdout" or "stderr". Invalid UTF-8 is replaced; a final line without a newline still arrives
on_exit(code)Once, after both streams close and the process exits. code is the exit status, or nil when a signal ended it or the spawn failed
Handlehandle:kill(): SIGTERM to the whole process group, SIGKILL 100 ms later. on_exit still fires. A no-op after exit
stdiostdin /dev/null, so a prompt fails instead of hanging; stdout and stderr piped
EnvironmentInherited from the shell. The working directory is the shell’s and unspecified: use absolute paths. ~/ is not expanded in args
LifetimeUntil the next reload, failed ones included, which kills its group as kill() does and calls on_exit(nil) at once, before the new evaluation runs; no on_line follows. A Renderer replacement or shell exit reaps its group without calling on_exit
LimitsA line over 64 KiB is cut there and the rest of that line dropped, with one warning per stream. Callbacks run outside the CPU budget. A raise in either callback is logged as a warning

The call returns immediately. A spawn failure (command not on PATH) reaches Lua only as on_exit(nil) with no output; the reason is logged as a warning.

process.capture

Runs a helper to its end and hands over all of its stdout at once. process.run without the line accumulator, and with the newlines left in.

process.capture("date", { "+%F" }, function(stdout, code)
    if code == 0 then today = stdout:gsub("\n$", "") end
end)
PartContract
Signatureprocess.capture(cmd, args, on_done) → handle
cmd, args, handle, stdin, environmentAs process.run
on_done(stdout, code)Once, after the process exits. stdout is everything it wrote, newlines kept ("a\nb\n"; no newline is added), "" for none. Invalid UTF-8 is replaced. code is the exit status
FailureA signal, a failed spawn or a reload gives on_done(nil, nil); the reload calls it at once, as for process.run
stderrDropped
LimitsPast 1 MiB of stdout the rest is dropped, with no warning to Lua. Callbacks run outside the CPU budget; a raise is logged as a warning

process.detach

Starts a program that stops being the shell’s: its own session, reparented to init, stdio on /dev/null. It outlives reloads, Renderer replacement and the shell itself.

return panel {
    id = "dock", layer = "top", anchor = { bottom = true },
    child = rect {
        padding = 8,
        on_click = function()
            process.detach("xdg-open", { os.getenv("HOME") or "/" })
        end,
        children = { text { content = "Home folder" } },
    },
}
PartContract
Signatureprocess.detach(cmd, args?, opts?) → nothing
cmd, argsAs process.run
opts.env{ NAME = "value", OTHER = false }: a string sets the variable, false unsets it, on top of the shell’s environment. An empty name, one holding =, a value holding NUL, or any other type raises. A single-instance app that is already running hands the launch to its existing process, which keeps its old environment
Output, exit codeNone. A failed spawn is logged as a warning and otherwise silent

session_process

Declares one named, long-running program that the Supervisor holds. It survives reloads and Renderer replacement, and its state comes back as signals, so there is no pid file or poll. Use process.run when you need the output.

-- SIGINT lets the recorder finish the file; shutdown uses it too.
local recorder = session_process { name = "recorder", stop_signal = "INT" }
local recording = recorder.running:map(function(running) return running == true end)

local function toggle()
    if recording:get() then
        recorder:stop()
    else
        local file = (os.getenv("HOME") or "") .. "/Videos/" .. os.date("%Y%m%d_%H%M%S") .. ".mp4"
        recorder:start("gpu-screen-recorder", { "-w", "screen", "-o", file })
    end
end

return panel {
    id = "rec", layer = "top", anchor = { top = true, right = true },
    child = rect {
        on_click = toggle,
        children = {
            text {
                content = computed({ recording, recorder.start_error }, function(on, err)
                    if err and err ~= "" then return "Recorder failed: " .. err end
                    return on and "● REC" or "Record"
                end),
                foreground = recording:map(function(on) return on and "#F38BA8" or "#CDD6F4" end),
            },
        },
    },
}

session_process { name, stop_signal? } returns a handle. name must be non-empty; declaring the same name again, on reload or from another module, returns the same handle and re-reads only stop_signal. stop_signal defaults to "TERM". Any other key raises.

Signal names drop the SIG prefix: TERM, INT, HUP, QUIT, USR1, USR2, KILL, STOP, CONT.

Handle signals, each nil until mantle.processes first pushes (and in mantle check):

SignalValue
runningWhether it is up. When false, the rest describe the last run
pidProcess id, also its group id; kept after exit
started_atUnix seconds the current or last run began
exit_codeLast run’s exit status; nil while running, before any run, or after a signal ended it
start_errorWhy the last start spawned nothing, e.g. cmd not on PATH; "" when it spawned

Handle methods (call with :):

MethodEffect
start(cmd, args?)Spawns in a new process group, no shell, stdio inherited so output lands in mantle log. A no-op while running. Clears the last run’s fields
signal(name)Sends one signal to the process only, not its group. A no-op when not running
stop()Sends stop_signal to the group, then SIGKILL to the group after 5 s. A no-op when not running

At shutdown the Supervisor runs stop() on every running program and waits for each. The whole state is also readable as mantle.processes (capabilities).

How do I…

TaskAnswer
Fetch JSON over HTTPThe example at the top
Poll a command every N secondsBelow
Follow a long-running command’s outputBelow
Run a recorder that survives reloadssession_process
Stop a childhandle:kill(), or save: a reload kills them all
Open an app or URLprocess.detach
Read a filefile.read, or persistent_table for JSON settings

Poll a command every N seconds

A reload kills the df in flight and clears the interval, and the top-level call starts a fresh one.

local disk = state("disk_usage", "")

local function poll()
    local lines = {}
    process.run("df", { "--output=pcent", "/" }, function(line, stream)
        if stream == "stdout" then lines[#lines + 1] = line end
    end, function(code)
        if code == 0 and lines[2] then disk:set(lines[2]:match("%d+%%") or "") end
    end)
end
poll()
interval(30000, poll)

return panel {
    id = "disk", layer = "top", anchor = { top = true },
    child = text { content = disk:map(function(value) return "/ " .. value end) },
}

Follow a long-running command’s output

A process.run child that never exits keeps calling on_line. Start it at the top level: each save kills the old one and starts one fresh. The retry below runs after the child exits on its own; the one a reload’s on_exit(nil) arms is cleared with the old timers.

local title = state("now_playing", "")

local function follow()
    process.run("playerctl", { "--follow", "metadata", "--format", "{{artist}} - {{title}}" },
        function(line, stream)
            if stream == "stdout" then title:set(line) end
        end,
        function() timer(5000, follow) end)
end
follow()

return panel {
    id = "media", layer = "top", anchor = { top = true },
    child = text { content = title, elide = "end", max_width = 300 },
}

Gotchas

TrapFix
process.run("ls ~/*.png", {}) or process.run("ls", { "~/*.png" })No shell parses anything, so ~, globs and pipes stay literal. Split the arguments yourself, or run "sh", { "-c", "..." } explicitly
Calling json.decode(line) in on_lineOutput arrives one line at a time. Collect lines and decode once in on_exit
Treating kill() as cancelon_exit still fires, usually with nil. Tag requests with a counter and ignore stale ones
on_exit never arrivesIt waits for stdout and stderr to close, and a backgrounded grandchild holding the pipes keeps them open. Redirect the grandchild’s output
A process.run child that must outlive a saveA reload kills it. Use session_process
A failure logged on every saveA reload’s kill calls on_exit(nil). Report only a non-zero code
Invalid stop_signal, or signal(name) with oneRaises mantle.processes:declare: unknown variant ... (or :signal:) and sends nothing. Use a name from the list above

See also: scripting (timer, json, log, persistent_table), runtime (reloads, logging), signals (state), processes capability (mantle.processes), cli (mantle log).

Source: process, spawn and reap, process registry, session process, processes controller.