Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-wrot used a launch_application() function that spawned a child, set WAYLAND_DISPLAY, and returned a Child handle - but had no tracking, no reaper, and no graceful shutdown.
  • smearor-swipe-launcher used a TrackedProcess struct with DashMap-based tracking, manual /proc/{pid} polling for exit detection, and nix::sys::signal::kill for termination - duplicated across both terminal_command and app-launcher services.

This workspace consolidates that logic into two reusable, framework-agnostic crates:

  1. process-manager-socket - Wayland socket path management with Socket, SocketBuilder, and SocketManager
  2. process-manager - Child process lifecycle management with ProcessConfig, ProcessManager, and an optional reaper thread

Key Benefits

  • No duplicate code - Both projects share the same ProcessManager instead 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_exit flag ensures processes are killed when the manager is dropped
  • Signal escalation - SIGTERM with configurable timeout, automatic SIGKILL escalation for stubborn processes
  • Explicit lifecycle states - ProcessState enum (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() via pre_exec for processes that should survive parent exit
  • Wayland socket binding - Automatically sets WAYLAND_DISPLAY in 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 - Uses SocketManager for multi-output Wayland sockets and ProcessManager for spawning compositor clients.
  • smearor-swipe-launcher - Uses ProcessManager in its terminal_command and app-launcher services 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

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 with Socket, SocketBuilder, and SocketManager
  • process-manager - Child process lifecycle management with ProcessConfig, 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 - A PathBuf newtype representing a Wayland socket path. Implements Deref<Target = Path>, Display, AsRef<OsStr>, and AsRef<str>.
  • SocketBuilder - Constructs socket paths in XDG_RUNTIME_DIR. If a name is provided, it validates uniqueness. If no name is provided, it auto-generates a unique name like wayland-{N}.
  • SocketManager - A concurrent multi-socket manager using DashMap. Sockets are registered by name and can be retrieved, removed, or listed. Shareable via Arc across threads.

2. Process Management (process-manager)

The process crate provides the ProcessManager as its central component:

  • ProcessConfig - Built via TypedBuilder. 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 a DashMap keyed by ProcessId. Supports label-based grouping, concurrent start/stop operations, and an optional reaper thread.
  • Process - A handle to a managed child process. Contains the ProcessId, PID, label, config, and the std::process::Child handle.
  • ProcessExitEvent - Emitted by the reaper thread when a process exits. Contains id, pid, label, restart_on_exit, exit_status, and state (Stopped or Crashed).

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 - PathBuf newtype with path() accessor, Deref<Target = Path>, Display, AsRef<OsStr>, AsRef<str>, Serialize/Deserialize implementations
  • SocketBuilder - Builds socket paths in XDG_RUNTIME_DIR, validates existing names or generates unique ones
  • SocketManager - Multi-socket manager using DashMap, shareable via Arc across threads
  • SocketBuilderError - Error type for socket construction failures
  • SocketManagerError - 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&lt;String, Socket&gt;</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 for SocketManager
  • thiserror - error types
  • serde - Serialize/Deserialize for Socket

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:

  1. Type safety - Functions that expect a Wayland socket can take &Socket instead of &Path, preventing accidental misuse with arbitrary paths.
  2. Ergonomic trait implementations - AsRef<str> and Display make it easy to extract the socket name (e.g. wayland-0) for setting WAYLAND_DISPLAY in child environments.

Accessing the Path

The inner PathBuf is private. Use one of these methods to access it:

Method / TraitReturnsPurpose
path()&PathDirect accessor for the socket path
Deref<Target = Path>&PathImplicit deref for filesystem operations
DisplayStringFormat the path as a string
AsRef<OsStr>&OsStrInterop with std::ffi::OsStr
AsRef<str>&strString access for environment variable names
From<PathBuf>SocketConstruct 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 in XDG_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

ErrorWhen
SocketBuilderError::XdgRuntimeDirNotSetXDG_RUNTIME_DIR environment variable is not set
SocketBuilderError::SocketAlreadyExistsA 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

MethodReturnsDescription
new()SocketManagerCreate 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()boolCheck if manager has no sockets
len()usizeNumber 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&lt;String, Socket&gt;</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

ErrorWhen
SocketManagerError::AlreadyRegisteredA socket with the given name has already been registered

Error Types

SocketBuilderError

Errors that can occur when building a socket path via SocketBuilder::build().

VariantWhenDescription
XdgRuntimeDirNotSetbuild()XDG_RUNTIME_DIR environment variable is not set - the user session may not be properly initialized
SocketAlreadyExistsbuild()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.

VariantWhenDescription
AlreadyRegisteredregister()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 via TypedBuilder (command, args, env, working_dir, shell, forked, terminate_on_exit, kill_signal, restart_on_exit, stdio, socket)
  • ProcessManager - Concurrent process tracking via DashMap, label-based grouping, optional reaper thread
  • Process / ProcessId / ProcessInfo - Process handle, unique identifier, and lightweight snapshot type
  • ProcessState - Explicit lifecycle state enum (Starting, Running, Stopping, Stopped, Crashed, Restarting, Failed)
  • ProcessExitEvent - Reaper exit notification with id, pid, label, restart_on_exit, exit_status, state
  • StdioConfig - Inherit/Null/Piped enum for standard streams
  • KillSignal - Sigterm/Sigkill enum for termination config
  • Signal - Broader signal enum (SIGHUP, SIGUSR1, SIGSTOP, etc.) for general process control
  • ProcessManagerError / 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

  • ProcessConfig with TypedBuilder - Compile-time enforcement of required fields, ergonomic optional fields
  • Label-based grouping - Start/stop multiple processes under a shared label
  • Forked/detached processes - setsid() via pre_exec for terminal detachment
  • Reaper thread - Non-blocking try_wait() polling with ProcessExitEvent channel
  • Signal escalation - SIGTERM with configurable timeout, automatic SIGKILL escalation
  • Wayland socket binding - Sets WAYLAND_DISPLAY from Socket
  • StdioConfig - Inherit/Null/Piped with reader threads for output capture
  • KillSignal - Sigterm/Sigkill with serde support for termination config
  • Signal - Broader signal enum for general process control via send_signal()
  • Restart - restart() / restart_label() preserve config and label across restarts
  • Serde - ProcessConfig, StdioConfig, KillSignal, and Signal implement Serialize/Deserialize
  • Executable resolution - which integration for PATH lookup
  • Graceful shutdown - terminate_on_exit flag kills processes on drop
  • #[must_use] - All public structs and enums are #[must_use]

Dependencies

CratePurpose
dashmapConcurrent process tracking
nixSignal handling (SIGTERM, SIGKILL)
libcsetsid() for forked processes
typed-builderProcessConfig builder pattern
whichExecutable path resolution
process-manager-socketWayland socket binding
thiserrorError types
tracingLogging

See the individual pages for detailed documentation:

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

FieldTypeDefaultDescription
commandString(required)Program name or path. Resolved via which if not absolute.
argsVec<String>vec![]Command-line arguments passed to the program
envHashMap<String, String>HashMap::new()Additional environment variables merged into the child’s environment
working_dirOption<PathBuf>NoneWorking directory for the child process
shellboolfalseRun command via sh -c instead of direct execution
forkedboolfalseDetach via setsid() in pre_exec - process gets its own session
terminate_on_exitboolfalseKill this process when ProcessManager is dropped
kill_signalKillSignalSigtermSignal to send on termination (Sigterm or Sigkill)
terminate_timeout_msu645000Grace period (ms) before escalating from SIGTERM to SIGKILL
restart_on_exitboolfalseEnable automatic restart on exit (requires reaper thread)
restart_triggerRestartTriggerCrashOnlyWhen to restart: CrashOnly (non-zero exit) or Always (any exit)
restart_policyRestartPolicyImmediateRestart strategy: Immediate or Backoff(BackoffConfig)
supervisor_strategySupervisorStrategyOneForOneControls which processes are restarted on crash: OneForOne, OneForAll, or RestForOne
depends_onVec<DependencyRef>vec![]Dependencies to wait for before starting. DependencyRef::Label or DependencyRef::Id
dependency_timeout_msu6430000Timeout (ms) for dependencies to become Running before failing
cascade_stopbooltrueWhen true, stopping a process also stops its dependents
stdinStdioConfigNullStandard input configuration
stdoutStdioConfigNullStandard output configuration
stderrStdioConfigNullStandard error configuration
socketOption<Socket>NoneWayland 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. Use shell = true only when you need shell features (pipes, redirects, variable expansion).
  • forked = false: By default, processes share the parent’s controlling terminal. Use forked = true for daemons that should survive terminal close.
  • terminate_on_exit = false: By default, processes are left running when the manager is dropped. Use true for processes that should be cleaned up with the manager.
  • kill_signal = Sigterm: Graceful termination by default. The process can catch SIGTERM and clean up.
  • terminate_timeout_ms = 5000: 5 seconds grace period before SIGKILL. Adjust for processes that need more cleanup time.
  • restart_on_exit = false: By default, processes are not automatically restarted. Enable with restart_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. Use Always to also restart on clean exits.
  • restart_policy = Immediate: By default, restarts happen immediately. Use Backoff(BackoffConfig) for exponential backoff with rate limiting.
  • stdio = Null: All streams are null by default, suitable for background processes. Use Piped for output capture or Inherit for 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 DashMap keyed by ProcessId, 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 emit ProcessExitEvents
  • Signal-based termination - SIGTERM with configurable timeout, automatic SIGKILL escalation
  • Graceful shutdown - Processes with terminate_on_exit = true are 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

MethodReturnsDescription
start(label, &config)Result<ProcessId, ProcessManagerError>Spawn a child process with the given label and config

start() performs the following:

  1. Resolves the executable via which if not an absolute path
  2. Constructs std::process::Command with env, working_dir, shell mode
  3. Applies setsid() via pre_exec if config.forked is true
  4. Sets WAYLAND_DISPLAY if config.socket is Some
  5. Configures stdio (spawns reader threads for Piped)
  6. Spawns the child and stores it in the DashMap

Stopping

MethodReturnsDescription
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

MethodReturnsDescription
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

MethodReturnsDescription
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

MethodReturnsDescription
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()boolCheck if manager has no processes
len()usizeNumber of tracked processes

Convenience Getters

MethodReturnsDescription
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:

  1. If the reaper thread is active, it is stopped (via stop_flag atomic) and joined
  2. All processes with terminate_on_exit = true are terminated (SIGTERM → wait → SIGKILL)
  3. Processes with terminate_on_exit = false are left running (detached)
  4. The DashMap is 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 number
  • PartialEq, Eq, Hash - comparison and hashing
  • Clone, 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

FieldTypeDescription
idProcessIdUnique identifier assigned by ProcessManager
pidu32OS process ID
program_nameStringProgram name (for error reporting)
labelLabelLabel under which the process was started
terminate_on_exitboolWhether to terminate on ProcessManager drop
configProcessConfigThe configuration this process was started with
childOption<Child>The std::process::Child handle (always Some)
stateProcessStateThe current lifecycle state (updated lazily by state(), explicitly by stop() / restart())

Methods

MethodReturnsDescription
is_running()boolNon-blocking check via try_wait() - true if still running (delegates to state().is_alive())
state()ProcessStateNon-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 string
  • PartialEq, Eq, Hash - comparison and hashing (usable as HashMap key)
  • Clone - lightweight clone (not Copy because it wraps String)
  • AsRef<str> - interoperability with &str APIs
  • From<&str>, From<String>, From<&String> - convenient construction
  • Serialize, 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

VariantDescription
StartingThe process is being spawned and has not yet been confirmed running. Transient state between spawn() and insertion into the manager.
RunningThe process is alive and running. Confirmed via try_wait() returning Ok(None).
WaitingThe process is queued but waiting for dependencies to become Running. No OS child process has been spawned yet. is_alive() returns true.
StoppingA stop signal has been sent and the manager is waiting for exit. Set by stop() / stop_many().
StoppedThe process has exited normally (exit code 0 or stopped by the manager within the grace period).
CrashedThe process exited unexpectedly with a non-zero exit code or signal.
RestartingA 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.
FailedThe 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

MethodReturnsDescription
is_alive()booltrue for Starting, Running, Waiting, Stopping, Restarting - equivalent to the old is_running() semantics
is_terminated()booltrue for Stopped, Crashed, Failed

Trait Implementations

  • Debug, Clone, Copy, PartialEq, Eq, Hash
  • Default - defaults to Starting
  • Display - 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 restart
  • restart_policy - Determines how to restart (immediate or with backoff)

RestartTrigger

VariantDescription
CrashOnlyRestart only on crashes (non-zero exit code or signal). Clean exits (exit(0)) are not restarted. Default.
AlwaysRestart on any exit, including clean exits. Useful for processes that should always be running.

RestartPolicy

VariantDescription
ImmediateRestart immediately on exit. No delay, no rate limiting. Default.
Backoff(BackoffConfig)Restart with exponential backoff and rate limiting.

BackoffConfig

FieldTypeDefaultDescription
initial_delayDuration1sInitial delay before first restart
multiplieru3220Multiplier applied to delay after each restart, in tenths (20 = 2.0x, 15 = 1.5x, 10 = 1.0x)
max_delayDuration60sMaximum delay cap (prevents unbounded growth)
max_restartsu325Maximum consecutive restarts before giving up
min_uptimeDuration10sUptime 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
11s
22s
34s
48s
516s
632s
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 uptime
  • last_started_at - When the process was last spawned
  • next_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:

OperationBehavior
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

StrategyDescription
OneForOne (default)Restart only the crashed process
OneForAllRestart all processes in the same label group
RestForOneRestart 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_sequence in 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 a ProcessId at start() time; the first Running process with that label is selected and the binding persists for the dependent’s lifetime
  • DependencyRef::id(process_id) - the process with the given ProcessId must be Running

Start flow

When start() is called with non-empty depends_on:

  1. The process is inserted into the DashMap with ProcessState::Waiting.
  2. A ProcessId is assigned immediately.
  3. The reaper loop checks Waiting processes on each poll cycle.
  4. Once all dependencies are Running, the process is spawned and transitions to Starting.
  5. If dependencies are not Running within dependency_timeout_ms, the process transitions to Failed.

ProcessState::Waiting

PhaseProcessState
Queued, waiting for depsWaiting
Dependencies ready, spawningStarting
Process runningRunning
Dependency timeoutFailed

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 Label dependency is resolved, the resulting ProcessId is stored in resolved_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 to tracing. 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 to SIGTERM/SIGKILL, used for process termination via stop()
  • [Signal] - Broader enum for general-purpose signaling via send_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

VariantSignalDescription
SigtermSIGTERM (15)Graceful termination - the process can catch and handle it
SigkillSIGKILL (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 on SIGTERM (save state, close connections, flush buffers). The configurable timeout gives them time to shut down gracefully.
  • Sigkill - Use for processes that don’t respond to SIGTERM or when you need immediate termination. SIGKILL cannot 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

VariantSignalDescription
SighupSIGHUP (1)Hang up - often used to reload configuration
SigintSIGINT (2)Interrupt (Ctrl+C)
SigquitSIGQUIT (3)Quit with core dump
SigtermSIGTERM (15)Graceful termination request
SigkillSIGKILL (9)Immediate forced termination, cannot be caught
Sigusr1SIGUSR1 (10)User-defined signal 1
Sigusr2SIGUSR2 (12)User-defined signal 2
SigwinchSIGWINCH (28)Window size change
SigstopSIGSTOP (19)Pause execution, cannot be caught
SigcontSIGCONT (18)Resume execution after SIGSTOP
SigalrmSIGALRM (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

AspectKillSignalSignal
VariantsSigterm, Sigkill11 common Unix signals
Used byProcessConfig::kill_signal, stop()send_signal(), send_signal_label()
PurposeTermination with escalationGeneral process control
Removes processYes (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:

  1. Spawns a std::thread named "process-reaper"
  2. Every poll_interval, iterates all tracked processes in the DashMap
  3. Phase 1: Detect exits - for each process (skip Restarting), check stable uptime reset, then call try_wait() to detect exits. If exited, determine state, check restart policy, emit event, and either remove or transition to Restarting. If restart is triggered and supervisor_strategy is OneForAll or RestForOne, collect cascade targets in the same label group.
  4. Phase 2: Cascade kill - send kill signals to cascade-flagged processes. Processes with cascade_flag = true skip supervisor strategy logic on their own exit.
  5. Phase 3: Set Restarting state for processes scheduled for restart.
  6. Phase 4: Check eligible restarts - for Restarting processes whose backoff has elapsed, spawn a new process in-place. If the process has unsatisfied dependencies, transition to Waiting instead of spawning.
  7. Phase 5: Check Waiting processes - resolve label dependencies, spawn when all deps are Running, fail-fast when a dependency is terminal, timeout if deps not ready within dependency_timeout_ms.
  8. Phase 6: Check Running processes for terminal dependencies - if a resolved dependency is removed or enters a terminal state, fail-fast the dependent process.
  9. Runs until ProcessManager is dropped (via a stop_flag AtomicBool)

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_interval delay 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_interval and iterates all processes. For a small number of processes (< 100), this is negligible.
  • Portability - try_wait() works on all Unix systems. pidfd_open is 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 shard
  • stop() 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

FieldTypeDescription
idProcessIdThe unique identifier of the exited process
pidu32The OS process ID (for logging/debugging)
labelStringThe label under which the process was started
restart_on_exitboolWhether the consumer should restart this process
exit_statusOption<ExitStatus>The exit status of the process (None if the child handle was missing or try_wait() returned an error)
stateProcessStateThe 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:

  1. The ProcessManager is dropped → the reaper thread stops → the mpsc::Sender is dropped
  2. receiver.recv() returns Err (sender disconnected) → the forwarding thread exits
  3. The async_sender is dropped (when the GTK MainContext task completes) → async_sender.send() returns Err → 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.

VariantWhenDescription
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:

  1. Create a ProcessManager - Either new() (no reaper) or with_reaper() (with exit notifications)
  2. Build a ProcessConfig - Using the TypedBuilder pattern with .command() as the only required field
  3. Start the process - manager.start(label, &config) returns a ProcessId
  4. 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

  1. ProcessManager::start() resolves echo via which (since it’s not an absolute path)
  2. A std::process::Command is built with the piped stdio configuration
  3. The child is spawned and stored in the DashMap under the label "echo-app"
  4. Reader threads are spawned for stdout and stderr, forwarding lines to tracing
  5. manager.stop(id) sends SIGTERM, waits up to 5 seconds, then escalates to SIGKILL if 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 Child handle is retained in the DashMap
  • try_wait() works for reaping and is_running() checks
  • stop() / 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 SIGHUP when 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:

  1. ProcessManager is dropped → reaper stops → mpsc::Sender dropped → recv() returns Err
  2. MainContext task completes → event_rx dropped → event_tx.send() returns Err

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;&quot;backend&quot;#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

MethodReturnsDescription
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):

  1. ProcessManager::start() extracts the socket name from the Socket path (e.g. wayland-0 from /run/user/1000/wayland-0)
  2. The socket name is inserted into the child’s environment as WAYLAND_DISPLAY=wayland-0
  3. 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 SIGHUP to trigger a reload
  • Pause/resume - Send SIGSTOP / SIGCONT
  • Custom protocols - Send SIGUSR1 / SIGUSR2 for application-specific events
  • Window resize - Send SIGWINCH to 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
}