# "Process Supervision Recipes"
_Path: en/tutorials/supervision_
> "Apply monitoring, linking, cancellation, and restart patterns to Wippy processes."
## Table of Contents
- Process Supervision Recipes
## Content
# Process Supervision Recipes
Use monitoring and linking to observe process exits, propagate failures, handle cancellation, and restart workers.
**Classification:** Partial recipe. The lifecycle snippets are independent, and the
worker-pool section supplies its core entries but not the separate control process
needed to trigger and verify a restart.
## Context and Dependencies
The snippets target Wippy runtime `v0.3.32a` and assume an executable Lua entry, a
running `process.host` named `app:processes`, and project-defined worker entries such
as `app.workers:task_worker`. The `process` and `channel` APIs are ambient globals.
Any snippet that calls `time.*` requires the `time` module in its entry and
`local time = require("time")` in its source.
Process spawn, host selection, monitoring, linking, sending, cancellation, and
termination are guarded operations. Attach an actor and narrowly scoped allow
policies to each executable entry that uses them. The worker-pool configuration
below includes the policies needed by that recipe; the isolated snippets do not.
## Monitoring vs Linking
**Monitoring** provides one-way observation:
- A parent monitors a child.
- When the child exits, the parent receives an `EXIT` event.
- The parent continues running.
**Linking** creates bidirectional fate-sharing:
- A parent and child are linked.
- If either process exits abnormally, the other also terminates.
- Setting `trap_links=true` changes failures into events that the process can handle.
```mermaid
flowchart TB
subgraph Monitoring["MONITORING (one-way)"]
direction TB
P1[Parent monitors] -->|EXIT event
parent continues| C1[Child exits]
end
subgraph Linking["LINKING (bidirectional)"]
direction TB
P2[Parent linked] <-->|abnormal exit
fate sharing| C2[Child fails]
end
```
### Spawn with Monitoring
Use `process.spawn_monitored()` to spawn and monitor in one call:
```lua
local function main()
local events_ch = process.events()
-- Spawn worker and start monitoring
local worker_pid, err = process.spawn_monitored(
"app.workers:task_worker",
"app:processes"
)
if err then
return nil, "spawn failed: " .. tostring(err)
end
-- Wait for worker to complete
local event = events_ch:receive()
if event.kind == process.event.EXIT then
print("Worker exited:", event.from)
if event.result then
print("Result:", event.result.value)
end
if event.result and event.result.error then
print("Error:", event.result.error)
end
end
end
```
### Monitor Existing Process
Call `process.monitor()` to start monitoring an already-running process:
```lua
local function main()
local time = require("time")
local events_ch = process.events()
-- Spawn without monitoring
local worker_pid, err = process.spawn(
"app.workers:long_worker",
"app:processes"
)
if err then
return nil, "spawn failed: " .. tostring(err)
end
-- Start monitoring later
local ok, monitor_err = process.monitor(worker_pid)
if monitor_err then
return nil, "monitor failed: " .. tostring(monitor_err)
end
-- Cancel the worker
time.sleep("5ms")
local _, cancel_err = process.cancel(worker_pid)
if cancel_err then
return nil, "cancel failed: " .. tostring(cancel_err)
end
-- Receive EXIT event
local event = events_ch:receive()
if event.kind == process.event.EXIT then
print("Worker terminated:", event.from)
end
end
```
### Stop Monitoring
Use `process.unmonitor()` to stop receiving EXIT events:
```lua
local function main()
local time = require("time")
local events_ch = process.events()
-- Spawn and monitor
local worker_pid, err = process.spawn_monitored(
"app.workers:long_worker",
"app:processes"
)
if err then
return nil, "spawn failed: " .. tostring(err)
end
time.sleep("5ms")
-- Stop monitoring
local ok, unmon_err = process.unmonitor(worker_pid)
if unmon_err then
return nil, "unmonitor failed: " .. tostring(unmon_err)
end
-- Cancel worker
local _, cancel_err = process.cancel(worker_pid)
if cancel_err then
return nil, "cancel failed: " .. tostring(cancel_err)
end
-- No EXIT event will be received (we unmonitored)
local timeout = time.after("200ms")
local result = channel.select {
events_ch:case_receive(),
timeout:case_receive(),
}
if result.channel == events_ch then
return nil, "should not receive event after unmonitor"
end
end
```
### Explicit Linking
Use `process.link()` to create a bidirectional link:
```lua
-- Worker that links to a target process
local function worker_main()
local time = require("time")
local events_ch = process.events()
local inbox_ch = process.inbox()
-- Enable trap_links to receive LINK_DOWN events
local _, options_err = process.set_options({ trap_links = true })
if options_err then
return nil, "set_options failed: " .. tostring(options_err)
end
-- Receive target PID from sender
local msg = inbox_ch:receive()
local target_pid = msg:payload():data()
local sender = msg:from()
-- Create bidirectional link
local ok, err = process.link(target_pid)
if err then
return nil, "link failed: " .. tostring(err)
end
-- Notify sender we're linked
local _, send_err = process.send(sender, "linked", process.pid())
if send_err then
return nil, "confirmation failed: " .. tostring(send_err)
end
-- Wait for LINK_DOWN when target exits with an error
local timeout = time.after("3s")
local result = channel.select {
events_ch:case_receive(),
timeout:case_receive(),
}
if result.channel == events_ch then
local event = result.value
if event.kind == process.event.LINK_DOWN then
return "LINK_DOWN_RECEIVED"
end
end
return nil, "no LINK_DOWN received"
end
```
### Spawn with Link
Use `process.spawn_linked()` to spawn and link in one call:
```lua
local function parent_main()
-- Enable trap_links to handle child death
local _, options_err = process.set_options({ trap_links = true })
if options_err then
return nil, "set_options failed: " .. tostring(options_err)
end
local events_ch = process.events()
-- Spawn and link to child
local child_pid, err = process.spawn_linked(
"app.workers:child_worker",
"app:processes"
)
if err then
return nil, "spawn_linked failed: " .. tostring(err)
end
-- If the child exits with an error, we receive LINK_DOWN
local event = events_ch:receive()
if event.kind == process.event.LINK_DOWN then
print("Child died:", event.from)
end
end
```
The target or child must exit abnormally for these examples to receive
`LINK_DOWN`; the explicit-link example also requires that failure to occur within
its three-second wait window. Normal completion does not emit this event.
## Trap Links
By default, when a linked process fails, the current process also fails. Set `trap_links=true` to receive LINK_DOWN events instead.
### Default Behavior (trap_links=false)
Without `trap_links`, linked process failure terminates the current process:
```lua
local function worker_main()
local events_ch = process.events()
-- trap_links is false by default
local opts = process.get_options()
print("trap_links:", opts.trap_links) -- false
-- Spawn linked worker that will fail
local child_pid, err = process.spawn_linked(
"app.workers:error_worker",
"app:processes"
)
if err then
return nil, "spawn_linked failed: " .. tostring(err)
end
-- When child errors, THIS process terminates
-- We never reach this point
local event = events_ch:receive()
end
```
### With trap_links=true
Enable `trap_links` to receive LINK_DOWN events and survive:
```lua
local function worker_main()
-- Enable trap_links
local _, options_err = process.set_options({ trap_links = true })
if options_err then
return nil, "set_options failed: " .. tostring(options_err)
end
local events_ch = process.events()
-- Spawn linked worker that will fail
local child_pid, err = process.spawn_linked(
"app.workers:error_worker",
"app:processes"
)
if err then
return nil, "spawn_linked failed: " .. tostring(err)
end
-- Wait for LINK_DOWN event
local event = events_ch:receive()
if event.kind == process.event.LINK_DOWN then
print("Child failed, handling gracefully")
return "LINK_DOWN_RECEIVED"
end
end
```
### Send Cancel Signal
Use `process.cancel()` to request graceful cancellation from a process:
```lua
local function main()
local time = require("time")
local events_ch = process.events()
-- Spawn and monitor worker
local worker_pid, err = process.spawn_monitored(
"app.workers:long_worker",
"app:processes"
)
if err then
return nil, "spawn failed: " .. tostring(err)
end
time.sleep("5ms")
-- Cancel the worker
local ok, cancel_err = process.cancel(worker_pid)
if cancel_err then
return nil, "cancel failed: " .. tostring(cancel_err)
end
-- Wait for EXIT event
local event = events_ch:receive()
if event.kind == process.event.EXIT then
print("Worker cancelled:", event.from)
end
end
```
### Handle Cancellation
The worker receives the `CANCEL` event through `process.events()`:
`cleanup()` and `handle_message()` below are application callbacks that the recipe
does not define.
```lua
local function worker_main()
local events_ch = process.events()
local inbox_ch = process.inbox()
while true do
local result = channel.select {
inbox_ch:case_receive(),
events_ch:case_receive(),
}
if result.channel == events_ch then
local event = result.value
if event.kind == process.event.CANCEL then
-- Cleanup resources
cleanup()
return "cancelled gracefully"
end
else
-- Process inbox message
handle_message(result.value)
end
end
end
```
### Star Topology
A parent can coordinate multiple children that link back to it:
```lua
-- Parent worker spawns children that link TO parent
local function star_parent_main()
local time = require("time")
local events_ch = process.events()
local child_count = 10
-- Enable trap_links to see children die
local _, options_err = process.set_options({ trap_links = true })
if options_err then
error("set_options failed: " .. tostring(options_err))
end
local children = {}
-- Spawn children
for i = 1, child_count do
local child_pid, err = process.spawn(
"app.workers:linker_child",
"app:processes"
)
if err then
error("spawn child failed: " .. tostring(err))
end
-- Send parent PID to child
local _, send_err = process.send(child_pid, "inbox", process.pid())
if send_err then
error("send parent PID failed: " .. tostring(send_err))
end
children[child_pid] = true
end
-- Wait for all children to confirm link
for i = 1, child_count do
local msg = process.inbox():receive()
if msg:topic() ~= "linked" then
error("expected linked confirmation")
end
end
-- Trigger failure - all children should receive LINK_DOWN
error("PARENT_STAR_FAILURE")
end
```
Child worker that links to parent:
```lua
local function linker_child_main()
-- Enable trap_links to receive LINK_DOWN events
local _, options_err = process.set_options({ trap_links = true })
if options_err then
return nil, "set_options failed: " .. tostring(options_err)
end
local events_ch = process.events()
local inbox_ch = process.inbox()
-- Receive parent PID
local msg = inbox_ch:receive()
local parent_pid = msg:payload():data()
-- Link to parent
local _, link_err = process.link(parent_pid)
if link_err then
return nil, "link failed: " .. tostring(link_err)
end
-- Confirm link
local _, send_err = process.send(parent_pid, "linked", process.pid())
if send_err then
return nil, "confirmation failed: " .. tostring(send_err)
end
-- Wait for LINK_DOWN when parent dies
local event = events_ch:receive()
if event.kind == process.event.LINK_DOWN then
return "parent_died"
end
end
```
### Chain Topology
In a linear chain, each node links to its parent:
```lua
-- Chain root: A -> B -> C -> D -> E
local function chain_root_main()
local time = require("time")
-- Spawn first child
local child_pid, err = process.spawn_linked(
"app.workers:chain_node",
"app:processes",
4 -- depth remaining
)
if err then
error("spawn failed: " .. tostring(err))
end
-- Wait for chain to build
time.sleep("100ms")
-- Trigger cascade - all linked processes die
error("CHAIN_ROOT_FAILURE")
end
```
Chain node spawns next node and links:
```lua
local function chain_node_main(depth)
if depth > 0 then
-- Spawn next in chain
local child_pid, err = process.spawn_linked(
"app.workers:chain_node",
"app:processes",
depth - 1
)
if err then
error("spawn failed: " .. tostring(err))
end
end
-- Block until parent death kills us via LINK_DOWN (default trap_links=false)
process.inbox():receive()
end
```
### Configuration
```yaml
# src/_index.yaml
version: "1.0"
namespace: app
entries:
- name: supervision-policy
kind: security.policy
policy:
actions:
- process.host
- process.send
- process.spawn
- process.spawn.linked
resources: "*"
effect: allow
- name: processes
kind: process.host
host:
workers: 16
lifecycle:
auto_start: true
```
```yaml
# src/supervisor/_index.yaml
version: "1.0"
namespace: app.supervisor
entries:
- name: pool
kind: process.lua
source: file://pool.lua
method: main
modules:
- time
- name: pool-service
kind: process.service
process: app.supervisor:pool
host: app:processes
lifecycle:
auto_start: true
security:
actor:
id: "service:supervisor"
groups:
- app.security:supervisors
```
Strict mode is on by default, so a service that declares no security context is denied every check, including `process.spawn`. Grant the pool the actions it and its workers use:
```yaml
# src/security/_index.yaml
version: "1.0"
namespace: app.security
entries:
- name: supervisor_policy
kind: security.policy
policy:
actions:
- process.spawn
- process.spawn.linked
- process.host
- process.registry.register
- process.terminate
resources: "*"
effect: allow
groups:
- supervisors
```
Workers inherit the actor and scope of the pool that spawns them, so they need no block of their own.
### Supervisor Implementation
```lua
-- src/supervisor/pool.lua
local function main(worker_count)
local time = require("time")
worker_count = worker_count or 4
-- Enable trap_links to handle worker deaths
local _, options_err = process.set_options({ trap_links = true })
if options_err then
error("set_options failed: " .. tostring(options_err))
end
local events_ch = process.events()
local workers = {}
local function start_worker(id)
local pid, err = process.spawn_linked(
"app.workers:task_worker",
"app:processes",
id
)
if err then
print("Failed to start worker " .. id .. ": " .. tostring(err))
return nil
end
workers[pid] = {id = id, started_at = os.time()}
print("Worker " .. id .. " started: " .. pid)
return pid
end
-- Start initial pool
for i = 1, worker_count do
start_worker(i)
end
print("Supervisor started with " .. worker_count .. " workers")
-- Supervision loop
while true do
local timeout = time.after("60s")
local result = channel.select {
events_ch:case_receive(),
timeout:case_receive(),
}
if result.channel == timeout then
-- Periodic health check
local count = 0
for _ in pairs(workers) do count = count + 1 end
print("Health check: " .. count .. " active workers")
elseif result.channel == events_ch then
local event = result.value
if event.kind == process.event.CANCEL then
return "supervisor stopped"
end
if event.kind == process.event.LINK_DOWN then
local dead_worker = workers[event.from]
if dead_worker then
workers[event.from] = nil
local uptime = os.time() - dead_worker.started_at
print("Worker " .. dead_worker.id .. " died after " .. uptime .. "s, restarting")
-- Brief delay before restart
time.sleep("100ms")
start_worker(dead_worker.id)
end
end
end
end
end
return { main = main }
```
### Worker Definition
```yaml
# src/workers/_index.yaml
version: "1.0"
namespace: app.workers
entries:
- name: task_worker
kind: process.lua
source: file://task_worker.lua
method: main
modules:
- time
security:
actor:
id: app.workers:task_worker
policies:
- app:supervision-policy
```
### Worker Implementation
```lua
-- src/workers/task_worker.lua
local function main(worker_id)
local time = require("time")
local events_ch = process.events()
local inbox_ch = process.inbox()
-- Register under a name so other processes can reach this worker
process.registry.register("worker-" .. worker_id)
print("Task worker " .. worker_id .. " started")
while true do
local timeout = time.after("5s")
local result = channel.select {
inbox_ch:case_receive(),
events_ch:case_receive(),
timeout:case_receive(),
}
if result.channel == events_ch then
local event = result.value
if event.kind == process.event.CANCEL then
print("Worker " .. worker_id .. " cancelled")
return "cancelled"
elseif event.kind == process.event.LINK_DOWN then
print("Worker " .. worker_id .. " linked process died")
return nil, "linked_process_died"
end
elseif result.channel == inbox_ch then
local msg = result.value
local topic = msg:topic()
local payload = msg:payload():data()
if topic == "work" then
print("Worker " .. worker_id .. " processing: " .. payload)
time.sleep("100ms")
local _, send_err = process.send(msg:from(), "result", "completed: " .. payload)
if send_err then
return nil, "send result failed: " .. tostring(send_err)
end
end
elseif result.channel == timeout then
-- Idle timeout
print("Worker " .. worker_id .. " idle")
end
end
end
return { main = main }
```
## Process Host Settings
The `app:processes` entry defined in [Configuration](#configuration) uses the
following host setting:
```yaml
# Within the app:processes entry in src/_index.yaml
host:
workers: 16 # Worker goroutines (default: NumCPU)
```
The `workers` setting:
- Controls parallelism for CPU-bound work.
- Is typically set to the number of CPU cores.
- Applies to the scheduler pool shared by all processes on the host.
## Event Types
| Event | Triggered By | Required Setup |
|-------|--------------|----------------|
| `EXIT` | Monitored process exits | `spawn_monitored()` or `monitor()` |
| `LINK_DOWN` | Linked process fails | `spawn_linked()` or `link()` with `trap_links=true` |
| `CANCEL` | `process.cancel()` called | The target consumes `process.events()` |
## Using the Supervisor Pool Recipe
The displayed pool starts and supervises workers, but it is not a complete runnable
tutorial: it intentionally omits a control process, that process's termination
policy, and a deterministic assertion of the restart. After incorporating the recipe
into an application, initialize and run that application normally:
```bash
wippy init
wippy run
```
The supervisor autostarts, spawns four workers, and logs `Worker N started` for each. `LINK_DOWN` is only delivered when a linked process exits with an error, so trigger a restart by forcefully terminating a worker. The terminating code has to run inside the same runtime, so add it as an ad-hoc service:
```yaml
# src/chaos/_index.yaml
version: "1.0"
namespace: app.chaos
entries:
- name: killer
kind: process.lua
source: file://killer.lua
method: main
modules:
- time
- name: killer-service
kind: process.service
process: app.chaos:killer
host: app:processes
lifecycle:
auto_start: true
security:
actor:
id: "service:chaos"
groups:
- app.security:supervisors
```
```lua
-- src/chaos/killer.lua
local function main()
local time = require("time")
time.sleep("2s")
process.terminate("worker-1")
return "terminated"
end
return { main = main }
```
Run `wippy init` again to pick up the new entries, then `wippy run`. Two seconds in, the pool receives `LINK_DOWN`, waits 100 ms, and respawns the worker under the same id:
```
INFO Worker 1 died after 2s, restarting
INFO Worker 1 started: {...@app:processes|0x00009}
INFO Task worker 1 started
```
A graceful `process.cancel()` lets the worker exit cleanly, which does not raise `LINK_DOWN` and therefore does not trigger a restart — that is why the supervision loop returns on `CANCEL` rather than treating shutdown as a worker failure.
## Next Steps
- [Processes](tutorials/processes.md) — Process fundamentals
- [Channels](tutorials/channels.md) — Message-passing patterns
- [Process Module](lua/core/process.md) — Process API reference
## Navigation
Previous: "Processes and Messaging Primer" (tutorials/processes)
Next: "Task Queue" (tutorials/task-queue)