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¤t_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.capture | session_process | process.detach | |
|---|---|---|---|
| Output | Line by line to on_line; capture: one string at the end | Inherited stdio: mantle log | None: /dev/null |
| Exit | on_exit(code); capture: on_done(stdout, code) | running and exit_code signals | None |
| Instances | One per call | One per name; start is a no-op while it runs | One per call |
| Owner | The evaluation that started it | The Supervisor | Nobody: own session, reparented to init |
| Reload | Killed, on_exit(nil) | Keeps running | Unaffected |
| Renderer crash | Killed, no on_exit | Keeps running | Unaffected |
| Shell stops | Killed | Stopped with stop_signal, SIGKILL after 5 s | Unaffected |
| Typical uses | curl, getent, a poll every N seconds, a --follow stream | A screen recorder, a daemon the shell owns | Apps, 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.
| Part | Contract |
|---|---|
| Signature | process.run(cmd, args, on_line, on_exit) → handle |
cmd | Program name, looked up on PATH. No shell: no globbing, pipes, ~, $VAR or quoting |
args | List 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 |
| Handle | handle:kill(): SIGTERM to the whole process group, SIGKILL 100 ms later. on_exit still fires. A no-op after exit |
| stdio | stdin /dev/null, so a prompt fails instead of hanging; stdout and stderr piped |
| Environment | Inherited from the shell. The working directory is the shell’s and unspecified: use absolute paths. ~/ is not expanded in args |
| Lifetime | Until 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 |
| Limits | A 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)
| Part | Contract |
|---|---|
| Signature | process.capture(cmd, args, on_done) → handle |
cmd, args, handle, stdin, environment | As 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 |
| Failure | A signal, a failed spawn or a reload gives on_done(nil, nil); the reload calls it at once, as for process.run |
| stderr | Dropped |
| Limits | Past 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" } },
},
}
| Part | Contract |
|---|---|
| Signature | process.detach(cmd, args?, opts?) → nothing |
cmd, args | As 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 code | None. 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):
| Signal | Value |
|---|---|
running | Whether it is up. When false, the rest describe the last run |
pid | Process id, also its group id; kept after exit |
started_at | Unix seconds the current or last run began |
exit_code | Last run’s exit status; nil while running, before any run, or after a signal ended it |
start_error | Why the last start spawned nothing, e.g. cmd not on PATH; "" when it spawned |
Handle methods (call with :):
| Method | Effect |
|---|---|
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…
| Task | Answer |
|---|---|
| Fetch JSON over HTTP | The example at the top |
| Poll a command every N seconds | Below |
| Follow a long-running command’s output | Below |
| Run a recorder that survives reloads | session_process |
| Stop a child | handle:kill(), or save: a reload kills them all |
| Open an app or URL | process.detach |
| Read a file | file.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
| Trap | Fix |
|---|---|
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_line | Output arrives one line at a time. Collect lines and decode once in on_exit |
Treating kill() as cancel | on_exit still fires, usually with nil. Tag requests with a counter and ignore stale ones |
on_exit never arrives | It 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 save | A reload kills it. Use session_process |
| A failure logged on every save | A reload’s kill calls on_exit(nil). Report only a non-zero code |
Invalid stop_signal, or signal(name) with one | Raises 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.