1use std::collections::HashMap;
7
8use anyhow::Context as _;
9use clap::Args;
10use thiserror::Error;
11use xianyun_core::config::global::Context;
12use xianyun_core::config::latest::{PROCESS_CONTEXT_MIRRORD, ProcessConfig};
13
14pub const COMMAND_NAME: &str = "edit-mode-process-supervisor";
15
16pub const DEFAULT_PROCESS_CONTEXT: &str = PROCESS_CONTEXT_MIRRORD;
18
19#[derive(Args, Debug)]
20pub struct EditModeProcessSupervisorArgs {
21 #[arg(long = "reloader", required = true)]
23 pub reloader: String,
24
25 #[arg(long = "logs-dir")]
27 pub logs_dir: Option<String>,
28
29 #[arg(long = "context", default_value = DEFAULT_PROCESS_CONTEXT)]
31 pub context: String,
32}
33
34#[derive(Debug, Error, PartialEq, Eq, Clone)]
36pub enum SupervisorError {
37 #[error("reloader not found: {0:?}")]
38 ReloaderNotFound(String),
39 #[error("reloader has no edit mode processes configured: {0:?}")]
40 NoEditModeProcesses(String),
41 #[error("repository not found: {0:?}")]
42 RepositoryNotFound(String),
43 #[error("process exited abnormally")]
44 ProcessFailed,
45}
46
47#[derive(Debug, Error, PartialEq, Eq, Clone, Copy)]
54pub enum ShellwordsError {
55 #[error("unmatched quote")]
56 UnmatchedQuote,
57}
58
59pub fn split_command(input: &str) -> Result<Vec<String>, ShellwordsError> {
63 #[derive(PartialEq)]
64 enum State {
65 Normal,
66 Word,
67 Single,
68 Double,
69 }
70
71 let mut state = State::Normal;
72 let mut words = Vec::new();
73 let mut current = String::new();
74 let mut chars = input.chars();
75
76 while let Some(c) = chars.next() {
77 match state {
78 State::Normal | State::Word => match c {
79 _ if c.is_whitespace() && state == State::Word => {
80 words.push(std::mem::take(&mut current));
81 state = State::Normal;
82 }
83 _ if c.is_whitespace() => {}
84 '\'' => state = State::Single,
85 '"' => state = State::Double,
86 '\\' => {
87 if let Some(next) = chars.next() {
88 current.push(next);
89 }
90 state = State::Word;
91 }
92 _ => {
93 current.push(c);
94 state = State::Word;
95 }
96 },
97 State::Single => {
98 if c == '\'' {
99 state = State::Word;
100 } else {
101 current.push(c);
102 }
103 }
104 State::Double => {
105 if c == '"' {
106 state = State::Word;
107 } else {
108 current.push(c);
109 }
110 }
111 }
112 }
113
114 match state {
115 State::Normal => {}
116 State::Word => words.push(current),
117 State::Single | State::Double => return Err(ShellwordsError::UnmatchedQuote),
118 }
119
120 Ok(words)
121}
122
123#[derive(Debug, Clone)]
140pub struct ProcessOptions {
141 pub working_dir: String,
142}
143
144#[must_use]
146pub fn process_options(working_dir: &str) -> ProcessOptions {
147 ProcessOptions {
148 working_dir: working_dir.to_string(),
149 }
150}
151
152#[derive(Debug, Clone, Default)]
157pub struct CmdStatus {
158 pub error: Option<String>,
159}
160
161pub struct SupervisedCmd {
172 name: String,
173 args: Vec<String>,
174 working_dir: String,
175}
176
177#[must_use]
179pub fn new_cmd(name: &str, args: &[String], opts: ProcessOptions) -> SupervisedCmd {
180 SupervisedCmd {
181 name: name.to_string(),
182 args: args.to_vec(),
183 working_dir: opts.working_dir,
184 }
185}
186
187impl SupervisedCmd {
188 pub fn start(
212 self,
213 ) -> std::io::Result<(
214 std::sync::mpsc::Receiver<String>,
215 std::sync::mpsc::Receiver<CmdStatus>,
216 )> {
217 use std::io::BufRead as _;
218 use std::process::{Command, Stdio};
219 use std::sync::mpsc;
220
221 let mut command = Command::new(&self.name);
222 command.args(&self.args);
223 if !self.working_dir.is_empty() {
224 command.current_dir(&self.working_dir);
225 }
226 command.stdin(Stdio::null());
227 command.stdout(Stdio::piped());
228 command.stderr(Stdio::null());
229
230 let mut child = command.spawn()?;
231 let stdout = child.stdout.take().expect("stdout was piped");
232
233 let (line_tx, line_rx) = mpsc::channel::<String>();
234 let (status_tx, status_rx) = mpsc::channel::<CmdStatus>();
235
236 std::thread::spawn(move || {
237 let mut reader = std::io::BufReader::new(stdout);
238 let mut buf = Vec::new();
239
240 loop {
241 buf.clear();
242 match reader.read_until(b'\n', &mut buf) {
243 Ok(0) | Err(_) => break,
244 Ok(_) => {
245 if buf.last() == Some(&b'\n') {
246 buf.pop();
247 }
248 let line = String::from_utf8_lossy(&buf).into_owned();
249 if line_tx.send(line).is_err() {
250 break;
251 }
252 }
253 }
254 }
255 drop(line_tx);
256
257 let error = match child.wait() {
258 Ok(exit_status) if exit_status.success() => None,
259 Ok(exit_status) => Some(format!("exit status {exit_status}")),
260 Err(error) => Some(error.to_string()),
261 };
262
263 let _ = status_tx.send(CmdStatus { error });
264 });
265
266 Ok((line_rx, status_rx))
267 }
268}
269
270struct NamedProcess {
272 name: String,
273 config: ProcessConfig,
274}
275
276pub fn run(args: &EditModeProcessSupervisorArgs, ctx: &Context) -> anyhow::Result<()> {
277 run_supervisor(
278 ctx,
279 &args.reloader,
280 args.logs_dir.as_deref().unwrap_or(""),
281 &args.context,
282 )
283}
284
285pub fn run_supervisor(
298 ctx: &Context,
299 reloader_name: &str,
300 logs_dir: &str,
301 proc_context: &str,
302) -> anyhow::Result<()> {
303 let _ = logs_dir; xianyun_executor::tempfile::ensure_temp_dir_exists().context("creating temporary directory")?;
306
307 let container =
308 xianyun_core::config::caproni::load_from_context(ctx).context("loading configuration")?;
309
310 let reloader_config = container
311 .caproni
312 .reloaders
313 .as_ref()
314 .and_then(|reloaders| reloaders.get(&reloader_name.to_string()))
315 .ok_or_else(|| SupervisorError::ReloaderNotFound(reloader_name.to_string()))?;
316
317 let edit_mode_processes = reloader_config
318 .mirror
319 .as_ref()
320 .and_then(|mirror| mirror.edit_mode_processes.as_ref())
321 .ok_or_else(|| SupervisorError::NoEditModeProcesses(reloader_name.to_string()))?;
322
323 let repo_config = container
324 .caproni
325 .repositories
326 .as_ref()
327 .and_then(|repos| repos.get(&reloader_config.repository))
328 .ok_or_else(|| SupervisorError::RepositoryNotFound(reloader_config.repository.clone()))?;
329
330 let working_dir = container.caproni.resolve_path(&repo_config.directory);
331
332 let processes: Vec<NamedProcess> = edit_mode_processes
333 .iter()
334 .filter_map(|(name, proc_config)| {
335 let run_in = if proc_config.context.is_empty() {
336 PROCESS_CONTEXT_MIRRORD
337 } else {
338 proc_config.context.as_str()
339 };
340
341 if run_in == proc_context {
342 Some(NamedProcess {
343 name: name.clone(),
344 config: proc_config.clone(),
345 })
346 } else {
347 None
348 }
349 })
350 .collect();
351
352 if processes.is_empty() {
353 return Ok(());
354 }
355
356 supervise_all(&processes, &working_dir)
357}
358
359fn supervise_all(processes: &[NamedProcess], _working_dir: &str) -> anyhow::Result<()> {
363 for process in processes {
364 if process.config.port > 0
365 && xianyun_executor::portprobe::in_use(&format!("localhost:{}", process.config.port))
366 {
367 anyhow::bail!(
368 "cannot start {}: port {} already in use",
369 process.name,
370 process.config.port
371 );
372 }
373 }
374
375 for process in processes {
376 split_command(&process.config.command).map_err(|error| {
377 anyhow::anyhow!("invalid command for process {:?}: {error}", process.name)
378 })?;
379 }
380
381 anyhow::bail!(
382 "supervising edit mode processes requires an overseer-equivalent process supervisor, \
383 which isn't implemented in this worktree yet"
384 );
385}
386
387#[derive(Debug, Clone, Default)]
389pub struct ProcessStatus {
390 pub state: String,
391 pub exit_code: i32,
392 pub error: Option<String>,
393}
394
395pub fn collect_process_errors(
400 statuses: &HashMap<String, ProcessStatus>,
401 names: &[String],
402) -> anyhow::Result<()> {
403 let mut messages = Vec::new();
404
405 for name in names {
406 let Some(status) = statuses.get(name) else {
407 continue;
408 };
409
410 match status.state.as_str() {
411 "finished" if status.exit_code != 0 => {
412 messages.push(format!(
413 "process {name:?} exit code {}: {}",
414 status.exit_code,
415 SupervisorError::ProcessFailed
416 ));
417 }
418 "fatal" => {
419 if let Some(error) = &status.error {
420 messages.push(format!(
421 "process {name:?}: {error}: {}",
422 SupervisorError::ProcessFailed
423 ));
424 } else {
425 messages.push(format!(
426 "process {name:?}: {}",
427 SupervisorError::ProcessFailed
428 ));
429 }
430 }
431 _ => {}
432 }
433 }
434
435 if messages.is_empty() {
436 return Ok(());
437 }
438
439 anyhow::bail!(messages.join("\n"));
440}