1use base64::Engine;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Error, Result};
18use crate::remote::{encode_utf16_b64, ps_quote, separator, Platform, Remote};
19use crate::text::shell_quote;
20
21const CONTROL_TIMEOUT: u64 = 30;
23const CD_FAILED_EXIT: i32 = 97;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Job {
29 pub id: String,
30 pub pid: u32,
31 pub command: String,
32 pub cwd: String,
33 pub started: String,
34 pub log: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum JobState {
40 Running,
41 Exited(i32),
43 Gone,
46}
47
48impl Remote {
49 async fn jobs_dir(&self) -> Result<String> {
51 let home = self.home().await?;
52 let sep = separator(self.platform().await?);
53 Ok(format!("{home}{sep}.farhand{sep}jobs"))
54 }
55
56 pub async fn spawn(&self, command: &str, cwd: Option<&str>) -> Result<Job> {
59 let platform = self.platform().await?;
60 let cwd = match cwd {
61 Some(c) => self.resolve(c).await?,
62 None => self.effective_workdir().await?,
63 };
64 let dir = self.jobs_dir().await?;
65 self.ensure_dir(&dir).await?;
66 let id = format!("{:08x}", crate::remote::random_u64() as u32);
67 let sep = separator(platform);
68 let log = format!("{dir}{sep}{id}.log");
69 let status = format!("{dir}{sep}{id}.exit");
70 let launcher = match platform {
71 Platform::Posix => posix_launcher(self.shell(), command, &cwd, &log, &status),
72 Platform::Windows => {
73 let (exe, extra) = self.powershell();
74 windows_launcher(exe, &extra, command, &cwd, &log, &status)
75 }
76 };
77 let out = self.exec(&launcher, None, Some(CONTROL_TIMEOUT)).await?;
78 let stdout = String::from_utf8_lossy(&out.stdout);
79 let pid = stdout
80 .lines()
81 .filter_map(|l| l.trim().strip_prefix("pid="))
82 .find_map(|p| p.trim().parse::<u32>().ok())
83 .ok_or_else(|| {
84 Error::Ssh(format!(
85 "the job did not start (exit code {}): {}{}",
86 out.exit_code,
87 stdout.trim(),
88 String::from_utf8_lossy(&out.stderr).trim()
89 ))
90 })?;
91 let job = Job {
92 id,
93 pid,
94 command: command.to_string(),
95 cwd,
96 started: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
97 log,
98 };
99 let record = serde_json::to_vec_pretty(&job).map_err(std::io::Error::other)?;
100 self.write_file(&format!("{dir}{sep}{}.json", job.id), &record)
101 .await?;
102 Ok(job)
103 }
104
105 pub async fn jobs(&self) -> Result<Vec<(Job, JobState)>> {
107 let dir = self.jobs_dir().await?;
108 let sep = separator(self.platform().await?);
109 let mut jobs = Vec::new();
110 for name in self.list_files(&dir).await? {
111 if !name.ends_with(".json") {
112 continue;
113 }
114 let Ok((bytes, _)) = self
115 .read_file(&format!("{dir}{sep}{name}"), 64 * 1024)
116 .await
117 else {
118 continue;
119 };
120 if let Ok(job) = serde_json::from_slice::<Job>(&bytes) {
121 jobs.push(job);
122 }
123 }
124 jobs.sort_by(|a, b| b.started.cmp(&a.started));
125 let running = self.running(&jobs).await?;
126 let mut out = Vec::with_capacity(jobs.len());
127 for job in jobs {
128 let state = if running.contains(&job.pid) {
129 JobState::Running
130 } else {
131 match self
132 .read_file(&format!("{dir}{sep}{}.exit", job.id), 64)
133 .await
134 {
135 Ok((b, _)) => String::from_utf8_lossy(&b)
136 .trim()
137 .parse()
138 .map(JobState::Exited)
139 .unwrap_or(JobState::Gone),
140 Err(_) => JobState::Gone,
141 }
142 };
143 out.push((job, state));
144 }
145 Ok(out)
146 }
147
148 pub async fn kill_job(&self, id: &str) -> Result<(Job, bool)> {
151 if id.len() != 8 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
152 return Err(Error::Invalid(format!(
153 "`{id}` is not a job id (see remote_jobs)"
154 )));
155 }
156 let (job, state) = self
157 .jobs()
158 .await?
159 .into_iter()
160 .find(|(j, _)| j.id == id)
161 .ok_or_else(|| Error::Invalid(format!("no job `{id}` (see remote_jobs)")))?;
162 if state != JobState::Running {
163 return Ok((job, false));
164 }
165 let script = match self.platform().await? {
166 Platform::Posix => format!(
169 "kill -TERM -- -{p} 2>/dev/null || kill -TERM {p} 2>/dev/null\n\
170 for _ in 1 2 3 4 5; do kill -0 {p} 2>/dev/null || exit 0; sleep 1; done\n\
171 kill -KILL -- -{p} 2>/dev/null || kill -KILL {p} 2>/dev/null\nexit 0\n",
172 p = job.pid
173 ),
174 Platform::Windows => format!("taskkill /T /F /PID {} 2>&1 | Out-Null", job.pid),
175 };
176 self.exec(&script, None, Some(CONTROL_TIMEOUT)).await?;
177 Ok((job, true))
178 }
179
180 async fn running(&self, jobs: &[Job]) -> Result<Vec<u32>> {
182 if jobs.is_empty() {
183 return Ok(Vec::new());
184 }
185 let pids = jobs.iter().map(|j| j.pid.to_string()).collect::<Vec<_>>();
186 let script = match self.platform().await? {
187 Platform::Posix => format!(
188 "for p in {}; do kill -0 $p 2>/dev/null && echo $p; done; exit 0",
189 pids.join(" ")
190 ),
191 Platform::Windows => format!(
192 "foreach ($p in @({})) {{ if (Get-Process -Id $p -ErrorAction \
193 SilentlyContinue) {{ $p }} }}",
194 pids.join(",")
195 ),
196 };
197 let out = self.exec(&script, None, Some(CONTROL_TIMEOUT)).await?;
198 Ok(String::from_utf8_lossy(&out.stdout)
199 .lines()
200 .filter_map(|l| l.trim().parse().ok())
201 .collect())
202 }
203}
204
205fn posix_launcher(shell: &[String], command: &str, cwd: &str, log: &str, status: &str) -> String {
209 let b64 = |s: &str| base64::engine::general_purpose::STANDARD.encode(s.as_bytes());
210 let shell = shell
211 .iter()
212 .map(|a| shell_quote(a))
213 .collect::<Vec<_>>()
214 .join(" ");
215 let job = format!(
216 "cd {cwd} || {{ echo {CD_FAILED_EXIT} > {status}; exit {CD_FAILED_EXIT}; }}\n\
217 export NO_COLOR=1\n\
218 {shell} \"$(printf %s {cmd} | base64 -d)\"\n\
219 echo $? > {status}\n",
220 cwd = shell_quote(cwd),
221 status = shell_quote(status),
222 cmd = b64(command),
223 );
224 format!(
225 "detach() {{\n\
226 \x20 if command -v setsid >/dev/null 2>&1; then exec setsid nohup \"$@\"\n\
227 \x20 elif command -v perl >/dev/null 2>&1; then \
228 exec perl -e 'setpgrp(0, 0); exec @ARGV or die \"exec: $!\"' nohup \"$@\"\n\
229 \x20 else exec nohup \"$@\"; fi\n\
230 }}\n\
231 ( detach sh -c \"$(printf %s {job} | base64 -d)\" ) > {log} 2>&1 < /dev/null &\n\
232 echo pid=$!\n",
233 job = b64(&job),
234 log = shell_quote(log),
235 )
236}
237
238fn windows_launcher(
243 exe: &str,
244 extra: &[String],
245 command: &str,
246 cwd: &str,
247 log: &str,
248 status: &str,
249) -> String {
250 let job = format!(
251 "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Continue'\n\
252 [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8\n\
253 $env:NO_COLOR='1'; $env:PYTHONUTF8='1'; $env:PYTHONIOENCODING='utf-8'\n\
254 try {{ Set-Location -LiteralPath {cwd} -ErrorAction Stop }} \
255 catch {{ Set-Content -LiteralPath {status} -Value {CD_FAILED_EXIT}; exit {CD_FAILED_EXIT} }}\n\
256 $global:LASTEXITCODE=0\n\
257 {command}\n\
258 $__fh_ok=$?\n\
259 $__fh_code=$(if ($__fh_ok) {{0}} elseif ($LASTEXITCODE) {{$LASTEXITCODE}} \
260 elseif ($Error.Count -and $Error[0].FullyQualifiedErrorId -like 'NativeCommandError*') {{0}} \
261 else {{1}})\n\
262 Set-Content -LiteralPath {status} -Value $__fh_code\n\
263 exit $__fh_code\n",
264 cwd = ps_quote(cwd),
265 status = ps_quote(status),
266 );
267 let extra = extra
268 .iter()
269 .map(|a| format!("\"{a}\""))
270 .collect::<Vec<_>>()
271 .join(" ");
272 let line = format!(
274 "cmd.exe /d /s /c \"\"{exe}\" {extra} -NoProfile -NonInteractive -ExecutionPolicy Bypass \
275 -EncodedCommand {enc} > \"{log}\" 2>&1\"",
276 enc = encode_utf16_b64(&job),
277 );
278 format!(
279 "$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments \
280 @{{ CommandLine = {line}; CurrentDirectory = {cwd} }}\n\
281 if ($r.ReturnValue -ne 0) {{ [Console]::Error.WriteLine(\"Win32_Process.Create \
282 returned $($r.ReturnValue)\"); exit 1 }}\n\
283 \"pid=$($r.ProcessId)\"\n",
284 line = ps_quote(&line),
285 cwd = ps_quote(cwd),
286 )
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[cfg(unix)]
296 #[test]
297 fn a_posix_job_outlives_its_launcher() {
298 let d = tempfile::tempdir().unwrap();
299 let p = |n: &str| d.path().join(n).to_string_lossy().into_owned();
300 let script = posix_launcher(
301 &["sh".into(), "-c".into()],
302 "sleep 1; echo \"out $NO_COLOR\"; exit 3",
303 &d.path().to_string_lossy(),
304 &p("job.log"),
305 &p("job.exit"),
306 );
307 let started = std::time::Instant::now();
308 let out = std::process::Command::new("sh")
309 .arg("-c")
310 .arg(&script)
311 .stdin(std::process::Stdio::null())
312 .output()
313 .unwrap();
314 assert!(started.elapsed() < std::time::Duration::from_secs(1));
316 let text = String::from_utf8_lossy(&out.stdout);
317 let pid: i32 = text.trim().strip_prefix("pid=").unwrap().parse().unwrap();
318 let mut pgid = 0;
321 for _ in 0..50 {
322 pgid = unsafe { libc::getpgid(pid) };
323 if pgid == pid {
324 break;
325 }
326 std::thread::sleep(std::time::Duration::from_millis(20));
327 }
328 assert_eq!(pgid, pid, "{text}");
329 for _ in 0..50 {
330 if std::path::Path::new(&p("job.exit")).exists() {
331 break;
332 }
333 std::thread::sleep(std::time::Duration::from_millis(100));
334 }
335 assert_eq!(std::fs::read_to_string(p("job.exit")).unwrap().trim(), "3");
336 assert_eq!(std::fs::read_to_string(p("job.log")).unwrap(), "out 1\n");
337 }
338
339 #[test]
340 fn the_windows_launcher_goes_through_wmi() {
341 let s = windows_launcher(
342 "powershell.exe",
343 &[],
344 "npm run dev",
345 "C:\\Users\\me\\proj",
346 "C:\\Users\\me\\.farhand\\jobs\\0badc0de.log",
347 "C:\\Users\\me\\.farhand\\jobs\\0badc0de.exit",
348 );
349 assert!(s.contains("Win32_Process -MethodName Create"), "{s}");
350 assert!(
351 s.contains("CurrentDirectory = 'C:\\Users\\me\\proj'"),
352 "{s}"
353 );
354 assert!(
355 s.contains("> \"C:\\Users\\me\\.farhand\\jobs\\0badc0de.log\" 2>&1"),
356 "{s}"
357 );
358 assert!(!s.contains("npm run dev"), "{s}");
360 }
361}