use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::remote::{encode_utf16_b64, ps_quote, separator, Platform, Remote};
use crate::text::shell_quote;
const CONTROL_TIMEOUT: u64 = 30;
const CD_FAILED_EXIT: i32 = 97;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
pub id: String,
pub pid: u32,
pub command: String,
pub cwd: String,
pub started: String,
pub log: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobState {
Running,
Exited(i32),
Gone,
}
impl Remote {
async fn jobs_dir(&self) -> Result<String> {
let home = self.home().await?;
let sep = separator(self.platform().await?);
Ok(format!("{home}{sep}.farhand{sep}jobs"))
}
pub async fn spawn(&self, command: &str, cwd: Option<&str>) -> Result<Job> {
let platform = self.platform().await?;
let cwd = match cwd {
Some(c) => self.resolve(c).await?,
None => self.effective_workdir().await?,
};
let dir = self.jobs_dir().await?;
self.ensure_dir(&dir).await?;
let id = format!("{:08x}", crate::remote::random_u64() as u32);
let sep = separator(platform);
let log = format!("{dir}{sep}{id}.log");
let status = format!("{dir}{sep}{id}.exit");
let launcher = match platform {
Platform::Posix => posix_launcher(self.shell(), command, &cwd, &log, &status),
Platform::Windows => {
let (exe, extra) = self.powershell();
windows_launcher(exe, &extra, command, &cwd, &log, &status)
}
};
let out = self.exec(&launcher, None, Some(CONTROL_TIMEOUT)).await?;
let stdout = String::from_utf8_lossy(&out.stdout);
let pid = stdout
.lines()
.filter_map(|l| l.trim().strip_prefix("pid="))
.find_map(|p| p.trim().parse::<u32>().ok())
.ok_or_else(|| {
Error::Ssh(format!(
"the job did not start (exit code {}): {}{}",
out.exit_code,
stdout.trim(),
String::from_utf8_lossy(&out.stderr).trim()
))
})?;
let job = Job {
id,
pid,
command: command.to_string(),
cwd,
started: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
log,
};
let record = serde_json::to_vec_pretty(&job).map_err(std::io::Error::other)?;
self.write_file(&format!("{dir}{sep}{}.json", job.id), &record)
.await?;
Ok(job)
}
pub async fn jobs(&self) -> Result<Vec<(Job, JobState)>> {
let dir = self.jobs_dir().await?;
let sep = separator(self.platform().await?);
let mut jobs = Vec::new();
for name in self.list_files(&dir).await? {
if !name.ends_with(".json") {
continue;
}
let Ok((bytes, _)) = self
.read_file(&format!("{dir}{sep}{name}"), 64 * 1024)
.await
else {
continue;
};
if let Ok(job) = serde_json::from_slice::<Job>(&bytes) {
jobs.push(job);
}
}
jobs.sort_by(|a, b| b.started.cmp(&a.started));
let running = self.running(&jobs).await?;
let mut out = Vec::with_capacity(jobs.len());
for job in jobs {
let state = if running.contains(&job.pid) {
JobState::Running
} else {
match self
.read_file(&format!("{dir}{sep}{}.exit", job.id), 64)
.await
{
Ok((b, _)) => String::from_utf8_lossy(&b)
.trim()
.parse()
.map(JobState::Exited)
.unwrap_or(JobState::Gone),
Err(_) => JobState::Gone,
}
};
out.push((job, state));
}
Ok(out)
}
pub async fn kill_job(&self, id: &str) -> Result<(Job, bool)> {
if id.len() != 8 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(Error::Invalid(format!(
"`{id}` is not a job id (see remote_jobs)"
)));
}
let (job, state) = self
.jobs()
.await?
.into_iter()
.find(|(j, _)| j.id == id)
.ok_or_else(|| Error::Invalid(format!("no job `{id}` (see remote_jobs)")))?;
if state != JobState::Running {
return Ok((job, false));
}
let script = match self.platform().await? {
Platform::Posix => format!(
"kill -TERM -- -{p} 2>/dev/null || kill -TERM {p} 2>/dev/null\n\
for _ in 1 2 3 4 5; do kill -0 {p} 2>/dev/null || exit 0; sleep 1; done\n\
kill -KILL -- -{p} 2>/dev/null || kill -KILL {p} 2>/dev/null\nexit 0\n",
p = job.pid
),
Platform::Windows => format!("taskkill /T /F /PID {} 2>&1 | Out-Null", job.pid),
};
self.exec(&script, None, Some(CONTROL_TIMEOUT)).await?;
Ok((job, true))
}
async fn running(&self, jobs: &[Job]) -> Result<Vec<u32>> {
if jobs.is_empty() {
return Ok(Vec::new());
}
let pids = jobs.iter().map(|j| j.pid.to_string()).collect::<Vec<_>>();
let script = match self.platform().await? {
Platform::Posix => format!(
"for p in {}; do kill -0 $p 2>/dev/null && echo $p; done; exit 0",
pids.join(" ")
),
Platform::Windows => format!(
"foreach ($p in @({})) {{ if (Get-Process -Id $p -ErrorAction \
SilentlyContinue) {{ $p }} }}",
pids.join(",")
),
};
let out = self.exec(&script, None, Some(CONTROL_TIMEOUT)).await?;
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.trim().parse().ok())
.collect())
}
}
fn posix_launcher(shell: &[String], command: &str, cwd: &str, log: &str, status: &str) -> String {
let b64 = |s: &str| base64::engine::general_purpose::STANDARD.encode(s.as_bytes());
let shell = shell
.iter()
.map(|a| shell_quote(a))
.collect::<Vec<_>>()
.join(" ");
let job = format!(
"cd {cwd} || {{ echo {CD_FAILED_EXIT} > {status}; exit {CD_FAILED_EXIT}; }}\n\
export NO_COLOR=1\n\
{shell} \"$(printf %s {cmd} | base64 -d)\"\n\
echo $? > {status}\n",
cwd = shell_quote(cwd),
status = shell_quote(status),
cmd = b64(command),
);
format!(
"detach() {{\n\
\x20 if command -v setsid >/dev/null 2>&1; then exec setsid nohup \"$@\"\n\
\x20 elif command -v perl >/dev/null 2>&1; then \
exec perl -e 'setpgrp(0, 0); exec @ARGV or die \"exec: $!\"' nohup \"$@\"\n\
\x20 else exec nohup \"$@\"; fi\n\
}}\n\
( detach sh -c \"$(printf %s {job} | base64 -d)\" ) > {log} 2>&1 < /dev/null &\n\
echo pid=$!\n",
job = b64(&job),
log = shell_quote(log),
)
}
fn windows_launcher(
exe: &str,
extra: &[String],
command: &str,
cwd: &str,
log: &str,
status: &str,
) -> String {
let job = format!(
"$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
[Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8\n\
$env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
catch {{ Set-Content -LiteralPath {status} -Value {CD_FAILED_EXIT}; exit {CD_FAILED_EXIT} }}\n\
$global:LASTEXITCODE=0\n\
{command}\n\
$__fh_ok=$?\n\
$__fh_code=$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} \
elseif ($Error.Count -and $Error[0].FullyQualifiedErrorId -like 'NativeCommandError*') {{0}} \
else {{1}})\n\
Set-Content -LiteralPath {status} -Value $__fh_code\n\
exit $__fh_code\n",
cwd = ps_quote(cwd),
status = ps_quote(status),
);
let extra = extra
.iter()
.map(|a| format!("\"{a}\""))
.collect::<Vec<_>>()
.join(" ");
let line = format!(
"cmd.exe /d /s /c \"\"{exe}\" {extra} -NoProfile -NonInteractive -ExecutionPolicy Bypass \
-EncodedCommand {enc} > \"{log}\" 2>&1\"",
enc = encode_utf16_b64(&job),
);
format!(
"$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments \
@{{ CommandLine = {line}; CurrentDirectory = {cwd} }}\n\
if ($r.ReturnValue -ne 0) {{ [Console]::Error.WriteLine(\"Win32_Process.Create \
returned $($r.ReturnValue)\"); exit 1 }}\n\
\"pid=$($r.ProcessId)\"\n",
line = ps_quote(&line),
cwd = ps_quote(cwd),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_posix_job_outlives_its_launcher() {
let d = tempfile::tempdir().unwrap();
let p = |n: &str| d.path().join(n).to_string_lossy().into_owned();
let script = posix_launcher(
&["sh".into(), "-c".into()],
"sleep 1; echo \"out $NO_COLOR\"; exit 3",
&d.path().to_string_lossy(),
&p("job.log"),
&p("job.exit"),
);
let started = std::time::Instant::now();
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&script)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(1));
let text = String::from_utf8_lossy(&out.stdout);
let pid: i32 = text.trim().strip_prefix("pid=").unwrap().parse().unwrap();
let mut pgid = 0;
for _ in 0..50 {
pgid = unsafe { libc::getpgid(pid) };
if pgid == pid {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert_eq!(pgid, pid, "{text}");
for _ in 0..50 {
if std::path::Path::new(&p("job.exit")).exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(std::fs::read_to_string(p("job.exit")).unwrap().trim(), "3");
assert_eq!(std::fs::read_to_string(p("job.log")).unwrap(), "out 1\n");
}
#[test]
fn the_windows_launcher_goes_through_wmi() {
let s = windows_launcher(
"powershell.exe",
&[],
"npm run dev",
"C:\\Users\\me\\proj",
"C:\\Users\\me\\.farhand\\jobs\\0badc0de.log",
"C:\\Users\\me\\.farhand\\jobs\\0badc0de.exit",
);
assert!(s.contains("Win32_Process -MethodName Create"), "{s}");
assert!(
s.contains("CurrentDirectory = 'C:\\Users\\me\\proj'"),
"{s}"
);
assert!(
s.contains("> \"C:\\Users\\me\\.farhand\\jobs\\0badc0de.log\" 2>&1"),
"{s}"
);
assert!(!s.contains("npm run dev"), "{s}");
}
}