use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use crate::shell::Dialect;
use crate::ssh::Session;
const RUNS: &str = ".turnout/runs";
const REDIAL_FOR: Duration = if cfg!(test) { Duration::from_millis(300) } else { Duration::from_secs(60) };
const REDIAL_EVERY: Duration = if cfg!(test) { Duration::from_millis(50) } else { Duration::from_secs(3) };
pub trait Remote {
fn exec_bytes(&self, command: &str) -> Result<Vec<u8>>;
}
impl Remote for Session {
fn exec_bytes(&self, command: &str) -> Result<Vec<u8>> {
Session::exec_bytes(self, command)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Run {
id: String,
}
impl Run {
fn new() -> Self {
static COUNT: AtomicU32 = AtomicU32::new(0);
let stamp = crate::remote::backup_name();
let stamp = stamp.trim_end_matches(".tar.gz");
Self {
id: format!("{stamp}-{}-{}", std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed)),
}
}
fn base(&self) -> String {
format!("\"$HOME/{RUNS}/{}\"", self.id)
}
fn log(&self) -> String {
format!("~/{RUNS}/{}.log", self.id)
}
}
fn launch_command(run: &Run, dir: &str, command: &str) -> String {
let posix = Dialect::Posix;
let inner = format!(
"\"${{SHELL:-sh}}\" -c {}; c=$?; echo $c > \"$1.part\" && mv \"$1.part\" \"$1.code\"",
posix.quote(command)
);
let script = format!(
"mkdir -p \"$HOME/{RUNS}\" || exit 1; f={base}; cd {dir} || exit 1; \
if command -v setsid >/dev/null 2>&1; then d=setsid; else d=nohup; fi; \
$d sh -c {inner} turnout-run \"$f\" > \"$f.log\" 2>&1 < /dev/null & \
echo launched",
base = run.base(),
dir = posix.quote(dir),
inner = posix.quote(&inner),
);
format!("sh -c {}", posix.quote(&script))
}
fn poll_command(run: &Run, offset: u64) -> String {
let script = format!(
"f={base}; if [ -f \"$f.code\" ]; then echo \"exit=$(cat \"$f.code\")\"; else echo running; fi; \
tail -c +{from} \"$f.log\" 2>/dev/null; exit 0",
base = run.base(),
from = offset + 1,
);
format!("sh -c {}", Dialect::Posix.quote(&script))
}
fn forget_command(run: &Run) -> String {
let script = format!("f={}; rm -f \"$f.log\" \"$f.code\"", run.base());
format!("sh -c {}", Dialect::Posix.quote(&script))
}
#[derive(Debug, PartialEq, Eq)]
struct Polled {
exit: Option<i32>,
output: Vec<u8>,
}
fn read_poll(reply: &[u8]) -> Result<Polled> {
let split = reply.iter().position(|byte| *byte == b'\n').unwrap_or(reply.len());
let head = String::from_utf8_lossy(&reply[..split]);
let output = reply.get(split + 1..).unwrap_or_default().to_vec();
let exit = match head.trim() {
"running" => None,
status => match status.strip_prefix("exit=").and_then(|code| code.trim().parse().ok()) {
Some(code) => Some(code),
None => bail!("the server answered '{status}' about a run it should know"),
},
};
Ok(Polled { exit, output })
}
fn last_line(output: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(output);
let line = text
.lines()
.rev()
.map(|line| crate::job::strip_ansi(line).trim().to_string())
.find(|line| !line.is_empty())?;
const WIDTH: usize = 60;
Some(if line.chars().count() > WIDTH {
format!("{}…", line.chars().take(WIDTH - 1).collect::<String>())
} else {
line
})
}
pub fn run<R: Remote>(
remote: &mut R,
dialect: Dialect,
dir: &str,
command: &str,
mut reconnect: impl FnMut() -> Result<R>,
mut progress: impl FnMut(&str),
) -> Result<String> {
if dialect == Dialect::Windows {
let stdout = remote.exec_bytes(&dialect.run_in(dir, command))?;
return Ok(String::from_utf8_lossy(&stdout).into_owned());
}
let run = Run::new();
let launched = remote.exec_bytes(&launch_command(&run, dir, command))?;
if !String::from_utf8_lossy(&launched).contains("launched") {
bail!("`{command}` did not start on the server: {}", String::from_utf8_lossy(&launched).trim());
}
let mut output = Vec::new();
let mut pause = Duration::from_millis(100);
loop {
let reply = match remote.exec_bytes(&poll_command(&run, output.len() as u64)) {
Ok(reply) => reply,
Err(lost) => {
progress("the connection dropped - dialling again; the command runs on");
*remote = redial(&mut reconnect).map_err(|_| {
anyhow::anyhow!(
"the connection dropped while `{command}` ran ({lost:#}), and it could not be opened again.\n\
The command keeps running on the server; its output is in {}",
run.log()
)
})?;
continue;
}
};
let polled = read_poll(&reply)?;
output.extend_from_slice(&polled.output);
if !polled.output.is_empty()
&& let Some(line) = last_line(&output)
{
progress(&line);
}
if let Some(code) = polled.exit {
let _ = remote.exec_bytes(&forget_command(&run));
let text = String::from_utf8_lossy(&output).into_owned();
if code != 0 {
bail!("`{command}` exited with {code}: {}", text.trim());
}
return Ok(text);
}
std::thread::sleep(pause);
pause = (pause * 2).min(Duration::from_secs(1));
}
}
fn redial<R>(reconnect: &mut impl FnMut() -> Result<R>) -> Result<R> {
let started = Instant::now();
loop {
match reconnect() {
Ok(remote) => return Ok(remote),
Err(err) if started.elapsed() >= REDIAL_FOR => return Err(err),
Err(_) => std::thread::sleep(REDIAL_EVERY),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::cell::Cell;
use super::*;
#[test]
fn the_command_starts_in_a_session_of_its_own() {
let run = Run { id: "r1".into() };
let launch = launch_command(&run, "/srv/my app", "docker compose up -d --build");
assert!(launch.starts_with("sh -c '"), "the outer script is sh, whatever the login shell: {launch}");
assert!(launch.contains("setsid"), "{launch}");
assert!(launch.contains("/srv/my app"), "{launch}");
assert!(launch.contains("docker compose up -d --build"), "{launch}");
assert!(launch.contains("< /dev/null &"), "nothing may hold the channel open: {launch}");
assert!(
poll_command(&run, 41).contains("tail -c +42"),
"the log is read on from the byte after the offset"
);
}
#[test]
fn a_poll_reads_the_state_then_the_log() {
assert_eq!(
read_poll(b"running\nstep 1\nstep 2\n").unwrap(),
Polled {
exit: None,
output: b"step 1\nstep 2\n".to_vec()
}
);
assert_eq!(
read_poll(b"exit=3\n").unwrap(),
Polled {
exit: Some(3),
output: Vec::new()
}
);
assert!(read_poll(b"who knows\n").is_err(), "a reply nobody wrote must not read as running forever");
}
#[test]
fn the_spinner_shows_the_newest_line() {
assert_eq!(last_line(b"one\ntwo\n\n").as_deref(), Some("two"));
assert_eq!(last_line(b"\x1b[32mbuilt\x1b[0m\n").as_deref(), Some("built"));
assert_eq!(last_line(b"").as_deref(), None);
assert_eq!(last_line(&[b'x'; 200]).map(|line| line.chars().count()), Some(60));
}
#[cfg(unix)]
struct Flaky {
session: Session,
drops: Cell<u32>,
}
#[cfg(unix)]
impl Remote for Flaky {
fn exec_bytes(&self, command: &str) -> Result<Vec<u8>> {
if command.contains("tail -c") && self.drops.get() > 0 {
self.drops.set(self.drops.get() - 1);
bail!("connection reset by peer");
}
self.session.exec_bytes(command)
}
}
#[cfg(unix)]
#[test]
fn a_run_survives_the_connection_that_started_it() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let dir = tempfile::tempdir().unwrap();
let mut remote = Flaky {
session: stand.session(),
drops: Cell::new(2),
};
let redials = Cell::new(0);
let mut lines = Vec::new();
let output = run(
&mut remote,
Dialect::Posix,
&dir.path().display().to_string(),
"sleep 1; pwd; echo done > marker",
|| {
redials.set(redials.get() + 1);
Ok(Flaky {
session: stand.session(),
drops: Cell::new(0),
})
},
|line| lines.push(line.to_string()),
)
.unwrap();
assert_eq!(redials.get(), 1, "one drop, one new connection: {lines:?}");
assert!(output.contains(&dir.path().display().to_string()), "it ran in the deploy directory: {output}");
assert!(dir.path().join("marker").is_file(), "and it ran to the end");
let runs = stand.home().join(RUNS);
assert_eq!(std::fs::read_dir(&runs).unwrap().count(), 0, "a finished run leaves nothing behind");
}
#[cfg(unix)]
#[test]
fn a_failing_command_reports_its_code_and_output() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let dir = tempfile::tempdir().unwrap();
let mut session = stand.session();
let err = run(
&mut session,
Dialect::Posix,
&dir.path().display().to_string(),
"echo the image did not build; exit 3",
|| Ok(stand.session()),
|_| {},
)
.unwrap_err()
.to_string();
assert!(err.contains("exited with 3"), "{err}");
assert!(err.contains("the image did not build"), "{err}");
}
#[cfg(unix)]
#[test]
fn a_connection_that_cannot_be_reopened_says_where_the_output_is() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let dir = tempfile::tempdir().unwrap();
let mut remote = Flaky {
session: stand.session(),
drops: Cell::new(1),
};
let err = run(
&mut remote,
Dialect::Posix,
&dir.path().display().to_string(),
"sleep 2; echo late > marker",
|| -> Result<Flaky> { bail!("no route to host") },
|_| {},
);
let err = format!("{:#}", err.unwrap_err());
assert!(err.contains("keeps running on the server"), "{err}");
assert!(err.contains("~/.turnout/runs/"), "{err}");
}
}