Skip to main content

fresh_plugin_runtime/
process.rs

1//! Plugin Process Spawning: Async process execution for plugins
2//!
3//! This module enables plugins to spawn external processes asynchronously,
4//! capturing stdout/stderr and notifying via callbacks when complete.
5
6use fresh_core::api::PluginAsyncMessage as AsyncMessage;
7use std::process::Stdio;
8use std::sync::mpsc;
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::Command;
11
12/// Consume and discard a `Result` from a fire-and-forget channel send.
13///
14/// Use when the receiver may have been dropped (e.g. during shutdown) and
15/// failure is expected and non-actionable.
16fn fire_and_forget<E: std::fmt::Debug>(result: Result<(), E>) {
17    if let Err(e) = result {
18        tracing::trace!(error = ?e, "fire-and-forget send failed");
19    }
20}
21
22/// Spawn an external process for a plugin
23///
24/// This function:
25/// 1. Spawns the process asynchronously
26/// 2. Captures all stdout and stderr
27/// 3. Waits for process completion
28/// 4. Sends results back via AsyncBridge with process_id for callback matching
29///
30/// # Arguments
31/// * `process_id` - Unique ID to match with callback
32/// * `command` - Command to execute (e.g., "git")
33/// * `args` - Command arguments
34/// * `cwd` - Optional working directory
35/// * `sender` - Channel to send results back to main loop
36pub async fn spawn_plugin_process(
37    process_id: u64,
38    command: String,
39    args: Vec<String>,
40    cwd: Option<String>,
41    sender: mpsc::Sender<AsyncMessage>,
42) {
43    // Build the command
44    let mut cmd = Command::new(&command);
45    cmd.args(&args);
46    cmd.stdout(Stdio::piped());
47    cmd.stderr(Stdio::piped());
48
49    // On Windows, suppress the transient console window that the OS
50    // would otherwise flash for a console-subsystem child spawned from
51    // the GUI editor. CREATE_NO_WINDOW = 0x0800_0000.
52    #[cfg(windows)]
53    cmd.creation_flags(0x0800_0000);
54
55    // Set working directory if provided
56    if let Some(ref dir) = cwd {
57        cmd.current_dir(dir);
58    }
59
60    // Spawn the process
61    let mut child = match cmd.spawn() {
62        Ok(child) => child,
63        Err(e) => {
64            // Failed to spawn - send error result
65            fire_and_forget(sender.send(AsyncMessage::ProcessOutput {
66                process_id,
67                stdout: String::new(),
68                stderr: format!("Failed to spawn process: {}", e),
69                exit_code: -1,
70            }));
71            return;
72        }
73    };
74
75    // Capture stdout and stderr
76    let stdout_handle = child.stdout.take();
77    let stderr_handle = child.stderr.take();
78
79    // Read stdout
80    let stdout_future = async {
81        if let Some(stdout) = stdout_handle {
82            let reader = BufReader::new(stdout);
83            let mut lines = reader.lines();
84            let mut output = String::new();
85
86            while let Ok(Some(line)) = lines.next_line().await {
87                output.push_str(&line);
88                output.push('\n');
89            }
90            output
91        } else {
92            String::new()
93        }
94    };
95
96    // Read stderr
97    let stderr_future = async {
98        if let Some(stderr) = stderr_handle {
99            let reader = BufReader::new(stderr);
100            let mut lines = reader.lines();
101            let mut output = String::new();
102
103            while let Ok(Some(line)) = lines.next_line().await {
104                output.push_str(&line);
105                output.push('\n');
106            }
107            output
108        } else {
109            String::new()
110        }
111    };
112
113    // Wait for both outputs concurrently
114    let (stdout, stderr) = tokio::join!(stdout_future, stderr_future);
115
116    // Wait for process to complete
117    let exit_code = match child.wait().await {
118        Ok(status) => status.code().unwrap_or(-1),
119        Err(_) => -1,
120    };
121
122    // Send results back to main loop
123    fire_and_forget(sender.send(AsyncMessage::ProcessOutput {
124        process_id,
125        stdout,
126        stderr,
127        exit_code,
128    }));
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[tokio::test]
136    async fn test_spawn_simple_command() {
137        let (sender, receiver) = mpsc::channel();
138
139        spawn_plugin_process(
140            1,
141            "echo".to_string(),
142            vec!["hello".to_string()],
143            None,
144            sender,
145        )
146        .await;
147
148        let msg = receiver.recv().unwrap();
149        match msg {
150            AsyncMessage::ProcessOutput {
151                process_id,
152                stdout,
153                stderr,
154                exit_code,
155            } => {
156                assert_eq!(process_id, 1);
157                assert!(stdout.contains("hello"));
158                assert_eq!(stderr, "");
159                assert_eq!(exit_code, 0);
160            }
161            _ => panic!("Expected PluginProcessOutput"),
162        }
163    }
164
165    #[tokio::test]
166    async fn test_spawn_with_args() {
167        let (sender, receiver) = mpsc::channel();
168
169        spawn_plugin_process(
170            2,
171            "printf".to_string(),
172            vec![
173                "%s %s".to_string(),
174                "hello".to_string(),
175                "world".to_string(),
176            ],
177            None,
178            sender,
179        )
180        .await;
181
182        let msg = receiver.recv().unwrap();
183        match msg {
184            AsyncMessage::ProcessOutput {
185                process_id,
186                stdout,
187                exit_code,
188                ..
189            } => {
190                assert_eq!(process_id, 2);
191                assert!(stdout.contains("hello world"));
192                assert_eq!(exit_code, 0);
193            }
194            _ => panic!("Expected PluginProcessOutput"),
195        }
196    }
197
198    #[tokio::test]
199    async fn test_spawn_nonexistent_command() {
200        let (sender, receiver) = mpsc::channel();
201
202        spawn_plugin_process(
203            3,
204            "this_command_does_not_exist_12345".to_string(),
205            vec![],
206            None,
207            sender,
208        )
209        .await;
210
211        let msg = receiver.recv().unwrap();
212        match msg {
213            AsyncMessage::ProcessOutput {
214                process_id,
215                stdout,
216                stderr,
217                exit_code,
218            } => {
219                assert_eq!(process_id, 3);
220                assert_eq!(stdout, "");
221                assert!(stderr.contains("Failed to spawn"));
222                assert_eq!(exit_code, -1);
223            }
224            _ => panic!("Expected PluginProcessOutput"),
225        }
226    }
227
228    #[tokio::test]
229    async fn test_spawn_failing_command() {
230        let (sender, receiver) = mpsc::channel();
231
232        // Use a command that will fail
233        spawn_plugin_process(
234            4,
235            "sh".to_string(),
236            vec!["-c".to_string(), "exit 42".to_string()],
237            None,
238            sender,
239        )
240        .await;
241
242        let msg = receiver.recv().unwrap();
243        match msg {
244            AsyncMessage::ProcessOutput {
245                process_id,
246                exit_code,
247                ..
248            } => {
249                assert_eq!(process_id, 4);
250                assert_eq!(exit_code, 42);
251            }
252            _ => panic!("Expected PluginProcessOutput"),
253        }
254    }
255}