Introduction
Welcome to the documentation for smearor-wrot-process-manager.
This workspace provides shared socket and process management crates for the smearor-wrot Wayland compositor and the smearor-swipe-launcher desktop launcher.
Why smearor-wrot-process-manager?
Both smearor-wrot and smearor-swipe-launcher need to spawn and manage child processes - compositor clients, terminal commands, desktop applications. Previously, each project had its own ad-hoc process tracking:
smearor-wrotused alaunch_application()function that spawned a child, setWAYLAND_DISPLAY, and returned aChildhandle - but had no tracking, no reaper, and no graceful shutdown.smearor-swipe-launcherused aTrackedProcessstruct withDashMap-based tracking, manual/proc/{pid}polling for exit detection, andnix::sys::signal::killfor termination - duplicated across bothterminal_commandandapp-launcherservices.
This workspace consolidates that logic into two reusable, framework-agnostic crates:
process-manager-socket- Wayland socket path management withSocket,SocketBuilder, andSocketManagerprocess-manager- Child process lifecycle management withProcessConfig,ProcessManager, and an optional reaper thread
Key Benefits
- No duplicate code - Both projects share the same
ProcessManagerinstead of maintaining separate tracking logic - Zombie prevention - The reaper thread calls
try_wait()on all tracked processes, preventing zombies without per-process wait threads - Graceful shutdown -
terminate_on_exitflag ensures processes are killed when the manager is dropped - Signal escalation -
SIGTERMwith configurable timeout, automaticSIGKILLescalation for stubborn processes - Explicit lifecycle states -
ProcessStateenum (Starting,Running,Stopping,Stopped,Crashed,Restarting,Failed) for precise decision-making - Label-based grouping - Start and stop multiple processes under a shared label (e.g. all workers in a pool)
- Forked/detached support -
setsid()viapre_execfor processes that should survive parent exit - Wayland socket binding - Automatically sets
WAYLAND_DISPLAYin child environment - Framework-agnostic - No dependency on GTK, Smithay, or any plugin API
Crate Relationship
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef crate fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef consumer fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000000
Socket["process-manager-socket"]
Process["process-manager"]
Wrot["smearor-wrot"]
Launcher["smearor-swipe-launcher"]
Process -->|depends on| Socket
Wrot -->|uses| Socket
Wrot -->|uses| Process
Launcher -->|uses| Process
class Socket crate
class Process crate
class Wrot consumer
class Launcher consumer
Consumers
smearor-wrot- UsesSocketManagerfor multi-output Wayland sockets andProcessManagerfor spawning compositor clients.smearor-swipe-launcher- UsesProcessManagerin itsterminal_commandandapp-launcherservices for launching and tracking commands and applications.
Getting Started
Head over to the Architecture page for a visual overview of how the crates work internally. For code examples, see Usage Examples. To migrate from the old approach, see the Migration Guide.
License
MIT
Quick Start
Get started with smearor-wrot-process-manager in a few minutes.
Installation
Add the crates to your Cargo.toml:
[dependencies]
process-manager = { git = "https://github.com/smearor/smearor-wrot-process-manager" }
# Optional: only if you need Wayland socket management
process-manager-socket = { git = "https://github.com/smearor/smearor-wrot-process-manager" }
Minimal Example
Spawn a child process, let it run, and stop it:
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["5".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("sleeper", &config)?;
println!("Started process with ID {}", id);
// Check if it's running
let process = manager.get(id).unwrap();
println!("Running: {}", process.is_running());
drop(process);
// Stop it
manager.stop(id)?;
println!("Stopped");
Ok(())
}
With Reaper and Restart
Spawn a process that auto-restarts on exit:
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::Error::Error>> {
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
let config = ProcessConfig::builder()
.command("true".to_string())
.restart_on_exit(true)
.stdout(StdioConfig::Null)
.build();
manager.start("service", &config)?;
// Wait for exit and restart
let event = receiver.recv_timeout(Duration::from_secs(10))?;
println!("Process {} exited, restarting...", event.label);
if event.restart_on_exit {
manager.start(&event.label, &config)?;
}
Ok(())
}
With Wayland Socket
Spawn a Wayland client bound to a specific socket:
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use process_manager_socket::SocketBuilder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = ProcessManager::new();
let socket = SocketBuilder::build(&None)?;
let config = ProcessConfig::builder()
.command("gtk4-demo".to_string())
.socket(Some(socket))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("wayland-client", &config)?;
println!("Started Wayland client with ID {}", id);
Ok(())
}
Next Steps
- Architecture - Visual overview of crate internals and design decisions
- ProcessConfig - Full configuration reference
- ProcessManager - Complete API documentation
- Usage Examples - More practical examples
- Migration Guide - Migrating from old patterns
Architecture
smearor-wrot-process-manager achieves its socket and process management through two crates working together with DashMap-based concurrent tracking and an optional reaper thread.
Overview
The workspace is composed of two crates:
process-manager-socket- Wayland socket path management withSocket,SocketBuilder, andSocketManagerprocess-manager- Child process lifecycle management withProcessConfig,ProcessManager, and reaper thread
Crate Relationships
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef socket fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef process fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef consumer fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000000
Socket["process-manager-socket<br/><small>Socket, SocketBuilder, SocketManager</small>"]
Process["process-manager<br/><small>ProcessConfig, ProcessManager, Reaper</small>"]
Wrot["smearor-wrot<br/><small>Compositor + clients</small>"]
Launcher["smearor-swipe-launcher<br/><small>terminal_command + app-launcher</small>"]
Process -->|depends on| Socket
Wrot -->|uses| Socket
Wrot -->|uses| Process
Launcher -->|uses| Process
class Socket socket
class Process process
class Wrot consumer
class Launcher consumer
Core Components
1. Socket Management (process-manager-socket)
The socket crate provides three main types:
Socket- APathBufnewtype representing a Wayland socket path. ImplementsDeref<Target = Path>,Display,AsRef<OsStr>, andAsRef<str>.SocketBuilder- Constructs socket paths inXDG_RUNTIME_DIR. If a name is provided, it validates uniqueness. If no name is provided, it auto-generates a unique name likewayland-{N}.SocketManager- A concurrent multi-socket manager usingDashMap. Sockets are registered by name and can be retrieved, removed, or listed. Shareable viaArcacross threads.
2. Process Management (process-manager)
The process crate provides the ProcessManager as its central component:
ProcessConfig- Built viaTypedBuilder. Contains all configuration for spawning a child: command, args, env, working_dir, shell mode, forked mode, terminate_on_exit, kill_signal, terminate_timeout, restart_on_exit, stdio config, and optional Wayland socket.ProcessManager- Tracks child processes in aDashMapkeyed byProcessId. Supports label-based grouping, concurrent start/stop operations, and an optional reaper thread.Process- A handle to a managed child process. Contains theProcessId, PID, label, config, and thestd::process::Childhandle.ProcessExitEvent- Emitted by the reaper thread when a process exits. Containsid,pid,label,restart_on_exit,exit_status, andstate(StoppedorCrashed).
Process Lifecycle
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef config fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef manager fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef state fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
classDef terminal fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
A["ProcessConfig::builder()"] --> B["manager.start(label, &config)"]
B --> C["Process spawned<br/>tracked in DashMap"]
C --> D{Process exits?}
D -->|No| E["Running<br/>state() == Running"]
D -->|Yes, reaper active| F["ProcessExitEvent<br/>state: Stopped/Crashed<br/>sent via mpsc channel"]
D -->|Yes, no reaper| G["Zombie until<br/>stop() or drop()"]
E --> H["manager.stop(id)"]
H --> I["SIGTERM sent"]
I --> J{Process exited<br/>within timeout?}
J -->|Yes| K["Removed from DashMap"]
J -->|No| L["SIGKILL escalation"]
L --> K
F --> K
G --> K
class A config
class B manager
class C state
class E state
class F state
class G state
class H manager
class I terminal
class L terminal
class K state
Reaper Thread Architecture
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef thread fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef map fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef event fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
classDef consumer fill: #04e762, stroke: #333333, stroke-width: 1px, color: #000
A["Reaper Thread<br/><small>process-reaper</small>"] -->|poll every N ms| B["Iterate DashMap"]
B --> C{"try_wait() on<br/>each process"}
C -->|Still running| D["Skip"]
C -->|Exited| E["Remove from DashMap"]
E --> F["Send ProcessExitEvent<br/>via mpsc::Sender"]
F --> G["Consumer receives<br/>via mpsc::Receiver"]
G --> H{"Consumer type"}
H -->|Sync| I["receiver.recv()<br/>or try_recv()"]
H -->|GTK/Async| J["Forwarding thread<br/>std → tokio::mpsc"]
J --> K["MainContext::spawn_local<br/>async event handling"]
class A thread
class B map
class C map
class D map
class E map
class F event
class G consumer
class H consumer
class I consumer
class J consumer
class K consumer
Termination Flow
sequenceDiagram
participant Consumer
participant Manager as ProcessManager
participant Process as Child Process
participant Reaper as Reaper Thread
Consumer->>Manager: stop(id)
Manager->>Process: send SIGTERM (or SIGKILL)
alt KillSignal::Sigterm
Manager->>Manager: wait terminate_timeout_ms
alt Process exits within timeout
Process-->>Manager: try_wait() returns exit status
Manager->>Manager: remove from DashMap
else Process still running
Manager->>Process: send SIGKILL
Process-->>Manager: process killed
Manager->>Manager: remove from DashMap
end
else KillSignal::Sigkill
Process-->>Manager: process killed immediately
Manager->>Manager: remove from DashMap
end
opt Reaper thread active
Reaper->>Reaper: try_wait() detects exit
Reaper-->>Consumer: ProcessExitEvent
end
Drop Behavior
When ProcessManager is dropped, the following sequence occurs:
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef action fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef terminal fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef done fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
A["ProcessManager::drop()"] --> B{"Reaper thread<br/>active?"}
B -->|Yes| C["Set stop_flag<br/>join thread"]
B -->|No| D["Skip"]
C --> E["Iterate all processes"]
D --> E
E --> F{"terminate_on_exit<br/>== true?"}
F -->|Yes| G["Send SIGTERM<br/>wait timeout<br/>escalate to SIGKILL"]
F -->|No| H["Leave running<br/>(detach)"]
G --> I["Drop complete"]
H --> I
class A action
class C action
class E action
class G terminal
class I done
Design Decisions
Why split socket and process into separate crates?
Socket management is a lightweight concern (path manipulation, DashMap for multi-socket support). Process management is heavier (child spawning, signal handling, reaper threads). Separating them allows consumers to depend on only what they need - smearor-swipe-launcher uses only process-manager without needing the socket crate directly.
Why DashMap instead of Mutex<HashMap>?
Both SocketManager and ProcessManager are accessed concurrently from multiple threads. DashMap provides shard-level locking, avoiding the contention of a single Mutex. The reaper thread iterates processes while the main thread may start/stop others - DashMap handles this without blocking.
Why std::sync::mpsc for reaper events?
The reaper thread is a plain std::thread, not async. std::sync::mpsc::Sender is Send + Sync and integrates cleanly with both sync and async consumers. In GTK applications, a forwarding thread bridges the blocking recv() to a non-blocking tokio::sync::mpsc channel for use in MainContext::spawn_local. This avoids blocking the GTK main loop while keeping the reaper thread simple.
Why typed-builder for ProcessConfig?
ProcessConfig has many fields with sensible defaults. typed-builder enforces required fields (only command) at compile time while keeping optional fields ergonomic with .field_name(value) syntax. This prevents missing-field bugs without runtime validation.
Why not tokio::process::Child?
The ProcessManager is deliberately synchronous. It works with GTK’s MainContext and Smithay’s event loop without requiring an async runtime. The reaper thread uses non-blocking try_wait() polling instead - simpler, no runtime dependency, and sufficient for the use case (exit detection with configurable latency).
Why setsid() instead of double-fork?
setsid() detaches the process from the controlling terminal. A double-fork (grandchild) would lose the Child handle, preventing tracking and try_wait() reaping. With setsid(), the process is still a direct child - the Child handle is stored, try_wait() works, and stop() can send signals. This is the correct trade-off for a process manager that needs to track and terminate its children.
process-manager-socket
Wayland socket path management crate.
Overview
Provides types for building, registering, and managing Wayland socket paths. Sockets are created in XDG_RUNTIME_DIR and can be shared across threads via Arc<SocketManager>.
In a Wayland compositor, each output may need its own socket. SocketManager allows registering multiple sockets by name (e.g. "default", "hdmi-1") and retrieving them when spawning compositor clients. The Socket type is a lightweight PathBuf newtype that can be passed to ProcessConfig::socket() to automatically set WAYLAND_DISPLAY in a child process’s environment.
Types
Socket-PathBufnewtype withpath()accessor,Deref<Target = Path>,Display,AsRef<OsStr>,AsRef<str>,Serialize/DeserializeimplementationsSocketBuilder- Builds socket paths inXDG_RUNTIME_DIR, validates existing names or generates unique onesSocketManager- Multi-socket manager usingDashMap, shareable viaArcacross threadsSocketBuilderError- Error type for socket construction failuresSocketManagerError- Error type for socket management operations
Architecture
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef builder fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef manager fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef socket fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
A["SocketBuilder::build()"] --> B["Socket"]
B --> C["SocketManager::register()"]
C --> D["SocketManager<br/><small>DashMap<String, Socket></small>"]
D --> E["SocketManager::get()"]
E --> F["Socket"]
F --> G["ProcessConfig::socket()"]
class A builder
class B socket
class C manager
class D manager
class E manager
class F socket
class G builder
Dependencies
dashmap- concurrent map forSocketManagerthiserror- error typesserde-Serialize/DeserializeforSocket
Usage
#![allow(unused)]
fn main() {
use process_manager_socket::{SocketBuilder, SocketManager};
use std::sync::Arc;
// Build a socket (auto-generates unique name if None)
let socket = SocketBuilder::build(&None)?;
// Register in manager
let manager = Arc::new(SocketManager::new());
manager.register("default", socket)?;
// Retrieve by name
let socket = manager.get("default");
assert!(socket.is_some());
}
See the individual pages for detailed documentation:
Socket Type
Socket is a PathBuf newtype representing a Wayland socket path.
It exists as a distinct type rather than using PathBuf directly for two reasons:
- Type safety - Functions that expect a Wayland socket can take
&Socketinstead of&Path, preventing accidental misuse with arbitrary paths. - Ergonomic trait implementations -
AsRef<str>andDisplaymake it easy to extract the socket name (e.g.wayland-0) for settingWAYLAND_DISPLAYin child environments.
Accessing the Path
The inner PathBuf is private. Use one of these methods to access it:
| Method / Trait | Returns | Purpose |
|---|---|---|
path() | &Path | Direct accessor for the socket path |
Deref<Target = Path> | &Path | Implicit deref for filesystem operations |
Display | String | Format the path as a string |
AsRef<OsStr> | &OsStr | Interop with std::ffi::OsStr |
AsRef<str> | &str | String access for environment variable names |
From<PathBuf> | Socket | Construct from a PathBuf |
Serde
Socket implements Serialize and Deserialize, making it suitable for JSON/TOML configuration files.
Usage
#![allow(unused)]
fn main() {
use process_manager_socket::Socket;
use std::path::PathBuf;
// Construct from a path
let socket = Socket::from(PathBuf::from("/run/user/1000/wayland-0"));
// Display the full path
println!("{}", socket); // /run/user/1000/wayland-0
// Access as &Path via path() or Deref
let path = socket.path();
assert!(path.exists());
// Access as &str via AsRef
let socket_str: &str = socket.as_ref();
assert_eq!(socket_str, "/run/user/1000/wayland-0");
}
How It’s Used by ProcessManager
When a Socket is passed to ProcessConfig::socket(Some(socket)), the ProcessManager::start() method extracts the socket name (the last path component) and sets it as the WAYLAND_DISPLAY environment variable in the child process. This allows the child to connect to the correct Wayland display.
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use process_manager_socket::Socket;
use std::path::PathBuf;
let socket = Socket::from(PathBuf::from("/run/user/1000/wayland-1"));
let config = ProcessConfig::builder()
.command("gtk4-app".to_string())
.socket(Some(socket))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
// The child process will have WAYLAND_DISPLAY=wayland-1
let id = manager.start("wayland-client", &config)?;
}
SocketBuilder
SocketBuilder constructs socket paths in XDG_RUNTIME_DIR.
Behavior
SocketBuilder::build() takes an optional name and returns a Socket:
- If a name is provided (
Some("wayland-0")), it validates that no socket file with that name already exists inXDG_RUNTIME_DIR. - If no name is provided (
None), it generates a unique name by incrementing a counter (wayland-0,wayland-1,wayland-2, …) until an unused name is found. - The socket path is constructed as
XDG_RUNTIME_DIR/{name}.
Build Flow
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef input fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef check fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef output fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
classDef error fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
A["SocketBuilder::build(&name)"] --> B{"name provided?"}
B -->|Some name| C{"Socket exists<br/>in XDG_RUNTIME_DIR?"}
B -->|None| D["Generate unique name<br/>wayland-0, wayland-1, ..."]
C -->|No| E["Construct path<br/>XDG_RUNTIME_DIR/name"]
C -->|Yes| F["SocketBuilderError<br/>::SocketAlreadyExists"]
D --> E
E --> G["Return Socket"]
class A input
class B check
class C check
class D check
class E output
class F error
class G output
Usage
#![allow(unused)]
fn main() {
use process_manager_socket::SocketBuilder;
// Auto-generate unique name (recommended)
let socket = SocketBuilder::build(&None)?;
// e.g. /run/user/1000/wayland-0
// Use a specific name
let socket = SocketBuilder::build(&Some("wayland-1".to_string()))?;
// /run/user/1000/wayland-1
}
Errors
| Error | When |
|---|---|
SocketBuilderError::XdgRuntimeDirNotSet | XDG_RUNTIME_DIR environment variable is not set |
SocketBuilderError::SocketAlreadyExists | A socket file with the given name already exists in XDG_RUNTIME_DIR |
SocketManager
SocketManager manages multiple Wayland sockets concurrently using DashMap.
Overview
In a multi-output Wayland compositor, each output may have its own socket. SocketManager provides a concurrent registry for named sockets, allowing the compositor to:
- Register sockets by name (
"default","hdmi-1", etc.) - Retrieve sockets when spawning compositor clients
- Share socket references across threads via
Arc
The DashMap backend allows concurrent reads and writes without a single Mutex lock - multiple threads can register and retrieve sockets simultaneously.
register() uses the DashMap entry API for atomic check-and-insert, preventing TOCTOU race conditions when multiple threads register sockets concurrently.
API
| Method | Returns | Description |
|---|---|---|
new() | SocketManager | Create an empty manager |
register(name, socket) | Result<(), SocketManagerError> | Register a socket by name |
get(name) | Option<Socket> | Retrieve a socket by name |
remove(name) | Result<(), SocketManagerError> | Remove a socket by name |
names() | Vec<String> | List all registered socket names |
sockets() | Vec<Socket> | List all registered sockets |
is_empty() | bool | Check if manager has no sockets |
len() | usize | Number of registered sockets |
Concurrency Model
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef thread fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef map fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef socket fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
A["Thread 1<br/>Compositor main"] --> B["SocketManager<br/><small>DashMap<String, Socket></small>"]
C["Thread 2<br/>Client spawner"] --> B
D["Thread 3<br/>Output handler"] --> B
B --> E["register('default', socket)"]
B --> F["get('hdmi-1')"]
B --> G["remove('old-output')"]
class A thread
class C thread
class D thread
class B map
class E socket
class F socket
class G socket
Usage
Basic
#![allow(unused)]
fn main() {
use process_manager_socket::{SocketBuilder, SocketManager};
let manager = SocketManager::new();
let socket = SocketBuilder::build(&None)?;
manager.register("default", socket)?;
let socket = manager.get("default");
assert!(socket.is_some());
}
Multi-output with Arc
#![allow(unused)]
fn main() {
use process_manager_socket::{SocketBuilder, SocketManager};
use std::sync::Arc;
let manager = Arc::new(SocketManager::new());
// Register sockets for each output
manager.register("default", SocketBuilder::build(&Some("wayland-0".to_string()))?)?;
manager.register("hdmi-1", SocketBuilder::build(&Some("wayland-1".to_string()))?)?;
// Share across threads
let manager_clone = Arc::clone(&manager);
std::thread::spawn(move || {
let socket = manager_clone.get("hdmi-1").unwrap();
// Use socket for spawning a client on hdmi-1
});
// List all sockets
let names = manager.names();
assert_eq!(names.len(), 2);
}
Errors
| Error | When |
|---|---|
SocketManagerError::AlreadyRegistered | A socket with the given name has already been registered |
Error Types
SocketBuilderError
Errors that can occur when building a socket path via SocketBuilder::build().
| Variant | When | Description |
|---|---|---|
XdgRuntimeDirNotSet | build() | XDG_RUNTIME_DIR environment variable is not set - the user session may not be properly initialized |
SocketAlreadyExists | build() | A socket file with the given name already exists in XDG_RUNTIME_DIR - only returned when a specific name is requested |
SocketManagerError
Errors that can occur when managing sockets in SocketManager.
| Variant | When | Description |
|---|---|---|
AlreadyRegistered | register() | A socket with the given name has already been registered in this SocketManager instance |
Usage
#![allow(unused)]
fn main() {
use process_manager_socket::{SocketBuilder, SocketBuilderError};
match SocketBuilder::build(&Some("wayland-0".to_string())) {
Ok(socket) => println!("Socket: {}", socket),
Err(SocketBuilderError::XdgRuntimeDirNotSet) => {
eprintln!("XDG_RUNTIME_DIR is not set");
}
Err(SocketBuilderError::SocketAlreadyExists) => {
eprintln!("Socket wayland-0 already exists");
}
Err(e) => eprintln!("Error: {}", e),
}
}
process-manager
Child process lifecycle management with Wayland socket binding.
Overview
Provides a ProcessManager for spawning, tracking, and terminating child processes. Supports label-based grouping, forked/detached processes, and an optional reaper thread for zombie prevention and exit notifications.
This crate is used by both smearor-wrot (to spawn compositor clients like panels, wallpapers, and widgets) and smearor-swipe-launcher (to launch terminal commands and desktop applications). It replaces the ad-hoc launch_application() function in wrot and the duplicated TrackedProcess struct in swipe-launcher.
Types
ProcessConfig- Unified configuration viaTypedBuilder(command, args, env, working_dir, shell, forked, terminate_on_exit, kill_signal, restart_on_exit, stdio, socket)ProcessManager- Concurrent process tracking viaDashMap, label-based grouping, optional reaper threadProcess/ProcessId/ProcessInfo- Process handle, unique identifier, and lightweight snapshot typeProcessState- Explicit lifecycle state enum (Starting,Running,Stopping,Stopped,Crashed,Restarting,Failed)ProcessExitEvent- Reaper exit notification withid,pid,label,restart_on_exit,exit_status,stateStdioConfig- Inherit/Null/Piped enum for standard streamsKillSignal- Sigterm/Sigkill enum for termination configSignal- Broader signal enum (SIGHUP, SIGUSR1, SIGSTOP, etc.) for general process controlProcessManagerError/ProcessConfigError- Error types
Architecture
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef config fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef manager fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef reaper fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef process fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
classDef event fill: #04e762, stroke: #333333, stroke-width: 1px, color: #000
A["ProcessConfig<br/><small>TypedBuilder</small>"] --> B["ProcessManager::start()"]
B --> C["Process<br/><small>stored in DashMap</small>"]
B --> D{"forked?"}
D -->|Yes| E["setsid() in pre_exec"]
D -->|No| F["Normal spawn"]
E --> C
F --> C
G["Reaper Thread<br/><small>optional</small>"] -->|"try_wait() poll"| C
C -->|exited| H["ProcessExitEvent<br/>via mpsc channel"]
H --> I["Consumer<br/>restart/status logic"]
class A config
class B manager
class C process
class D manager
class E manager
class F manager
class G reaper
class H event
class I event
Features
ProcessConfigwithTypedBuilder- Compile-time enforcement of required fields, ergonomic optional fields- Label-based grouping - Start/stop multiple processes under a shared label
- Forked/detached processes -
setsid()viapre_execfor terminal detachment - Reaper thread - Non-blocking
try_wait()polling withProcessExitEventchannel - Signal escalation -
SIGTERMwith configurable timeout, automaticSIGKILLescalation - Wayland socket binding - Sets
WAYLAND_DISPLAYfromSocket StdioConfig- Inherit/Null/Piped with reader threads for output captureKillSignal-Sigterm/Sigkillwith serde support for termination configSignal- Broader signal enum for general process control viasend_signal()- Restart -
restart()/restart_label()preserve config and label across restarts - Serde -
ProcessConfig,StdioConfig,KillSignal, andSignalimplementSerialize/Deserialize - Executable resolution -
whichintegration for PATH lookup - Graceful shutdown -
terminate_on_exitflag kills processes on drop #[must_use]- All public structs and enums are#[must_use]
Dependencies
| Crate | Purpose |
|---|---|
dashmap | Concurrent process tracking |
nix | Signal handling (SIGTERM, SIGKILL) |
libc | setsid() for forked processes |
typed-builder | ProcessConfig builder pattern |
which | Executable path resolution |
process-manager-socket | Wayland socket binding |
thiserror | Error types |
tracing | Logging |
See the individual pages for detailed documentation:
- ProcessConfig
- ProcessManager
- Process & ProcessId
- ProcessState
- StdioConfig
- Signal
- Reaper Thread
- ProcessExitEvent
- Error Types
ProcessConfig
ProcessConfig is the unified configuration for spawning a child process, built via TypedBuilder.
Overview
ProcessConfig encapsulates everything ProcessManager::start() needs to spawn a child process:
- The command to run and its arguments
- Environment variables and working directory
- Process behavior flags (forked, shell, terminate_on_exit, restart_on_exit)
- Signal configuration (kill_signal, terminate_timeout)
- Standard I/O configuration (stdin, stdout, stderr)
- Optional Wayland socket binding
The TypedBuilder pattern enforces required fields at compile time - only command is required. All other fields have sensible defaults.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
command | String | (required) | Program name or path. Resolved via which if not absolute. |
args | Vec<String> | vec![] | Command-line arguments passed to the program |
env | HashMap<String, String> | HashMap::new() | Additional environment variables merged into the child’s environment |
working_dir | Option<PathBuf> | None | Working directory for the child process |
shell | bool | false | Run command via sh -c instead of direct execution |
forked | bool | false | Detach via setsid() in pre_exec - process gets its own session |
terminate_on_exit | bool | false | Kill this process when ProcessManager is dropped |
kill_signal | KillSignal | Sigterm | Signal to send on termination (Sigterm or Sigkill) |
terminate_timeout_ms | u64 | 5000 | Grace period (ms) before escalating from SIGTERM to SIGKILL |
restart_on_exit | bool | false | Enable automatic restart on exit (requires reaper thread) |
restart_trigger | RestartTrigger | CrashOnly | When to restart: CrashOnly (non-zero exit) or Always (any exit) |
restart_policy | RestartPolicy | Immediate | Restart strategy: Immediate or Backoff(BackoffConfig) |
supervisor_strategy | SupervisorStrategy | OneForOne | Controls which processes are restarted on crash: OneForOne, OneForAll, or RestForOne |
depends_on | Vec<DependencyRef> | vec![] | Dependencies to wait for before starting. DependencyRef::Label or DependencyRef::Id |
dependency_timeout_ms | u64 | 30000 | Timeout (ms) for dependencies to become Running before failing |
cascade_stop | bool | true | When true, stopping a process also stops its dependents |
stdin | StdioConfig | Null | Standard input configuration |
stdout | StdioConfig | Null | Standard output configuration |
stderr | StdioConfig | Null | Standard error configuration |
socket | Option<Socket> | None | Wayland socket - sets WAYLAND_DISPLAY in child environment |
Builder Flow
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef required fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef optional fill: #00a1e4, stroke: #ffffff, stroke-width: 1px, color: #ffffff
classDef build fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
A[".command()"] --> B[".args()"]
B --> C[".env()"]
C --> D[".forked()"]
D --> E[".kill_signal()"]
E --> F[".stdout()"]
F --> G[".socket()"]
G --> H[".build()"]
class A required
class B optional
class C optional
class D optional
class E optional
class F optional
class G optional
class H build
Usage
Minimal
#![allow(unused)]
fn main() {
use process_manager::ProcessConfig;
let config = ProcessConfig::builder()
.command("echo".to_string())
.build();
}
Full
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, StdioConfig, KillSignal, RestartPolicy, RestartTrigger, BackoffConfig};
use process_manager_socket::Socket;
use std::path::PathBuf;
use std::collections::HashMap;
let mut env = HashMap::new();
env.insert("MY_VAR".to_string(), "value".to_string());
let config = ProcessConfig::builder()
.command("my-app".to_string())
.args(vec!["--verbose".to_string(), "--port".to_string(), "8080".to_string()])
.env(env)
.working_dir(PathBuf::from("/tmp"))
.shell(false)
.forked(true)
.terminate_on_exit(true)
.kill_signal(KillSignal::Sigterm)
.terminate_timeout_ms(3000)
.restart_on_exit(true)
.restart_trigger(RestartTrigger::CrashOnly)
.restart_policy(RestartPolicy::Backoff(BackoffConfig::default()))
.stdin(StdioConfig::Null)
.stdout(StdioConfig::Piped)
.stderr(StdioConfig::Piped)
.socket(Some(Socket::from(PathBuf::from("/run/user/1000/wayland-0"))))
.build();
}
Defaults Explained
shell = false: Direct execution is safer and faster. Useshell = trueonly when you need shell features (pipes, redirects, variable expansion).forked = false: By default, processes share the parent’s controlling terminal. Useforked = truefor daemons that should survive terminal close.terminate_on_exit = false: By default, processes are left running when the manager is dropped. Usetruefor processes that should be cleaned up with the manager.kill_signal = Sigterm: Graceful termination by default. The process can catchSIGTERMand clean up.terminate_timeout_ms = 5000: 5 seconds grace period beforeSIGKILL. Adjust for processes that need more cleanup time.restart_on_exit = false: By default, processes are not automatically restarted. Enable withrestart_on_exit(true)and use the reaper thread for automatic restart with backoff.restart_trigger = CrashOnly: By default, only crashed processes (non-zero exit) are restarted. UseAlwaysto also restart on clean exits.restart_policy = Immediate: By default, restarts happen immediately. UseBackoff(BackoffConfig)for exponential backoff with rate limiting.stdio = Null: All streams are null by default, suitable for background processes. UsePipedfor output capture orInheritfor debugging.socket = None: No Wayland binding by default. Set when spawning Wayland clients.
Serde
ProcessConfig implements Serialize and Deserialize, making it suitable for JSON/TOML configuration files. All nested types (StdioConfig, KillSignal, Socket) also implement serde traits.
ProcessManager
ProcessManager manages child processes concurrently using DashMap.
Overview
ProcessManager is the central component of the process-manager crate. It provides:
- Concurrent tracking - All processes are stored in a
DashMapkeyed byProcessId, allowing concurrent access from multiple threads - Label-based grouping - Multiple processes can share a label for grouped start/stop operations
- Optional reaper thread - A background thread that polls
try_wait()to detect exits and emitProcessExitEvents - Signal-based termination -
SIGTERMwith configurable timeout, automaticSIGKILLescalation - Graceful shutdown - Processes with
terminate_on_exit = trueare killed when the manager is dropped
Construction
new() - Without reaper
#![allow(unused)]
fn main() {
use process_manager::ProcessManager;
let manager = ProcessManager::new();
}
Use this when you don’t need exit notifications. Processes are still tracked and can be stopped manually. Without the reaper, exited processes remain as zombies until stop() or drop() is called.
with_reaper(poll_interval, sender) - With reaper
#![allow(unused)]
fn main() {
use process_manager::ProcessManager;
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
}
The reaper thread polls all tracked processes every poll_interval and emits ProcessExitEvents via the sender. This prevents zombies and enables exit notifications and restart logic.
Methods
Spawning
| Method | Returns | Description |
|---|---|---|
start(label, &config) | Result<ProcessId, ProcessManagerError> | Spawn a child process with the given label and config |
start() performs the following:
- Resolves the executable via
whichif not an absolute path - Constructs
std::process::Commandwith env, working_dir, shell mode - Applies
setsid()viapre_execifconfig.forkedistrue - Sets
WAYLAND_DISPLAYifconfig.socketisSome - Configures stdio (spawns reader threads for
Piped) - Spawns the child and stores it in the
DashMap
Stopping
| Method | Returns | Description |
|---|---|---|
stop(id) | Result<(), ProcessManagerError> | Stop a single process by ProcessId |
stop_label(label) | Result<(), ProcessManagerError> | Stop all processes under a label |
stop_all() | () | Stop all tracked processes |
stop() sends the configured kill_signal, waits up to terminate_timeout_ms, and escalates to SIGKILL if the process is still running. If the process has already exited (ESRCH), it joins readers and returns Ok(()).
Stop Flow
sequenceDiagram
participant Consumer
participant Manager as ProcessManager
participant Process
participant OS
Consumer->>Manager: stop(id)
Manager->>Process: send_signal(kill_signal)
alt ESRCH (already exited)
Process-->>Manager: Ok (process gone)
Manager->>Process: join_readers(500ms)
Manager-->>Consumer: Ok
else Signal sent
Process->>OS: kill(pid, SIGTERM)
Manager->>Process: wait(terminate_timeout_ms)
alt Process exited in time
Process-->>Manager: exited
Manager->>Process: join_readers
Manager-->>Consumer: Ok
else Process still running
Manager->>Process: force_kill (SIGKILL)
Process->>OS: kill(pid, SIGKILL)
Process-->>Manager: killed
Manager-->>Consumer: Ok
end
end
Restarting
| Method | Returns | Description |
|---|---|---|
restart(id) | Result<ProcessId, ProcessManagerError> | Restart a single process, preserving config and label |
restart_label(label) | Result<(), StopManyError> | Restart all processes under a label |
restart() stops the process (with escalation), then starts a new one with the same config and label. Returns the new ProcessId.
Restart Flow
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef input fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef stop fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef start fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef done fill: #04e762, stroke: #333333, stroke-width: 1px, color: #000
A["restart(id)"] --> B["stop(id)<br/><small>SIGTERM → wait → SIGKILL</small>"]
B --> C["Remove old Process<br/>from DashMap"]
C --> D["start(label, &config)<br/><small>same config + label</small>"]
D --> E["New Process<br/>in DashMap"]
E --> F["Return new ProcessId"]
class A input
class B stop
class C stop
class D start
class E start
class F done
Signaling
| Method | Returns | Description |
|---|---|---|
send_signal(id, signal) | Result<(), ProcessManagerError> | Send a signal to a process by ProcessId (process stays in manager) |
send_signal_label(label, signal) | Result<(), StopManyError> | Send a signal to all processes under a label |
send_signal() sends any Signal (SIGHUP, SIGUSR1, SIGWINCH, etc.) without removing the process from the manager. This is useful for triggering config reloads, pausing/resuming, or other non-terminating signals.
Querying
| Method | Returns | Description |
|---|---|---|
get_info(id) | Option<ProcessInfo> | Get a process snapshot by ProcessId (no deadlock risk) |
get_by_label(label) | Vec<(ProcessId, ProcessInfo)> | Get all process snapshots with a label |
pids_by_label(label) | Vec<u32> | Get PIDs for a label |
labels() | Vec<String> | List all distinct labels |
ids() | Vec<ProcessId> | List all ProcessIds |
is_empty() | bool | Check if manager has no processes |
len() | usize | Number of tracked processes |
Convenience Getters
| Method | Returns | Description |
|---|---|---|
is_running(id) | Option<bool> | Check if a process is still running (delegates to state().is_alive()) |
state(id) | Option<ProcessState> | Get the explicit lifecycle state of a process |
is_forked(id) | Option<bool> | Check if a process was spawned with setsid() |
get_pid(id) | Option<u32> | Get the OS PID for a process |
get_label(id) | Option<String> | Get the label for a process |
get_program_name(id) | Option<String> | Get the command/program name |
get_terminate_on_exit(id) | Option<bool> | Check if process has terminate_on_exit set |
get_config(id) | Option<Arc<ProcessConfig>> | Get the process config (shared via Arc) |
get() is pub(crate) to avoid deadlock risks from holding DashMap guards. Use get_info() for a lightweight ProcessInfo snapshot or the convenience getters for specific fields.
Start Flow
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef input fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef resolve fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef spawn fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef error fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
A["start(label, &config)"] --> B{"command is<br/>absolute path?"}
B -->|No| C["which::which(command)"]
B -->|Yes| D["Use path directly"]
C -->|Found| D
C -->|Not found| E["ExecutableNotFound"]
D --> F["Build Command<br/>+ env + working_dir"]
F --> G{"forked?"}
G -->|Yes| H["Add pre_exec hook<br/>with setsid()"]
G -->|No| I["Skip"]
H --> J{"socket set?"}
I --> J
J -->|Yes| K["Set WAYLAND_DISPLAY<br/>from socket name"]
J -->|No| L["Skip"]
K --> M["Configure stdio<br/>(spawn reader threads<br/>for Piped)"]
L --> M
M --> N["Command::spawn()"]
N -->|Success| O["Store Process<br/>in DashMap"]
N -->|Failure| P["SpawnFailed"]
O --> Q["Return ProcessId"]
class A input
class B resolve
class C resolve
class D resolve
class F spawn
class G spawn
class H spawn
class J spawn
class K spawn
class M spawn
class N spawn
class O spawn
class Q spawn
class E error
class P error
Drop Behavior
When ProcessManager is dropped:
- If the reaper thread is active, it is stopped (via
stop_flagatomic) and joined - All processes with
terminate_on_exit = trueare terminated (SIGTERM → wait → SIGKILL) - Processes with
terminate_on_exit = falseare left running (detached) - The
DashMapis dropped, freeing all resources
Usage
Without reaper
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["10".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("task", &config)?;
// ... do work ...
manager.stop(id)?;
}
With reaper and restart
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.stdout(StdioConfig::Null)
.build();
manager.start("service", &config)?;
// In your event loop:
if let Ok(event) = receiver.try_recv() {
if event.restart_on_exit || event.state == ProcessState::Crashed {
manager.start(&event.label, &config)?;
}
}
}
Label-based operations
#![allow(unused)]
fn main() {
let config = ProcessConfig::builder()
.command("worker".to_string())
.stdout(StdioConfig::Null)
.build();
// Start a pool of workers
for _ in 0..4 {
manager.start("worker-pool", &config)?;
}
// Check all PIDs
let pids = manager.pids_by_label("worker-pool");
assert_eq!(pids.len(), 4);
// Stop the entire pool
manager.stop_label("worker-pool")?;
assert!(manager.is_empty());
}
Process & ProcessId
ProcessId
A unique identifier assigned by ProcessManager to each spawned process. Implemented as a u64 newtype.
ProcessId is generated atomically from an internal counter in ProcessManager. It is unique within a single ProcessManager instance - no two processes will share the same ProcessId.
Trait Implementations
Display- formats as a numberPartialEq,Eq,Hash- comparison and hashingClone,Copy- lightweight value type
Usage
#![allow(unused)]
fn main() {
// ProcessId is returned by start()
let id = manager.start("task", &config)?;
// Used for stop(), get(), etc.
manager.stop(id)?;
let process = manager.get(id);
}
Process
A handle to a managed child process. Stored in ProcessManager’s DashMap and accessed via get_info() which returns a ProcessInfo snapshot.
Lifecycle
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef start fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef running fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef signal fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef stop fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef done fill: #04e762, stroke: #333333, stroke-width: 1px, color: #000
classDef failed fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
A["start()"] --> B["Starting<br/>transient"]
B --> C["Running<br/>in DashMap"]
B -->|"spawn fails"| F["Failed"]
C -->|"send_signal()"| C
C -->|"stop()"| D["Stopping<br/>SIGTERM sent"]
C -->|"restart()"| G["Restarting"]
C -->|"process exits<br/>(reaper detects)"| E["ProcessExitEvent"]
D -->|"exits normally"| H["Stopped"]
D -->|"exits with error"| I["Crashed"]
G -->|"stop old, start new"| B
E --> E2["Removed from<br/>DashMap"]
H --> E2
I --> E2
F --> E2
class A start
class B running
class C running
class D stop
class E signal
class E2 done
class F failed
class G signal
class H done
class I done
Fields
| Field | Type | Description |
|---|---|---|
id | ProcessId | Unique identifier assigned by ProcessManager |
pid | u32 | OS process ID |
program_name | String | Program name (for error reporting) |
label | Label | Label under which the process was started |
terminate_on_exit | bool | Whether to terminate on ProcessManager drop |
config | ProcessConfig | The configuration this process was started with |
child | Option<Child> | The std::process::Child handle (always Some) |
state | ProcessState | The current lifecycle state (updated lazily by state(), explicitly by stop() / restart()) |
Methods
| Method | Returns | Description |
|---|---|---|
is_running() | bool | Non-blocking check via try_wait() - true if still running (delegates to state().is_alive()) |
state() | ProcessState | Non-blocking check via try_wait() - returns the current lifecycle state, updating it if the process has exited |
send_signal(signal) | Result<(), nix::Error> | Send a signal to the process via nix::sys::signal::kill |
force_kill() | Result<(), nix::Error> | Send SIGKILL immediately |
Clone Behavior
Process implements Clone manually because std::process::Child does not implement Clone. The clone shares the PID and config but does not duplicate the Child handle - the clone’s child field is None. This is sufficient for read-only access patterns (checking is_running(), reading PID/label).
Usage
#![allow(unused)]
fn main() {
let id = manager.start("task", &config)?;
// Access via DashMap guard
let process = manager.get(id).unwrap();
println!("PID: {}, Label: {}", process.pid, process.label);
println!("Running: {}", process.is_running());
println!("State: {}", process.state);
drop(process); // Release DashMap guard
// Stop the process
manager.stop(id)?;
}
Label
A type-safe label for grouping and identifying processes.
Overview
Label is a newtype wrapping String, following the same pattern as ProcessId. It provides type safety for label values used in grouped operations and dependency references.
Labels are not unique identifiers — multiple processes can share the same label. Use ProcessId for individual process operations and Label for grouped operations.
Construction
#![allow(unused)]
fn main() {
use process_manager::Label;
let label = Label::new("compositor");
let label2: Label = "panel".into();
}
Trait Implementations
Display- formats as the inner stringPartialEq,Eq,Hash- comparison and hashing (usable asHashMapkey)Clone- lightweight clone (notCopybecause it wrapsString)AsRef<str>- interoperability with&strAPIsFrom<&str>,From<String>,From<&String>- convenient constructionSerialize,Deserialize- serde support via#[serde(transparent)](serializes as the inner string across all formats, not just JSON)
Usage
#![allow(unused)]
fn main() {
use process_manager::{Label, ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("worker".to_string())
.stdout(StdioConfig::Null)
.build();
// Start processes with a label — &str is accepted via Into<Label>
let id = manager.start("worker-pool", &config)?;
// Group operations by label
manager.stop_label("worker-pool")?;
// Or construct a Label explicitly
let label = Label::new("worker-pool");
manager.start(label, &config)?;
}
DependencyRef
Label is used in DependencyRef::Label for declaring dependencies by name:
#![allow(unused)]
fn main() {
use process_manager::{DependencyRef, Label};
let dep = DependencyRef::label("compositor");
// or equivalently:
let dep = DependencyRef::Label(Label::new("compositor"));
}
ProcessState
ProcessState is an explicit lifecycle state enum that replaces the binary is_running() -> bool check with a granular state machine.
Overview
Each managed process has a ProcessState that reflects its current lifecycle phase. The state is updated lazily by state() / is_running() via non-blocking try_wait(), and explicitly by stop(), restart(), and the reaper thread.
Variants
| Variant | Description |
|---|---|
Starting | The process is being spawned and has not yet been confirmed running. Transient state between spawn() and insertion into the manager. |
Running | The process is alive and running. Confirmed via try_wait() returning Ok(None). |
Waiting | The process is queued but waiting for dependencies to become Running. No OS child process has been spawned yet. is_alive() returns true. |
Stopping | A stop signal has been sent and the manager is waiting for exit. Set by stop() / stop_many(). |
Stopped | The process has exited normally (exit code 0 or stopped by the manager within the grace period). |
Crashed | The process exited unexpectedly with a non-zero exit code or signal. |
Restarting | A restart is in progress - the process is in backoff wait. The OS child handle is None (resources released). send_signal() returns an error; stop() cancels backoff silently; restart() spawns immediately. |
Failed | The process failed to start, could not be killed, exhausted restarts (rate limit exceeded or spawn failure during automatic restart), or a dependency entered a terminal state. |
State Transitions
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef active fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef transient fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef terminal fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
Starting --> Running
Waiting --> Starting
Running --> Stopping
Stopping --> Stopped
Running --> Crashed
Stopping --> Crashed
Crashed --> Restarting
Stopped --> Restarting
Restarting --> Starting
Restarting --> Waiting
Running --> Failed
Restarting --> Failed
Waiting --> Failed
Starting --> Failed
class Starting,Running,Stopping,Restarting,Waiting active
class Restarting transient
class Stopped,Crashed,Failed terminal
Helper Methods
| Method | Returns | Description |
|---|---|---|
is_alive() | bool | true for Starting, Running, Waiting, Stopping, Restarting - equivalent to the old is_running() semantics |
is_terminated() | bool | true for Stopped, Crashed, Failed |
Trait Implementations
Debug,Clone,Copy,PartialEq,Eq,HashDefault- defaults toStartingDisplay- lowercase string ("starting","running", etc.)
Usage
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, ProcessState, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["10".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("task", &config)?;
// Check the explicit state
match manager.state(id) {
Some(ProcessState::Running) => println!("Process is running"),
Some(ProcessState::Stopped) => println!("Process stopped normally"),
Some(ProcessState::Crashed) => println!("Process crashed!"),
Some(state) => println!("Process state: {}", state),
None => println!("Process not found"),
}
// is_running() still works (delegates to state().is_alive())
assert_eq!(manager.is_running(id), Some(true));
// ProcessInfo also includes the state
let info = manager.get_info(id).unwrap();
println!("State: {}", info.state);
}
In ProcessExitEvent
When the reaper thread detects an exit, it sets the state field on ProcessExitEvent:
#![allow(unused)]
fn main() {
let event = receiver.recv_timeout(Duration::from_secs(5))?;
match event.state {
ProcessState::Stopped => println!("Process {} exited normally", event.label),
ProcessState::Crashed => println!("Process {} crashed", event.label),
ProcessState::Failed => println!("Process {} failed (rate limit or spawn error)", event.label),
_ => unreachable!(),
}
}
Restart Policy
RestartTrigger and RestartPolicy control automatic restart behavior when restart_on_exit is enabled.
Overview
When restart_on_exit(true) is set on a ProcessConfig and the ProcessManager is constructed with with_reaper(), the reaper thread automatically restarts processes that exit. The restart behavior is controlled by two config fields:
restart_trigger- Determines when to restartrestart_policy- Determines how to restart (immediate or with backoff)
RestartTrigger
| Variant | Description |
|---|---|
CrashOnly | Restart only on crashes (non-zero exit code or signal). Clean exits (exit(0)) are not restarted. Default. |
Always | Restart on any exit, including clean exits. Useful for processes that should always be running. |
RestartPolicy
| Variant | Description |
|---|---|
Immediate | Restart immediately on exit. No delay, no rate limiting. Default. |
Backoff(BackoffConfig) | Restart with exponential backoff and rate limiting. |
BackoffConfig
| Field | Type | Default | Description |
|---|---|---|---|
initial_delay | Duration | 1s | Initial delay before first restart |
multiplier | u32 | 20 | Multiplier applied to delay after each restart, in tenths (20 = 2.0x, 15 = 1.5x, 10 = 1.0x) |
max_delay | Duration | 60s | Maximum delay cap (prevents unbounded growth) |
max_restarts | u32 | 5 | Maximum consecutive restarts before giving up |
min_uptime | Duration | 10s | Uptime required to reset the restart counter |
Backoff Delay Calculation
The delay for restart N (1-indexed) is:
delay = min(initial_delay * (multiplier / 10)^(N-1), max_delay)
Example with initial_delay=1s, multiplier=20 (2.0x), max_delay=60s:
| Restart # | Delay |
|---|---|
| 1 | 1s |
| 2 | 2s |
| 3 | 4s |
| 4 | 8s |
| 5 | 16s |
| 6 | 32s |
| 7+ | 60s (capped) |
Rate Limiting
After max_restarts consecutive restarts, the process transitions to Failed state and is removed from the manager. A ProcessExitEvent with state=Failed is emitted.
Stable Uptime Reset
If a process runs for >= min_uptime without crashing, the restart counter resets to 0. This prevents a process that crashes once and then runs stably from being rate-limited.
RestartState (Internal)
Each process with restart_on_exit=true has an internal RestartState that tracks:
restart_count- Consecutive restarts since last stable uptimelast_started_at- When the process was last spawnednext_eligible_restart- Earliest time the process can be restarted (backoff timer)
This state is managed by the reaper thread and is not directly accessible by consumers.
Interaction with Manual Operations
During the Restarting state (backoff wait), manual operations behave as follows:
| Operation | Behavior |
|---|---|
stop(id) | Cancels backoff, removes process silently (no event, no signal) |
restart(id) | Cancels backoff, spawns immediately (preserves ProcessId) |
send_signal(id, ...) | Returns ProcessInRestartingState error |
Usage
Immediate restart on crash
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, RestartPolicy, RestartTrigger, StdioConfig};
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_millis(100), sender)?;
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.restart_trigger(RestartTrigger::CrashOnly)
.restart_policy(RestartPolicy::Immediate)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("service", &config)?;
}
Backoff with rate limiting
#![allow(unused)]
fn main() {
use process_manager::{BackoffConfig, ProcessConfig, ProcessManager, RestartPolicy, RestartTrigger, StdioConfig};
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_millis(100), sender)?;
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.restart_trigger(RestartTrigger::CrashOnly)
.restart_policy(RestartPolicy::Backoff(BackoffConfig {
initial_delay: Duration::from_secs(2),
multiplier: 20,
max_delay: Duration::from_secs(120),
max_restarts: 10,
min_uptime: Duration::from_secs(30),
}))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("service", &config)?;
}
Always restart (even on clean exit)
#![allow(unused)]
fn main() {
let config = ProcessConfig::builder()
.command("watcher".to_string())
.restart_on_exit(true)
.restart_trigger(RestartTrigger::Always)
.restart_policy(RestartPolicy::Immediate)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
}
Supervisor Strategies
The ProcessManager supports supervisor strategies that control which processes are restarted when one process in a group crashes. This follows the Erlang OTP supervisor model.
Strategies
| Strategy | Description |
|---|---|
OneForOne (default) | Restart only the crashed process |
OneForAll | Restart all processes in the same label group |
RestForOne | Restart the crashed process and all processes started after it |
How it works
When the reaper detects a crash and determines that a restart should occur (based on restart_on_exit, restart_trigger, and rate limiting), it reads the supervisor_strategy from the crashed process’s config.
- OneForOne - Only the crashed process is restarted. Other processes in the group are unaffected.
- OneForAll - All processes in the same label group are stopped and restarted. Each process gets its own backoff timer.
- RestForOne - The crashed process and all processes with a higher
spawn_sequencein the same group are stopped and restarted.
Interaction with dependencies
When using OneForAll or RestForOne with dependencies, restart order follows the dependency chain. The compositor restarts first, then dependent processes start once the compositor is Running.
Cascade flag
When a process is cascade-killed (stopped because another process in its group crashed), it is marked with cascade_flag = true. The reaper skips supervisor strategy logic for cascade-killed processes and emits a Stopped event instead of triggering another restart cycle. This prevents recursive cascades.
Example
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, SupervisorStrategy, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.supervisor_strategy(SupervisorStrategy::OneForAll)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("group-a", &config)?;
}
Dependencies
Processes can declare dependencies on other processes. A process with depends_on will not start until all its dependencies are Running.
DependencyRef
Dependencies are declared via DependencyRef:
DependencyRef::label("compositor")- resolved and bound to aProcessIdatstart()time; the firstRunningprocess with that label is selected and the binding persists for the dependent’s lifetimeDependencyRef::id(process_id)- the process with the givenProcessIdmust beRunning
Start flow
When start() is called with non-empty depends_on:
- The process is inserted into the
DashMapwithProcessState::Waiting. - A
ProcessIdis assigned immediately. - The reaper loop checks
Waitingprocesses on each poll cycle. - Once all dependencies are
Running, the process is spawned and transitions toStarting. - If dependencies are not
Runningwithindependency_timeout_ms, the process transitions toFailed.
ProcessState::Waiting
| Phase | ProcessState |
|---|---|
| Queued, waiting for deps | Waiting |
| Dependencies ready, spawning | Starting |
| Process running | Running |
| Dependency timeout | Failed |
manager.state(id) returns Waiting while the process is waiting for dependencies. is_alive() returns true for Waiting.
Fail-fast behavior
If a dependency enters a terminal state (Failed or permanently Stopped without restart_on_exit), the dependent process is immediately failed. This applies to both Waiting and Running processes:
- Waiting processes - The reaper checks resolved dependencies on each poll cycle. If a dependency is terminal or removed, the Waiting process transitions to
Failed. - Running processes - The reaper monitors resolved dependencies of Running processes. If a dependency is removed or enters a terminal state, the Running process is killed and transitions to
Failed.
A dependency in Restarting state (backoff wait) is not terminal. The dependent stays in Waiting until the dependency recovers or exhausts its restart limit.
Label binding semantics
Label bindings are resolved once and persist for the dependent’s lifetime:
- When a
Labeldependency is resolved, the resultingProcessIdis stored inresolved_deps. - The binding does not re-resolve if the dependency process is removed. A new process with the same label will not satisfy the binding.
- This ensures predictable behavior: if process A (label
"compositor") is stopped and process C is started with the same label, dependents of A do not switch to C.
Cycle detection
At start() time, the manager performs a DFS-based cycle check. If a dependency cycle is detected (e.g. A depends on B, B depends on A), start() returns ProcessManagerError::DependencyCycle.
Example
#![allow(unused)]
fn main() {
use process_manager::{DependencyRef, Label, ProcessConfig, ProcessManager, StdioConfig};
use std::time::Duration;
let manager = ProcessManager::new();
let compositor = ProcessConfig::builder()
.command("hyprland".to_string())
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let comp_id = manager.start("compositor", &compositor)?;
let panel = ProcessConfig::builder()
.command("smearor-swipe-launcher".to_string())
.depends_on(vec![DependencyRef::label("compositor")])
.dependency_timeout_ms(10_000)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let panel_id = manager.start("panel", &panel)?;
// Panel starts in Waiting state, transitions to Running once compositor is Running
}
StdioConfig
StdioConfig controls how standard streams (stdin, stdout, stderr) are configured for child processes.
Overview
When spawning a child process, each standard stream can be configured independently:
Inherit- The child inherits the parent’s stream. Output appears in the parent’s terminal. Useful for debugging.Null- The child’s stream is connected to/dev/null. All output is discarded. Suitable for background processes.Piped- The child’s stream is piped.ProcessManager::start()spawns reader threads that forward output totracing. This prevents pipe buffer deadlocks when a child produces significant output.
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef inherit fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef null fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef piped fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000000
classDef thread fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #000000
Child["Child Process"]
Child -->|"Inherit"| Parent["Parent's terminal<br/><small>output visible</small>"]
Child -->|"Null"| DevNull[" /dev/null<br/><small>output discarded</small>"]
Child -->|"Piped"| Pipe["OS Pipe<br/><small>64KB buffer</small>"]
Pipe --> Reader["Reader Thread<br/><small>tracing::debug! / error!</small>"]
class Child inherit
class Parent inherit
class DevNull null
class Pipe piped
class Reader thread
Default
StdioConfig::Null - all streams are null by default. This is the safest default for background processes and services that should not pollute the parent’s output.
Serde
StdioConfig implements Serialize and Deserialize with #[serde(rename_all = "lowercase")], so it serializes as "inherit", "null", and "piped".
Why Reader Threads for Piped?
When StdioConfig::Piped is used, the OS creates a pipe with a fixed buffer size (typically 64KB). If the child writes more than the buffer can hold and nobody reads the pipe, the child blocks - causing a deadlock. ProcessManager::start() spawns dedicated reader threads that continuously read from the pipe and forward lines to tracing::debug! / tracing::error!, preventing this deadlock.
Usage
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, StdioConfig};
// Background process - discard all output
let config = ProcessConfig::builder()
.command("daemon".to_string())
.stdin(StdioConfig::Null)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
// Debug mode - inherit parent's streams
let config = ProcessConfig::builder()
.command("my-app".to_string())
.stdin(StdioConfig::Inherit)
.stdout(StdioConfig::Inherit)
.stderr(StdioConfig::Inherit)
.build();
// Capture output via tracing
let config = ProcessConfig::builder()
.command("my-app".to_string())
.stdout(StdioConfig::Piped)
.stderr(StdioConfig::Piped)
.build();
// Reader threads will forward stdout to tracing::debug!
// and stderr to tracing::error!
}
Signal
The signal module provides two signal enums for different use cases:
- [
KillSignal] - Restricted toSIGTERM/SIGKILL, used for process termination viastop() - [
Signal] - Broader enum for general-purpose signaling viasend_signal()
KillSignal
KillSignal specifies which signal to send when terminating a process. It is intentionally limited to the two signals that make sense for the stop() path.
Variants
| Variant | Signal | Description |
|---|---|---|
Sigterm | SIGTERM (15) | Graceful termination - the process can catch and handle it |
Sigkill | SIGKILL (9) | Immediate termination - cannot be caught or handled |
Serde
KillSignal implements Serialize and Deserialize with #[serde(rename_all = "UPPERCASE")], so it serializes as "SIGTERM" and "SIGKILL". This makes it suitable for JSON/TOML configuration files.
Escalation Flow
When ProcessManager::stop() is called, the kill_signal determines the termination behavior:
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef input fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef sigterm fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef sigkill fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef done fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
A["stop(id)"] --> B{"kill_signal?"}
B -->|Sigterm| C["Send SIGTERM"]
B -->|Sigkill| D["Send SIGKILL"]
C --> E["Wait terminate_timeout_ms"]
E --> F{"Process exited?"}
F -->|Yes| G["Remove from DashMap"]
F -->|No| H["Send SIGKILL<br/>(escalation)"]
H --> G
D --> G
class A input
class C sigterm
class E sigterm
class F sigterm
class D sigkill
class H sigkill
class G done
Usage in Config
#![allow(unused)]
fn main() {
use process_manager::{KillSignal, ProcessConfig};
// Graceful termination with 3 second timeout
let config = ProcessConfig::builder()
.command("my-app".to_string())
.kill_signal(KillSignal::Sigterm)
.terminate_timeout_ms(3000)
.build();
// Immediate kill (no grace period)
let config = ProcessConfig::builder()
.command("stubborn-app".to_string())
.kill_signal(KillSignal::Sigkill)
.build();
}
When to Use Which
Sigterm(default) - Use for well-behaved processes that clean up onSIGTERM(save state, close connections, flush buffers). The configurable timeout gives them time to shut down gracefully.Sigkill- Use for processes that don’t respond toSIGTERMor when you need immediate termination.SIGKILLcannot be caught, so the process is killed instantly by the kernel.
Signal
Signal is a broader enum covering common Unix signals for general process control. It is used with ProcessManager::send_signal() and send_signal_label(), which send a signal without removing the process from the manager.
Variants
| Variant | Signal | Description |
|---|---|---|
Sighup | SIGHUP (1) | Hang up - often used to reload configuration |
Sigint | SIGINT (2) | Interrupt (Ctrl+C) |
Sigquit | SIGQUIT (3) | Quit with core dump |
Sigterm | SIGTERM (15) | Graceful termination request |
Sigkill | SIGKILL (9) | Immediate forced termination, cannot be caught |
Sigusr1 | SIGUSR1 (10) | User-defined signal 1 |
Sigusr2 | SIGUSR2 (12) | User-defined signal 2 |
Sigwinch | SIGWINCH (28) | Window size change |
Sigstop | SIGSTOP (19) | Pause execution, cannot be caught |
Sigcont | SIGCONT (18) | Resume execution after SIGSTOP |
Sigalrm | SIGALRM (14) | Timer alarm |
Serde
Signal implements Serialize and Deserialize with #[serde(rename_all = "UPPERCASE")], so it serializes as e.g. "SIGUSR1", "SIGHUP", etc.
Conversion from KillSignal
Signal implements From<KillSignal>, so a KillSignal can be converted to a Signal when needed:
#![allow(unused)]
fn main() {
use process_manager::{KillSignal, Signal};
let signal: Signal = KillSignal::Sigterm.into();
assert_eq!(signal, Signal::Sigterm);
}
Usage with send_signal
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, Signal, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("my-app".to_string())
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("worker", &config)?;
// Send SIGHUP to trigger a config reload
manager.send_signal(id, Signal::Sighup)?;
// Send SIGUSR1 to all processes with label "worker"
manager.send_signal_label("worker", Signal::Sigusr1)?;
// Stop the process (uses configured kill_signal)
manager.stop(id)?;
}
KillSignal vs Signal
| Aspect | KillSignal | Signal |
|---|---|---|
| Variants | Sigterm, Sigkill | 11 common Unix signals |
| Used by | ProcessConfig::kill_signal, stop() | send_signal(), send_signal_label() |
| Purpose | Termination with escalation | General process control |
| Removes process | Yes (via stop()) | No - process stays in manager |
graph LR
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef kill fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef signal fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef process fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
KS["KillSignal<br/><small>Sigterm / Sigkill</small>"] -->|"stop()<br/>removes process"| P["Process<br/>removed from<br/>DashMap"]
S["Signal<br/><small>SIGHUP, SIGUSR1,<br/>SIGWINCH, SIGSTOP, ...</small>"] -->|"send_signal()<br/>process stays"| Q["Process<br/>stays in<br/>DashMap"]
class KS kill
class S signal
class P process
class Q process
Why Not Child::kill()?
std::process::Child::kill() always sends SIGKILL. There is no way to send SIGTERM using the standard library alone. The nix crate provides nix::sys::signal::kill(pid, signal) which allows sending any signal, including SIGTERM. This is why process-manager depends on nix.
Reaper Thread
The reaper thread is an optional background thread that polls try_wait() on all tracked processes at a configurable interval, preventing zombies and emitting ProcessExitEvents.
Overview
When a child process exits, it becomes a zombie until someone calls wait() or try_wait() on it. Without the reaper, the consumer must manually call is_running() or stop() to reap exited processes. The reaper thread automates this:
- Spawns a
std::threadnamed"process-reaper" - Every
poll_interval, iterates all tracked processes in theDashMap - Phase 1: Detect exits - for each process (skip
Restarting), check stable uptime reset, then calltry_wait()to detect exits. If exited, determine state, check restart policy, emit event, and either remove or transition toRestarting. If restart is triggered andsupervisor_strategyisOneForAllorRestForOne, collect cascade targets in the same label group. - Phase 2: Cascade kill - send kill signals to cascade-flagged processes. Processes with
cascade_flag = trueskip supervisor strategy logic on their own exit. - Phase 3: Set
Restartingstate for processes scheduled for restart. - Phase 4: Check eligible restarts - for
Restartingprocesses whose backoff has elapsed, spawn a new process in-place. If the process has unsatisfied dependencies, transition toWaitinginstead of spawning. - Phase 5: Check
Waitingprocesses - resolve label dependencies, spawn when all deps areRunning, fail-fast when a dependency is terminal, timeout if deps not ready withindependency_timeout_ms. - Phase 6: Check
Runningprocesses for terminal dependencies - if a resolved dependency is removed or enters a terminal state, fail-fast the dependent process. - Runs until
ProcessManageris dropped (via astop_flagAtomicBool)
Polling Cycle
sequenceDiagram
participant Reaper as Reaper Thread
participant DashMap as DashMap
participant Channel as mpsc::Sender
participant Consumer as Consumer
loop Every poll_interval
Reaper->>DashMap: Phase 1: Detect exits
loop Each process (skip Restarting)
Reaper->>Reaper: Check stable uptime reset
Reaper->>Reaper: try_wait()
alt Still running
Reaper->>Reaper: Skip
else Exited
Reaper->>Reaper: Determine state + restart policy
alt No restart or rate-limited
Reaper->>DashMap: Remove process
Reaper->>Channel: Send ProcessExitEvent
else Restart triggered
Reaper->>Reaper: Release OS resources
Reaper->>Reaper: Record restart + schedule backoff
Reaper->>Reaper: Set state = Restarting
Reaper->>Channel: Send ProcessExitEvent
end
end
end
Reaper->>Reaper: Phase 2: Cascade kill (OneForAll/RestForOne)
Reaper->>Reaper: Phase 3: Set Restarting state
Reaper->>DashMap: Phase 4: Check eligible restarts
loop Each Restarting process
alt Backoff elapsed
Reaper->>Reaper: Check dependencies
alt Deps ready
Reaper->>Reaper: Spawn new process in-place
alt Spawn success
Reaper->>Reaper: Update entry, state = Starting
else Spawn failure
Reaper->>Reaper: state = Failed
Reaper->>DashMap: Remove process
Reaper->>Channel: Send ProcessExitEvent (Failed)
end
else Deps not ready
Reaper->>Reaper: state = Waiting
end
end
end
Reaper->>DashMap: Phase 5: Check Waiting processes
loop Each Waiting process
alt Deps all Running
Reaper->>Reaper: Spawn process
else Dependency terminal
Reaper->>Reaper: state = Failed, emit event
else Timeout exceeded
Reaper->>Reaper: state = Failed, emit event
end
end
Reaper->>DashMap: Phase 6: Check Running deps
loop Each Running process with deps
alt Dependency terminal/removed
Reaper->>Reaper: Kill + state = Failed, emit event
end
end
end
Note over Reaper: ProcessManager dropped
Reaper->>Reaper: stop_flag = true
Reaper->>Reaper: Thread exits
Polling vs. pidfd
The reaper uses polling (try_wait()) rather than pidfd_open (Linux ≥ 5.3). This means:
- Exit detection latency - Up to
poll_intervaldelay between process exit and event emission. With the default 2-second interval, a process exit is detected within 2 seconds. - CPU usage - The thread wakes up every
poll_intervaland iterates all processes. For a small number of processes (< 100), this is negligible. - Portability -
try_wait()works on all Unix systems.pidfd_openis Linux-only.
A future improvement could use pidfd_open for instant notification, but the current approach is sufficient for the use case (compositor clients and launcher apps).
Zombie Prevention
Without the reaper, exited child processes remain as zombies until someone calls wait(). Zombies consume a PID and a small amount of kernel memory. In long-running applications (like a Wayland compositor or desktop launcher), zombies accumulate over time.
The reaper calls try_wait() which reaps the zombie immediately upon detection. Even without the reaper, ProcessManager::drop will clean up remaining processes - but the reaper is recommended for long-lived managers.
Thread Safety
The reaper thread accesses the DashMap via an Arc, so it works concurrently with start(), stop(), and other operations from the main thread. DashMap’s shard-level locking ensures no contention:
- The reaper iterates with
DashMap::iter()which takes a snapshot of shard read locks start()inserts into a shard - may briefly block the reaper on that shardstop()removes from a shard - may briefly block the reaper on that shard
This is acceptable for the use case (low-frequency operations, small number of processes).
Usage
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
let config = ProcessConfig::builder()
.command("true".to_string())
.stdout(StdioConfig::Null)
.build();
manager.start("task", &config)?;
// Receive exit event
let event = receiver.recv_timeout(Duration::from_secs(10))?;
println!("Process {} (PID {}) exited: {}", event.label, event.pid, event.state);
}
ProcessExitEvent
ProcessExitEvent is emitted by the reaper thread when a tracked process exits.
Overview
When the reaper thread detects that a process has exited (via try_wait()), it constructs a ProcessExitEvent and sends it through the std::sync::mpsc::Sender provided to ProcessManager::with_reaper().
The consumer receives this event and can decide what to do - log the exit, restart the process, update UI state, etc.
Fields
| Field | Type | Description |
|---|---|---|
id | ProcessId | The unique identifier of the exited process |
pid | u32 | The OS process ID (for logging/debugging) |
label | String | The label under which the process was started |
restart_on_exit | bool | Whether the consumer should restart this process |
exit_status | Option<ExitStatus> | The exit status of the process (None if the child handle was missing or try_wait() returned an error) |
state | ProcessState | The lifecycle state at exit - Stopped if normal exit, Crashed if non-zero exit code or signal |
The restart_on_exit flag is set from ProcessConfig::restart_on_exit. It is a hint - the consumer is free to ignore it. The reaper itself does not restart processes; that is the consumer’s responsibility.
Event Flow
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef reaper fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef event fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef consumer fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef action fill: #f5b700, stroke: #333333, stroke-width: 1px, color: #000000
A["Reaper Thread"] -->|"try_wait() detects exit"| B["Construct ProcessExitEvent"]
B --> C["Send via mpsc::Sender"]
C --> D{"Consumer type"}
D -->|Sync| E["receiver.recv()<br/>or try_recv()"]
D -->|GTK/Async| F["Forwarding thread<br/>std → tokio::mpsc"]
F --> G["MainContext::spawn_local<br/>async event handling"]
E --> H{"state?"}
G --> H
H -->|"Stopped"| J["Log / update UI"]
H -->|"Crashed"| I["manager.start(label, &config)"]
H -->|"restart_on_exit"| I
class A reaper
class B event
class C event
class E consumer
class F consumer
class G consumer
class H action
class I action
class J action
Consumer Patterns
1. Synchronous Consumer
For non-UI applications, the consumer simply calls receiver.recv() or receiver.try_recv():
#![allow(unused)]
fn main() {
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
manager.start("task", &config)?;
loop {
let event = receiver.recv()?;
println!("Process {} (PID {}) exited: {}", event.label, event.pid, event.state);
if event.restart_on_exit || event.state == ProcessState::Crashed {
manager.start(&event.label, &config)?;
}
}
}
2. GTK Consumer (Forwarding Thread)
Important: Do not call receiver.recv() directly in MainContext::spawn_local - it is a blocking call that will freeze the GTK main loop.
Instead, use a forwarding thread to bridge the blocking std::sync::mpsc to a non-blocking tokio::sync::mpsc channel:
#![allow(unused)]
fn main() {
use std::sync::mpsc;
use tokio::sync::mpsc::unbounded_channel;
use gtk4::glib::MainContext;
let (sync_sender, sync_receiver) = mpsc::channel();
let (async_sender, mut async_receiver) = unbounded_channel();
// Forwarding thread: bridges blocking recv() to async channel
std::thread::spawn(move || {
while let Ok(event) = sync_receiver.recv() {
if async_sender.send(event).is_err() {
break; // Async receiver dropped - exit thread
}
}
});
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sync_sender);
manager.start("task", &config)?;
// In GTK main context - non-blocking
let main_context = MainContext::default();
main_context.spawn_local(async move {
while let Some(event) = async_receiver.recv().await {
println!("Process {} exited: {}", event.label, event.state);
if event.restart_on_exit || event.state == ProcessState::Crashed {
manager.start(&event.label, &config);
}
}
});
}
3. Periodic Polling (No Async)
For simple applications that already have a periodic timer:
#![allow(unused)]
fn main() {
loop {
// Process exit events
while let Ok(event) = receiver.try_recv() {
println!("Process {} exited: {}", event.label, event.state);
if event.restart_on_exit || event.state == ProcessState::Crashed {
manager.start(&event.label, &config)?;
}
}
// Do other work...
std::thread::sleep(Duration::from_millis(100));
}
}
Graceful Shutdown
The forwarding thread terminates automatically when:
- The
ProcessManageris dropped → the reaper thread stops → thempsc::Senderis dropped receiver.recv()returnsErr(sender disconnected) → the forwarding thread exits- The
async_senderis dropped (when the GTKMainContexttask completes) →async_sender.send()returnsErr→ the forwarding thread exits
This ensures the forwarding thread does not interfere with graceful shutdown. No explicit join is needed.
Error Types
ProcessManagerError
Errors returned by ProcessManager operations.
| Variant | When | Description |
|---|---|---|
ExecutableNotFound(String) | start() | The command could not be resolved via which and is not an absolute path |
SpawnFailed(String) | start() | std::process::Command::spawn() returned an error |
NotFound(ProcessId) | stop(), get() | No process with the given ProcessId exists in the DashMap |
NixError(nix::Error) | stop() | A nix signal operation failed - the process may have already exited |
ProcessConfigError
Errors returned during ProcessConfig construction. Currently minimal since TypedBuilder enforces required fields at compile time.
Usage
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, ProcessManagerError};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("nonexistent-program".to_string())
.build();
match manager.start("test", &config) {
Ok(id) => println!("Started with ID {}", id),
Err(ProcessManagerError::ExecutableNotFound(cmd)) => {
eprintln!("Program '{}' not found in PATH", cmd);
}
Err(ProcessManagerError::SpawnFailed(msg)) => {
eprintln!("Failed to spawn: {}", msg);
}
Err(e) => eprintln!("Error: {}", e),
}
}
Usage Examples
This section provides practical examples for common use cases of smearor-wrot-process-manager.
Examples
- Simple Child Process - Spawn a process with piped output capture via tracing
- Forked / Detached Process - Spawn a daemon that detaches from the controlling terminal via
setsid() - Reaper Monitoring - Detect process exits with
ProcessExitEvent, implement restart logic, and integrate with GTK - Label-based Grouping - Manage multiple processes under a shared label for grouped start/stop
- Stop Escalation - Observe SIGTERM to SIGKILL escalation with a short timeout
- Restart - Restart a process preserving its config and label
- Wayland Socket Binding - Bind a child process to a Wayland socket by setting
WAYLAND_DISPLAY - Send Signal - Send arbitrary signals (SIGHUP, SIGUSR1, etc.) to running processes
Common Patterns
All examples share these common patterns:
- Create a
ProcessManager- Eithernew()(no reaper) orwith_reaper()(with exit notifications) - Build a
ProcessConfig- Using theTypedBuilderpattern with.command()as the only required field - Start the process -
manager.start(label, &config)returns aProcessId - Stop or monitor - Call
manager.stop(id)for explicit termination, or use the reaper for exit detection
Simple Child Process
Spawn a child process with piped stdout/stderr for output capture.
Overview
This is the most basic usage of ProcessManager: spawn a process, let it run, and stop it when done. The Piped stdio configuration causes ProcessManager to spawn reader threads that forward stdout to tracing::debug! and stderr to tracing::error!, preventing pipe buffer deadlocks.
Example
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("echo".to_string())
.args(vec!["hello world".to_string()])
.stdout(StdioConfig::Piped)
.stderr(StdioConfig::Piped)
.build();
let id = manager.start("echo-app", &config)?;
// Reader threads capture stdout/stderr and forward to tracing
// Check if the process is still running
println!("Running: {}", manager.is_running(id).unwrap_or(false));
println!("State: {}", manager.state(id).unwrap_or(process_manager::ProcessState::Failed));
// Stop when done
manager.stop(id)?;
}
What Happens Internally
ProcessManager::start()resolvesechoviawhich(since it’s not an absolute path)- A
std::process::Commandis built with the piped stdio configuration - The child is spawned and stored in the
DashMapunder the label"echo-app" - Reader threads are spawned for stdout and stderr, forwarding lines to
tracing manager.stop(id)sendsSIGTERM, waits up to 5 seconds, then escalates toSIGKILLif needed
Forked / Detached Process
Spawn a process that is detached from the parent’s controlling terminal via setsid().
Overview
When forked = true is set in ProcessConfig, ProcessManager::start() adds a pre_exec hook that calls setsid(). This detaches the child process from the parent’s controlling terminal, making it suitable for daemons and background services that should survive terminal close.
Unlike a double-fork (which would create a grandchild and lose the Child handle), setsid() keeps the process as a direct child. This means:
- The
Childhandle is retained in theDashMap try_wait()works for reaping andis_running()checksstop()/stop_label()can still terminate it- The reaper thread can detect its exit
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef parent fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef fork fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef child fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef detached fill: #dc0073, stroke: #333333, stroke-width: 1px, color: #ffffff
P["Parent Process<br/><small>ProcessManager</small>"]
P -->|"fork + exec<br/>+ setsid() in pre_exec"| C["Child Process<br/><small>new session leader</small>"]
C --> D["Detached from<br/>controlling terminal"]
D --> E["Survives terminal close<br/><small>no SIGHUP</small>"]
C --> F["Child handle<br/>retained in DashMap"]
F --> G["stop() / reaper<br/>still work"]
class P parent
class C child
class D detached
class E detached
class F fork
class G fork
Example
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("my-daemon".to_string())
.forked(true)
.terminate_on_exit(true)
.stdin(StdioConfig::Null)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("daemon", &config)?;
}
When to Use forked
- Daemons - Processes that should run independently of the parent’s terminal session
- Terminal applications - Processes that should not receive
SIGHUPwhen the parent terminal closes - Long-running services - Processes that outlive the parent and should not be tied to the parent’s session
When NOT to Use forked
- Compositor clients - Wayland clients should share the compositor’s session
- Short-lived commands - No need to detach processes that finish quickly
- Processes you want to receive terminal signals -
setsid()detaches from the terminal, so terminal-generated signals (Ctrl+C, SIGHUP) won’t reach the child
Reaper Monitoring
Use the reaper thread to detect process exits and implement restart logic.
Overview
The reaper thread polls try_wait() on all tracked processes at a configurable interval. When a process exits, it emits a ProcessExitEvent through an mpsc channel. This enables:
- Zombie prevention - Exited processes are reaped automatically
- Exit notifications - The consumer is informed when processes exit
- Restart logic - The consumer can restart crashed processes
Basic Exit Detection
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use std::time::Duration;
let (sender, receiver) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_secs(2), sender)?;
let config = ProcessConfig::builder()
.command("short-lived".to_string())
.stdout(StdioConfig::Null)
.build();
manager.start("task", &config)?;
// Receive exit event (blocks until process exits or timeout)
let event = receiver.recv_timeout(Duration::from_secs(10))?;
println!("Process {} (PID {}) exited: {}", event.label, event.pid, event.state);
}
Restart on Exit
#![allow(unused)]
fn main() {
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.stdout(StdioConfig::Null)
.build();
manager.start("service", &config)?;
// In your event loop:
if let Ok(event) = receiver.try_recv() {
if event.restart_on_exit || event.state == ProcessState::Crashed {
// Restart the process with the same label and config
manager.start(&event.label, &config)?;
}
}
}
GTK Integration
In GTK applications, do not call receiver.recv() directly in MainContext::spawn_local - it is a blocking call that will freeze the GTK main loop. Instead, use a forwarding thread to bridge the blocking std::sync::mpsc to a non-blocking tokio::sync::mpsc channel:
#![allow(unused)]
fn main() {
use tokio::sync::mpsc::unbounded_channel;
use gtk4::glib::MainContext;
let (event_tx, mut event_rx) = unbounded_channel();
std::thread::spawn(move || {
while let Ok(event) = receiver.recv() {
if event_tx.send(event).is_err() {
break; // Async receiver dropped - exit thread
}
}
});
MainContext::default().spawn_local(async move {
while let Some(event) = event_rx.recv().await {
println!("Process {} exited: {}", event.label, event.state);
if event.restart_on_exit || event.state == ProcessState::Crashed {
// Restart in main thread
}
}
});
}
Event Flow Diagram
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef reaper fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef thread fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef gtk fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
A["Reaper Thread<br/>try_wait() poll"] -->|ProcessExitEvent| B["std::sync::mpsc"]
B --> C["Forwarding Thread<br/>recv() → send()"]
C --> D["tokio::sync::mpsc"]
D --> E["MainContext::spawn_local<br/>async event handling"]
class A reaper
class B thread
class C thread
class D gtk
class E gtk
Graceful Shutdown
The forwarding thread terminates automatically when:
ProcessManageris dropped → reaper stops →mpsc::Senderdropped →recv()returnsErrMainContexttask completes →event_rxdropped →event_tx.send()returnsErr
No explicit join is needed - the forwarding thread exits cleanly in both cases.
Label-based Grouping
Start and stop multiple processes under a shared label.
Overview
Labels provide a way to group related processes. Multiple processes can share the same label, and operations like stop_label() and pids_by_label() act on all processes with that label. This is useful for:
- Worker pools - Start N workers under a shared label, stop them all at once
- Application groups - Group auxiliary processes (e.g. a main app + its helpers)
- Service management - Start/stop services by name rather than tracking individual
ProcessIds
Labels are not unique identifiers - multiple processes can share the same label. Use ProcessId for individual process operations and labels for grouped operations.
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef manager fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef label fill: #f5b700, stroke: #333333, stroke-width: 2px, color: #000000
classDef process fill: #89fc00, stroke: #333333, stroke-width: 1px, color: #000
M["ProcessManager<br/><small>DashMap</small>"]
M --> L1["label: \"frontend\""]
M --> L2["label: \"backend\""]
L1 --> P1["Process #1<br/>PID 1234"]
L1 --> P2["Process #2<br/>PID 1235"]
L2 --> P3["Process #3<br/>PID 1236"]
L2 --> P4["Process #4<br/>PID 1237"]
L2 --> P5["Process #5<br/>PID 1238"]
S["stop_label#40;"backend"#41;"] -.->|"stops all"| L2
class M manager
class L1 label
class L2 label
class P1 process
class P2 process
class P3 process
class P4 process
class P5 process
Example
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("worker".to_string())
.stdout(StdioConfig::Null)
.build();
// Start multiple workers under the same label
manager.start("worker-pool", &config)?;
manager.start("worker-pool", &config)?;
manager.start("worker-pool", &config)?;
// Check all PIDs for the label
let pids = manager.pids_by_label("worker-pool");
assert_eq!(pids.len(), 3);
// Stop all workers in the pool at once
manager.stop_label("worker-pool")?;
assert!(manager.is_empty());
}
Querying by Label
| Method | Returns | Description |
|---|---|---|
pids_by_label(label) | Vec<u32> | All PIDs for processes with this label |
get_by_label(label) | Vec<Process> | All processes with this label |
labels() | Vec<Label> | All distinct labels currently registered |
Mixed Labels
You can have multiple labels active at the same time:
#![allow(unused)]
fn main() {
manager.start("frontend", &frontend_config)?;
manager.start("frontend", &frontend_config)?;
manager.start("backend", &backend_config)?;
manager.start("backend", &backend_config)?;
manager.start("backend", &backend_config)?;
assert_eq!(manager.pids_by_label("frontend").len(), 2);
assert_eq!(manager.pids_by_label("backend").len(), 3);
assert_eq!(manager.labels().len(), 2);
// Stop only the backend
manager.stop_label("backend")?;
assert_eq!(manager.pids_by_label("frontend").len(), 2);
assert!(manager.pids_by_label("backend").is_empty());
}
Stop Escalation
Observe the SIGTERM → SIGKILL escalation path with a short timeout.
Overview
When stop() is called, the manager sends the configured kill_signal (default SIGTERM), waits up to terminate_timeout_ms, and escalates to SIGKILL if the process is still running. This example uses a very short timeout (100ms) and a process that ignores SIGTERM to demonstrate the escalation.
sequenceDiagram
participant Consumer
participant Manager as ProcessManager
participant Process
participant OS
Consumer->>Manager: stop(id)
Manager->>OS: kill(pid, SIGTERM)
Note over Process: Process ignores SIGTERM<br/>(trap '' TERM)
Manager->>Manager: wait 100ms<br/>(terminate_timeout_ms)
Note over Manager: Process still running
Manager->>OS: kill(pid, SIGKILL)
Note over Process: SIGKILL cannot be caught
Process-->>Manager: killed
Manager->>Manager: join_readers
Manager-->>Consumer: Ok
Example
use process_manager::{KillSignal, ProcessConfig, ProcessManager, StdioConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = ProcessManager::new();
// Process that ignores SIGTERM (trap '' TERM)
let config = ProcessConfig::builder()
.command("sh".to_string())
.args(vec!["-c".to_string(), "trap '' TERM; sleep 30".to_string()])
.kill_signal(KillSignal::Sigterm)
.terminate_timeout_ms(100)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("stubborn", &config)?;
println!("Started stubborn process (PID {})", manager.get_pid(id).unwrap());
// stop() will send SIGTERM, wait 100ms, then escalate to SIGKILL
manager.stop(id)?;
println!("Process stopped (escalated to SIGKILL)");
assert!(manager.is_empty());
Ok(())
}
Running the Example
cargo run --example stop_escalation
Restart
Restart a process while preserving its config and label.
Overview
ProcessManager::restart(id) stops the process (with escalation) and starts a new one with the same config and label. It returns the same ProcessId - the process is updated in-place. restart_label(label) does the same for all processes under a label.
graph TD
classDef default fill: #1e1e1e, stroke: #333333, stroke-width: 1px, color: #ffffff
classDef input fill: #00a1e4, stroke: #ffffff, stroke-width: 2px, color: #ffffff
classDef stop fill: #dc0073, stroke: #333333, stroke-width: 2px, color: #ffffff
classDef start fill: #89fc00, stroke: #333333, stroke-width: 2px, color: #000
classDef done fill: #04e762, stroke: #333333, stroke-width: 1px, color: #000
A["restart(id)"] --> B{"Process in\nRestarting state?"}
B -- Yes --> C["Cancel backoff\nSpawn immediately"]
B -- No --> D["stop(id)"]
D --> E["Old process\nremoved"]
C --> F["Spawn in-place\n<small>same config + label</small>"]
E --> F
F --> G["Same ProcessId\npreserved"]
G --> H["Manager still\nhas process"]
class A input
class B stop
class C start
class D stop
class E stop
class F start
class G start
class H done
Example
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["10".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("worker", &config)?;
println!("Started worker (PID {})", manager.get_pid(id).unwrap());
// Restart - stops the old process, starts a new one with same config+label
// ProcessId is preserved (updated in-place)
let new_id = manager.restart(id)?;
println!("Restarted worker (new PID {})", manager.get_pid(new_id).unwrap());
assert_eq!(id, new_id);
// Clean up
manager.stop(new_id)?;
println!("Manager empty: {}", manager.is_empty());
Ok(())
}
Label-based Restart
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["10".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
// Start a pool of 3 workers
for _ in 0..3 {
manager.start("worker-pool", &config)?;
}
// Restart all workers in the pool
manager.restart_label("worker-pool")?;
}
Running the Example
cargo run --example restart
Wayland Socket Binding
Bind a child process to a Wayland socket by setting WAYLAND_DISPLAY in its environment.
Overview
When spawning a Wayland client (e.g. a GTK4 app, a panel, a wallpaper daemon), the client needs to know which Wayland display to connect to. This is done via the WAYLAND_DISPLAY environment variable.
ProcessConfig::socket(Some(socket)) tells ProcessManager to extract the socket name (the last path component, e.g. wayland-0) from the Socket and set it as WAYLAND_DISPLAY in the child’s environment.
Example
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
use process_manager_socket::{SocketBuilder, SocketManager};
// Create and register a socket
let socket = SocketBuilder::build(&None)?;
let socket_manager = SocketManager::new();
socket_manager.register("default", socket)?;
let socket = socket_manager.get("default").unwrap().clone();
// Spawn a process bound to the socket
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("gtk4-app".to_string())
.socket(Some(socket))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("wayland-client", &config)?;
}
Multi-Output Example
In a multi-output compositor, each output may have its own socket. Clients spawned for a specific output should be bound to that output’s socket:
#![allow(unused)]
fn main() {
use process_manager_socket::{SocketBuilder, SocketManager};
use process_manager::{ProcessConfig, ProcessManager, StdioConfig};
let socket_manager = SocketManager::new();
// Register sockets for each output
socket_manager.register("eDP-1", SocketBuilder::build(&Some("wayland-0".to_string()))?)?;
socket_manager.register("HDMI-1", SocketBuilder::build(&Some("wayland-1".to_string()))?)?;
let manager = ProcessManager::new();
// Spawn a panel on eDP-1
let edp_socket = socket_manager.get("eDP-1").unwrap().clone();
let panel_config = ProcessConfig::builder()
.command("smearor-swipe-launcher".to_string())
.socket(Some(edp_socket))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
manager.start("panel-eDP-1", &panel_config)?;
// Spawn a wallpaper on HDMI-1
let hdmi_socket = socket_manager.get("HDMI-1").unwrap().clone();
let wallpaper_config = ProcessConfig::builder()
.command("swaybg".to_string())
.args(vec!["--image".to_string(), "/path/to/wallpaper.jpg".to_string()])
.socket(Some(hdmi_socket))
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
manager.start("wallpaper-HDMI-1", &wallpaper_config)?;
}
How It Works
When config.socket is Some(socket):
ProcessManager::start()extracts the socket name from theSocketpath (e.g.wayland-0from/run/user/1000/wayland-0)- The socket name is inserted into the child’s environment as
WAYLAND_DISPLAY=wayland-0 - The child process connects to the Wayland display using this environment variable
If config.socket is None, WAYLAND_DISPLAY is not set - the child inherits the parent’s WAYLAND_DISPLAY if present.
Send Signal
Send arbitrary Unix signals to running processes without stopping them.
Overview
The send_signal() and send_signal_label() methods allow sending any Signal to a managed process. Unlike stop(), the process remains in the manager - this is useful for:
- Config reloads - Send
SIGHUPto trigger a reload - Pause/resume - Send
SIGSTOP/SIGCONT - Custom protocols - Send
SIGUSR1/SIGUSR2for application-specific events - Window resize - Send
SIGWINCHto notify terminal applications
sequenceDiagram
participant Consumer
participant Manager as ProcessManager
participant Process
participant OS
Consumer->>Manager: send_signal(id, Signal::Sighup)
Manager->>Process: lookup in DashMap
Process->>OS: kill(pid, SIGHUP)
OS-->>Process: signal delivered
Note over Process: Process stays in DashMap<br/>NOT removed
Manager-->>Consumer: Ok
Example
use process_manager::{ProcessConfig, ProcessManager, ProcessState, Signal, StdioConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("sleep".to_string())
.args(vec!["30".to_string()])
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("target", &config)?;
println!("Started process with ID {} (PID {})", id, manager.get_pid(id).unwrap());
assert_eq!(manager.is_running(id), Some(true));
assert_eq!(manager.state(id), Some(ProcessState::Running));
// Send SIGWINCH - sleep ignores it, process keeps running
manager.send_signal(id, Signal::Sigwinch)?;
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(manager.is_running(id), Some(true));
assert_eq!(manager.state(id), Some(ProcessState::Running));
println!("After SIGWINCH: still running");
// Send SIGTERM - sleep terminates
manager.send_signal(id, Signal::Sigterm)?;
std::thread::sleep(std::time::Duration::from_millis(200));
assert_eq!(manager.is_running(id), Some(false));
assert_eq!(manager.state(id), Some(ProcessState::Crashed));
println!("After SIGTERM: process exited");
manager.stop(id)?;
println!("Manager empty: {}", manager.is_empty());
Ok(())
}
Label-based Signaling
Send a signal to all processes sharing a label:
#![allow(unused)]
fn main() {
use process_manager::{ProcessConfig, ProcessManager, Signal, StdioConfig};
let manager = ProcessManager::new();
let config = ProcessConfig::builder()
.command("my-worker".to_string())
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
// Start a pool of 4 workers
for _ in 0..4 {
manager.start("worker-pool", &config)?;
}
// Send SIGHUP to all workers to trigger a config reload
manager.send_signal_label("worker-pool", Signal::Sighup)?;
}
Running the Example
cargo run --example send_signal
Supervisor Strategies
This example demonstrates OneForAll and RestForOne supervisor strategies.
OneForAll - restart all group members
When any process in the group crashes, all processes in the same label group are restarted.
#![allow(unused)]
fn main() {
use process_manager::{
BackoffConfig, ProcessConfig, ProcessManager, RestartPolicy,
RestartTrigger, StdioConfig, SupervisorStrategy,
};
use std::time::Duration;
let (tx, rx) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_millis(100), tx)?;
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.restart_trigger(RestartTrigger::CrashOnly)
.restart_policy(RestartPolicy::Backoff(BackoffConfig::default()))
.supervisor_strategy(SupervisorStrategy::OneForAll)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id_a = manager.start("group-a", &config)?;
let id_b = manager.start("group-a", &config)?;
// If either process crashes, both are restarted.
}
RestForOne - restart crashed and later processes
When a process crashes, it and all processes with a higher spawn_sequence in the same group are restarted.
#![allow(unused)]
fn main() {
let config = ProcessConfig::builder()
.command("my-service".to_string())
.restart_on_exit(true)
.restart_trigger(RestartTrigger::CrashOnly)
.restart_policy(RestartPolicy::Backoff(BackoffConfig::default()))
.supervisor_strategy(SupervisorStrategy::RestForOne)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id_a = manager.start("group-b", &config)?;
let id_b = manager.start("group-b", &config)?;
let id_c = manager.start("group-b", &config)?;
// If B crashes, B and C are restarted. A is unaffected.
}
Dependencies
This example demonstrates dependency ordering and timeout.
Label-based dependency
Process B depends on process A by label. B starts in Waiting state and transitions to Running once A is Running.
#![allow(unused)]
fn main() {
use process_manager::{
DependencyRef, ProcessConfig, ProcessManager, StdioConfig,
};
use std::time::Duration;
let (tx, rx) = std::sync::mpsc::channel();
let manager = ProcessManager::with_reaper(Duration::from_millis(100), tx)?;
let compositor = ProcessConfig::builder()
.command("hyprland".to_string())
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let comp_id = manager.start("compositor", &compositor)?;
let panel = ProcessConfig::builder()
.command("smearor-swipe-launcher".to_string())
.depends_on(vec![DependencyRef::label("compositor")])
.dependency_timeout_ms(10_000)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let panel_id = manager.start("panel", &panel)?;
// Panel starts in Waiting, transitions to Running once compositor is Running
}
Multiple dependencies
Process C depends on both A and B. C starts only after both are Running.
#![allow(unused)]
fn main() {
let config_c = ProcessConfig::builder()
.command("my-service".to_string())
.depends_on(vec![
DependencyRef::label("a"),
DependencyRef::label("b"),
])
.dependency_timeout_ms(30_000)
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id_c = manager.start("c", &config_c)?;
}
Dependency timeout
If dependencies are not Running within dependency_timeout_ms, the process transitions to Failed.
#![allow(unused)]
fn main() {
let config = ProcessConfig::builder()
.command("my-service".to_string())
.depends_on(vec![DependencyRef::label("missing")])
.dependency_timeout_ms(5_000) // Fail after 5 seconds
.stdout(StdioConfig::Null)
.stderr(StdioConfig::Null)
.build();
let id = manager.start("dependent", &config)?;
// Process enters Waiting, then transitions to Failed after 5 seconds
}