folk-plugin-process 0.2.2

Auxiliary process supervisor plugin for Folk — starts, monitors, and restarts sidecar processes
Documentation
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use folk_plugin_process::config::{
    LoggingConfig, OutputTarget, ProcessDef, RestartPolicy, StopSignal,
};
use folk_plugin_process::supervisor::{ProcessStatus, ProcessSupervisor};
use tokio::sync::watch;

fn simple_def(name: &str, command: &str, restart: RestartPolicy) -> ProcessDef {
    ProcessDef {
        name: name.into(),
        command: command.into(),
        restart,
        max_restarts: 2,
        restart_delay: Duration::from_millis(10),
        ..Default::default()
    }
}

#[tokio::test]
async fn supervisor_restarts_on_failure() {
    let def = simple_def("test", "false", RestartPolicy::OnFailure);

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });
    tokio::time::sleep(Duration::from_millis(200)).await;

    let status = sup.status().await;
    assert!(
        matches!(status, ProcessStatus::Failed { .. }),
        "expected Failed, got {:?}",
        status
    );
}

#[tokio::test]
async fn supervisor_stops_on_shutdown() {
    let def = simple_def("sleeper", "sleep 60", RestartPolicy::Always);

    let (sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    sd_tx.send(true).unwrap();
    let _ = tokio::time::timeout(Duration::from_secs(10), handle).await;

    let status = sup.status().await;
    assert_eq!(status, ProcessStatus::Stopped);
}

#[tokio::test]
async fn supervisor_never_restart_exits_cleanly() {
    let def = simple_def("once", "true", RestartPolicy::Never);

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;

    let status = sup.status().await;
    assert!(matches!(status, ProcessStatus::Failed { .. }));
}

#[tokio::test]
async fn supervisor_env_vars() {
    // echo $TEST_VAR should produce output (non-empty exit 0)
    let mut env = HashMap::new();
    env.insert("TEST_VAR".into(), "hello_folk".into());

    let def = ProcessDef {
        name: "env_test".into(),
        command: "sh -c 'test \"$TEST_VAR\" = hello_folk'".into(),
        restart: RestartPolicy::Never,
        max_restarts: 0,
        restart_delay: Duration::from_millis(10),
        env,
        ..Default::default()
    };

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;

    // Exit code 0 + Never = Failed{restarts:0}, but the test passed (exit 0)
    let code = sup.last_exit_code().await;
    assert_eq!(code, Some(0));
}

#[tokio::test]
async fn supervisor_working_directory() {
    let tmp = std::env::temp_dir().join("folk_process_test_cwd.log");
    let _ = std::fs::remove_file(&tmp);

    let def = ProcessDef {
        name: "dir_test".into(),
        command: "pwd".into(),
        restart: RestartPolicy::Never,
        max_restarts: 0,
        restart_delay: Duration::from_millis(10),
        directory: Some("/usr".into()),
        logging: LoggingConfig {
            stdout: OutputTarget::File(tmp.clone()),
            stderr: OutputTarget::Inherit,
        },
        ..Default::default()
    };

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;

    let code = sup.last_exit_code().await;
    assert_eq!(code, Some(0));

    let content = std::fs::read_to_string(&tmp).unwrap_or_default();
    assert!(content.trim() == "/usr", "expected /usr, got: {content}");
    let _ = std::fs::remove_file(&tmp);
}

#[tokio::test]
async fn supervisor_stop_timeout_custom() {
    let def = ProcessDef {
        name: "timeout_test".into(),
        command: "sleep 60".into(),
        restart: RestartPolicy::Always,
        max_restarts: 5,
        restart_delay: Duration::from_millis(10),
        stop_timeout: Duration::from_secs(2),
        ..Default::default()
    };

    let (sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    sd_tx.send(true).unwrap();

    // Should stop within stop_timeout (2s) + buffer
    let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
    assert!(result.is_ok(), "supervisor didn't stop within timeout");

    assert_eq!(sup.status().await, ProcessStatus::Stopped);
}

#[tokio::test]
async fn supervisor_shell_words_parsing() {
    // Command with quoted args should parse correctly
    let def = ProcessDef {
        name: "quoted".into(),
        command: r#"sh -c "echo 'hello world'""#.into(),
        restart: RestartPolicy::Never,
        max_restarts: 0,
        restart_delay: Duration::from_millis(10),
        ..Default::default()
    };

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
    let code = sup.last_exit_code().await;
    assert_eq!(code, Some(0));
}

#[tokio::test]
async fn supervisor_stdout_null() {
    let def = ProcessDef {
        name: "null_out".into(),
        command: "echo suppressed".into(),
        restart: RestartPolicy::Never,
        max_restarts: 0,
        restart_delay: Duration::from_millis(10),
        logging: LoggingConfig {
            stdout: OutputTarget::Null,
            stderr: OutputTarget::Null,
        },
        ..Default::default()
    };

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
    let code = sup.last_exit_code().await;
    assert_eq!(code, Some(0));
}

#[tokio::test]
async fn supervisor_stdout_file() {
    let tmp = std::env::temp_dir().join("folk_process_test_stdout.log");
    let _ = std::fs::remove_file(&tmp);

    let def = ProcessDef {
        name: "file_out".into(),
        command: "echo file_test_output".into(),
        restart: RestartPolicy::Never,
        max_restarts: 0,
        restart_delay: Duration::from_millis(10),
        logging: LoggingConfig {
            stdout: OutputTarget::File(tmp.clone()),
            stderr: OutputTarget::Inherit,
        },
        ..Default::default()
    };

    let (_sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;

    let content = std::fs::read_to_string(&tmp).unwrap_or_default();
    assert!(
        content.contains("file_test_output"),
        "expected output in file, got: {content}"
    );

    let _ = std::fs::remove_file(&tmp);
}

#[tokio::test]
async fn supervisor_restart_request() {
    let def = ProcessDef {
        name: "restartable".into(),
        command: "sleep 60".into(),
        restart: RestartPolicy::Always,
        max_restarts: 5,
        restart_delay: Duration::from_millis(10),
        ..Default::default()
    };

    let (sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    assert_eq!(sup.status().await, ProcessStatus::Running);

    // Request restart
    sup.request_restart();
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Should still be running (restarted)
    assert_eq!(sup.status().await, ProcessStatus::Running);

    // Shutdown
    sd_tx.send(true).unwrap();
    let _ = tokio::time::timeout(Duration::from_secs(10), handle).await;
    assert_eq!(sup.status().await, ProcessStatus::Stopped);
}

#[tokio::test]
async fn supervisor_stop_signal_int() {
    let def = ProcessDef {
        name: "sig_test".into(),
        command: "sleep 60".into(),
        restart: RestartPolicy::Always,
        max_restarts: 5,
        restart_delay: Duration::from_millis(10),
        stop_signal: StopSignal::Int,
        ..Default::default()
    };

    let (sd_tx, sd_rx) = watch::channel(false);
    let sup = Arc::new(ProcessSupervisor::new(def));
    let sup_clone = sup.clone();

    let handle = tokio::spawn(async move {
        sup_clone.run(sd_rx, None).await;
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    sd_tx.send(true).unwrap();

    let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
    assert!(result.is_ok());
    assert_eq!(sup.status().await, ProcessStatus::Stopped);
}