Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ async fn run_final_command_for_tui_inner(
state: &Arc<Mutex<AppState>>,
cancel_rx: oneshot::Receiver<()>,
) -> anyhow::Result<()> {
let mut final_cmd = match crate::core::command::spawn_captured_group(command) {
let mut final_cmd = match crate::core::command::spawn_group(command, false) {
Ok(final_cmd) => final_cmd,
Err(error) => {
mark_final_failed_with_message(
Expand Down
62 changes: 13 additions & 49 deletions src/core/command.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use command_group::{AsyncCommandGroup, AsyncGroupChild};
use tokio::process::Child;
use tokio::task::JoinHandle;

use std::sync::{Arc, Mutex};
Expand All @@ -9,14 +8,9 @@ use crate::core::server::{LOG_CAPACITY, build_command};
use crate::core::state::RingBuffer;

/// A spawned final command plus its captured output log.
pub struct FinalCommand {
pub child: Child,
#[allow(dead_code)] // read by AppState in Task 9
pub log: Arc<Mutex<RingBuffer>>,
pub readers: Vec<JoinHandle<()>>,
}

/// A captured final command spawned as a process group for TUI cancellation.
///
/// Always a process group, so the command and every descendant it spawned can
/// be killed together on shutdown.
pub struct FinalCommandGroup {
child: AsyncGroupChild,
pub log: Arc<Mutex<RingBuffer>>,
Expand All @@ -28,60 +22,30 @@ impl FinalCommandGroup {
self.child.wait().await
}

/// Kill the whole process group, descendants included.
pub async fn cancel(&mut self) -> std::io::Result<()> {
self.child.kill().await
}
}

/// Spawn the final command (NOT as a process group — matches today's `Command::spawn`).
pub fn spawn(command: &str) -> anyhow::Result<FinalCommand> {
spawn_inner(command, true)
}

#[allow(dead_code)] // used by TUI command execution in Task 7
pub fn spawn_captured(command: &str) -> anyhow::Result<FinalCommand> {
spawn_inner(command, false)
}

#[allow(dead_code)] // used by TUI command execution
pub fn spawn_captured_group(command: &str) -> anyhow::Result<FinalCommandGroup> {
/// Spawn the final command as a process group.
///
/// `tee_output` mirrors the child's output to the real stdout/stderr for plain
/// mode; the TUI captures only and renders the log itself.
pub fn spawn_group(command: &str, tee_output: bool) -> anyhow::Result<FinalCommandGroup> {
let mut cmd = build_command(command)?;
let mut child = cmd.group_spawn()?;
let log = Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY)));
let mut readers = Vec::new();

if let Some(stdout) = child.inner().stdout.take() {
readers.push(spawn_reader(stdout, Arc::clone(&log), false, false));
}
if let Some(stderr) = child.inner().stderr.take() {
readers.push(spawn_reader(stderr, Arc::clone(&log), true, false));
}

Ok(FinalCommandGroup {
child,
log,
readers,
})
}

fn spawn_inner(command: &str, tee_output: bool) -> anyhow::Result<FinalCommand> {
let mut cmd = build_command(command)?;
if !tee_output {
cmd.kill_on_drop(true);
}

let mut child = cmd.spawn()?;
let log = Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY)));
let mut readers = Vec::new();

if let Some(stdout) = child.stdout.take() {
readers.push(spawn_reader(stdout, Arc::clone(&log), false, tee_output));
}
if let Some(stderr) = child.stderr.take() {
if let Some(stderr) = child.inner().stderr.take() {
readers.push(spawn_reader(stderr, Arc::clone(&log), true, tee_output));
}

Ok(FinalCommand {
Ok(FinalCommandGroup {
child,
log,
readers,
Expand All @@ -94,8 +58,8 @@ mod tests {

#[tokio::test]
async fn spawn_captured_records_output_lines() {
let mut command = spawn_captured("sh -c 'echo out; echo err >&2'").unwrap();
let status = command.child.wait().await.unwrap();
let mut command = spawn_group("sh -c 'echo out; echo err >&2'", false).unwrap();
let status = command.wait().await.unwrap();
for reader in command.readers {
let _ = reader.await;
}
Expand Down
129 changes: 89 additions & 40 deletions src/runner/plain.rs
Original file line number Diff line number Diff line change
@@ -1,68 +1,117 @@
use anyhow::Context;
use tokio::sync::watch;

use std::time::Duration;

use crate::config::Config;
use crate::config::{Config, Server};
use crate::core::Engine;
use crate::core::state::ServerStatus;

/// Drive the engine in plain-log mode.
/// Exit semantics match the legacy tool exactly.
pub async fn run(config: Config, max_attempts: u8) -> anyhow::Result<()> {
// Registered before the servers start, so a Ctrl+C arriving during startup
// is already observable.
let mut shutdown = shutdown_signal();

let Config { servers, command } = config;
let mut engine = Engine::start(&servers, max_attempts)?;
let mut ready = vec![false; servers.len()];

let final_result = loop {
let mut ready = true;

for server in &servers {
match engine.probe(server).await {
Ok(ServerStatus::Running) => {}
Ok(_) => ready = false,
Err(e) => {
engine.stop_all().await?;
return Err(e);
}
}
}
loop {
// `None` means Ctrl+C won the race and the probe round was cancelled.
let round = tokio::select! {
result = probe_pending(&mut engine, &servers, &mut ready) => Some(result),
_ = shutdown.changed() => None,
};

if ready {
break tokio::select! {
result = run_final_command(&command) => result,
_ = tokio::signal::ctrl_c() => {
if let Err(e) = engine.stop_all().await {
eprintln!("Error stopping servers: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
};
match round {
None => return stop_for_shutdown(&mut engine).await,
Some(Err(error)) => {
engine.stop_all().await?;
return Err(error);
}
Some(Ok(true)) => {
let final_result = run_final_command(&command, &mut shutdown).await;
engine.stop_all().await?;
return final_result;
}
Some(Ok(false)) => {}
}

tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {}
_ = tokio::signal::ctrl_c() => {
if let Err(e) = engine.stop_all().await {
eprintln!("Error stopping servers: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
_ = shutdown.changed() => return stop_for_shutdown(&mut engine).await,
}
};
}
}

/// Watch channel that flips to `true` on the first Ctrl+C.
///
/// A dedicated task owns one long-lived `ctrl_c()` future. Building a fresh one
/// per `select!` iteration would drop any signal delivered while that future was
/// not being polled — during a health probe, for instance — and because tokio
/// installs a handler the default terminate action is gone too, so the signal
/// would be lost entirely rather than killing the process.
fn shutdown_signal() -> watch::Receiver<bool> {
let (tx, rx) = watch::channel(false);
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
let _ = tx.send(true);
}
});
rx
}

/// Probe every server that is not ready yet and report whether all are up.
///
/// Servers already known to be ready are skipped: re-probing them keeps
/// incrementing their attempt counter, so a healthy server could exhaust its
/// attempts — and get blamed in the error — while a different, slower server
/// was the one actually holding up the run.
async fn probe_pending(
engine: &mut Engine,
servers: &[Server],
ready: &mut [bool],
) -> anyhow::Result<bool> {
for (index, server) in servers.iter().enumerate() {
if ready[index] {
continue;
}

if engine.probe(server).await? == ServerStatus::Running {
ready[index] = true;
}
}

engine.stop_all().await?;
final_result
Ok(ready.iter().all(|ready| *ready))
}

async fn run_final_command(command: &str) -> anyhow::Result<()> {
let mut final_cmd = crate::core::command::spawn(command)
/// Ctrl+C path: stop every server, then exit successfully.
async fn stop_for_shutdown(engine: &mut Engine) -> anyhow::Result<()> {
engine.stop_all().await.context("Error stopping servers")
}

async fn run_final_command(
command: &str,
shutdown: &mut watch::Receiver<bool>,
) -> anyhow::Result<()> {
let mut final_cmd = crate::core::command::spawn_group(command, true)
.context(format!("Could not start process {}", command))?;

log::info!("Running command {}", command);

// Wait for the process to finish.
let status = final_cmd.child.wait().await?;
let status = tokio::select! {
status = final_cmd.wait() => status?,
_ = shutdown.changed() => {
// Kill the command and everything it spawned. Exiting the process
// here instead would leave the whole group orphaned.
let _ = final_cmd.cancel().await;
for reader in final_cmd.readers {
let _ = reader.await;
}
return Ok(());
}
};

for reader in final_cmd.readers {
let _ = reader.await;
Expand Down
Loading
Loading