src/process_compat.rs

//! Cross-platform process management helpers. //! //! Abstracts Unix-specific APIs (killpg, process_group, sh -c) behind a //! platform-neutral interface so that music.rs and stream_publish.rs compile //! on both Unix and Windows without #[cfg] scattered through business logic.

use std::io; use std::process::Command;

use tracing::warn;

// ── Shell quoting ──────────────────────────────────────────────────────────

/// Quote a string for embedding in a shell pipeline command. /// /// - Unix: wraps in single quotes with escaped interior single-quotes. /// - Windows: wraps in double quotes with escaped interior double-quotes. #[cfg(unix)] pub(crate) fn shell_quote(s: &str) -> String { let escaped = s.replace(''', "'\''"); format!("'{escaped}'") }

#[cfg(windows)] pub(crate) fn shell_quote(s: &str) -> String { // cmd.exe recognises double-quote delimiters; interior double quotes are // escaped with a backslash for the child process. let escaped = s.replace('"', "\""); format!(""{escaped}"") }

/// Quote a string intended as a single-quoted literal argument embedded in a /// larger shell pipeline string (e.g. --extractor-args 'youtube:...'). /// /// On Unix this produces 'value'; on Windows it produces "value". #[cfg(unix)] pub(crate) fn shell_quote_arg(s: &str) -> String { let escaped = s.replace(''', "'\''"); format!("'{escaped}'") }

#[cfg(windows)] pub(crate) fn shell_quote_arg(s: &str) -> String { let escaped = s.replace('"', "\""); format!(""{escaped}"") }

// ── Shell spawning ─────────────────────────────────────────────────────────

/// Create a Command that runs pipeline through the platform shell. /// /// - Unix: sh -c "pipeline" /// - Windows: cmd /c "pipeline" /// /// The returned Command already has a new process group configured so that /// the entire child tree can be signalled together. pub(crate) fn shell_command(pipeline: &str) -> Command { #[cfg(unix)] { use std::os::unix::process::CommandExt as _; let mut cmd = Command::new("sh"); cmd.process_group(0); cmd.args(["-c", pipeline]); cmd } #[cfg(windows)] { use std::os::windows::process::CommandExt as _; // CREATE_NEW_PROCESS_GROUP so we can send CTRL_BREAK to the tree. const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; let mut cmd = Command::new("cmd"); cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); cmd.args(["/c", pipeline]); cmd } }

// ── Process signal abstraction ─────────────────────────────────────────────

/// Signals that music.rs and stream_publish.rs need to send. #[derive(Debug, Clone, Copy)] pub(crate) enum ProcessSignal { /// Graceful termination (SIGTERM / TerminateProcess). Terminate, /// Suspend execution (SIGSTOP / NtSuspendProcess). Suspend, /// Resume execution (SIGCONT / NtResumeProcess). Resume, }

/// Send a signal to a process group rooted at pid. /// /// On Unix this uses killpg. On Windows it uses platform APIs: /// - TerminateTerminateProcess via the kernel32 handle. /// - Suspend/Resume → no-op with a warning (not supported on Windows /// without NtSuspendProcess which requires undocumented ntdll APIs). pub(crate) fn signal_process_group(pid: u32, signal: ProcessSignal) -> io::Result<()> { if pid == 0 { return Ok(()); }

#[cfg(unix)]
{
    let sig = match signal {
        ProcessSignal::Terminate => libc::SIGTERM,
        ProcessSignal::Suspend => libc::SIGSTOP,
        ProcessSignal::Resume => libc::SIGCONT,
    };
    // SAFETY: pid originates from Child::id(), the child was spawned with
    // process_group(0), and we guard against pid==0 above.
    #[allow(unsafe_code, clippy::cast_possible_wrap)]
    let rc = unsafe { libc::killpg(pid as libc::pid_t, sig) };
    if rc == 0 {
        Ok(())
    } else {
        Err(io::Error::last_os_error())
    }
}

#[cfg(windows)]
{
    match signal {
        ProcessSignal::Terminate => {
            // Open the process and terminate it. Also send CTRL_BREAK to
            // the process group so piped children (ffmpeg, yt-dlp) exit.
            terminate_process_tree_win32(pid)
        }
        ProcessSignal::Suspend => {
            warn!(pid, "process suspend not implemented on Windows; ignoring");
            Ok(())
        }
        ProcessSignal::Resume => {
            warn!(pid, "process resume not implemented on Windows; ignoring");
            Ok(())
        }
    }
}

}

/// Terminate a process and its children on Windows. /// /// Sends CTRL_BREAK to the process group first (graceful), then falls back /// to TerminateProcess on the root pid. #[cfg(windows)] fn terminate_process_tree_win32(pid: u32) -> io::Result<()> { use std::io::Error;

// First try CTRL_BREAK_EVENT to the process group (only works if the
// child was created with CREATE_NEW_PROCESS_GROUP).
#[allow(unsafe_code)]
unsafe {
    // GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT=1, pid)
    unsafe extern "system" {
        fn GenerateConsoleCtrlEvent(dw_ctrl_event: u32, dw_process_group_id: u32) -> i32;
    }
    let _ = GenerateConsoleCtrlEvent(1, pid);
}

// Give the process a short window to exit gracefully, then force-kill.
std::thread::sleep(std::time::Duration::from_millis(200));

#[allow(unsafe_code)]
unsafe {
    unsafe extern "system" {
        fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> *mut std::ffi::c_void;
        fn TerminateProcess(h_process: *mut std::ffi::c_void, u_exit_code: u32) -> i32;
        fn CloseHandle(h_object: *mut std::ffi::c_void) -> i32;
    }
    const PROCESS_TERMINATE: u32 = 0x0001;
    let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
    if handle.is_null() {
        // Process already exited — not an error.
        return Ok(());
    }
    let rc = TerminateProcess(handle, 1);
    CloseHandle(handle);
    if rc == 0 {
        let err = Error::last_os_error();
        // ERROR_ACCESS_DENIED (5) often means the process already exited.
        if err.raw_os_error() == Some(5) {
            return Ok(());
        }
        return Err(err);
    }
}
Ok(())

}

/// Helper: send [ProcessSignal::Terminate] to a child process, logging on /// failure (mirrors the old terminate_music_child / terminate_stream_publish_child). pub(crate) fn terminate_child(child: &mut std::process::Child, context: &str) { if let Err(error) = signal_process_group(child.id(), ProcessSignal::Terminate) { if error.kind() != io::ErrorKind::NotFound { warn!(pid = child.id(), error = %error, "{context}: failed to signal process group"); } } }