Skip to content
1,569 linesCodeBlameRaw
1//! The runner in the app: g1t's self-hosted runner, started for the
2//! signed-in person when g1t opens and stopped when it quits, so their
3//! computer runs their workspace's `runs-on: self-hosted` jobs with
4//! nothing to install, register or start by hand
5//! (docs.g1t.sh/guides/desktop/#the-runner).
6//!
7//! - **When.** On by default; settings turn it off ("Run jobs on this
8//! computer while g1t is open"), and Stop in settings or the tray stops
9//! it until Start, or until g1t starts again. It starts a few seconds
10//! after the window, never before.
11//! - **How.** The backend settings choose (backend.rs): Sandboxed, the
12//! default, where nothing a job does reaches this computer; Docker; or
13//! directly on this computer. The two that are not sandboxes are only
14//! ever chosen by the person, and nothing falls back to them. Sandboxed
15//! is `g1t-runner sandbox` (launcher.rs) on every OS: `up` a few seconds
16//! after the window, `status` every few seconds while it runs, `down`
17//! as g1t quits, and `down --drain` when another backend is chosen.
18//! - **Registering.** The first time, and when the workspace or backend
19//! changes, the app needs a registration token for the workspace. Only
20//! the site can make one, for an owner who is signed in: the app asks
21//! the site's page (`g1t-runner-wanted`), the page asks the server
22//! (`POST /-/desktop/runner-token`) and hands the token to the app
23//! (`runner_register` in bridge.rs), which gives it to `g1t-runner`
24//! once: in its environment, or to the sandbox's first `up`. It is never
25//! shown or written to the log. The registration is kept
26//! (registration.json and the runner's own folder; the sandbox keeps its
27//! own, and sandbox-registration.json says whose it is) and used on
28//! every later start.
29//! - **Up to date.** Soon after it starts and every six hours, while the
30//! runner is idle, the app looks for a newer release and puts it in
31//! place, and has the sandbox take the newest image (`sandbox update`):
32//! never in the middle of a job.
33//! - **Seen.** What it is doing, and when it is not running why and how to
34//! fix it (diagnosis.rs), is in the tray, in settings, and on the site's
35//! Runners page (`g1t-runner-status`), read from what its log says
36//! (log.rs), or for the sandbox from its status. The whole log is kept
37//! in `runner/runner.log`.
38//!
39//! All of it runs on a thread of its own, which owns the runner's process
40//! and is told what happened through one channel.
41
42mod backend;
43pub mod diagnosis;
44mod launcher;
45mod log;
46mod native;
47
48use std::collections::VecDeque;
49use std::fs::{File, OpenOptions};
50use std::io::{BufRead, BufReader, Read, Write};
51use std::path::PathBuf;
52use std::process::{Child, Stdio};
53use std::sync::Mutex;
54use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
55use std::time::{Duration, Instant};
56
57use serde::{Deserialize, Serialize};
58use tauri::{AppHandle, Emitter, EventTarget, Manager};
59use url::Url;
60
61pub use backend::Backend;
62use backend::{Engine, Missing};
63use diagnosis::{Code, Diagnosis, Facts};
64use launcher::{Down, State, Status};
65
66use crate::{config, frame, settings, tray};
67
68/// What settings, the tray and the site listen for: the runner's status changed.
69pub const STATUS_EVENT: &str = "g1t-runner-status";
70/// What the site's page listens for: the app needs a registration token for a workspace.
71pub const WANTED_EVENT: &str = "g1t-runner-wanted";
72
73/// How long after the window the runner starts.
74const AFTER_START: Duration = Duration::from_secs(4);
75/// How often the thread looks at the runner when nothing has happened.
76const TICK: Duration = Duration::from_secs(5);
77/// How long after starting the first look for an update is, then how often.
78const FIRST_UPDATE: Duration = Duration::from_secs(90);
79const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
80/// How often a backend that cannot start is looked at again.
81const LOOK_AGAIN: Duration = Duration::from_secs(30);
82/// How long the app waits for the site to hand over a token before asking again.
83const ASK_AGAIN: Duration = Duration::from_secs(60);
84/// The longest the app waits before starting a runner that stopped by itself again.
85const MOST_BACKOFF: Duration = Duration::from_secs(10 * 60);
86/// The runner says when a poll fails, and nothing when one works: this
87/// long without another failure, it is back (its longest wait is 60s).
88const BACK_ONLINE: Duration = Duration::from_secs(75);
89/// The runner's log is started over when it is bigger than this.
90const LOG_LIMIT: u64 = 5 * 1024 * 1024;
91/// The last lines of the log settings show.
92const TAIL: usize = 40;
93
94/// A workspace the site says the person may add runners to: those they own.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Offered {
97 pub slug: String,
98 #[serde(default)]
99 pub name: Option<String>,
100}
101
102/// What the runner is doing, as the thread last said.
103#[derive(Clone, Debug, Default, PartialEq)]
104struct Now {
105 code: Code,
106 detail: Option<String>,
107 exit_code: Option<i32>,
108 wait_seconds: Option<u64>,
109 job: Option<String>,
110 version: Option<String>,
111 /// What the sandbox says to do, for a cause in its own words.
112 fix: Option<String>,
113 /// How the sandbox is, while jobs run sandboxed.
114 sandbox: Option<Status>,
115}
116
117/// What the site last said about the person.
118#[derive(Clone, Debug, Default, PartialEq)]
119struct Site {
120 /// Unknown until the site's first page loads.
121 signed_in: Option<bool>,
122 owned: Vec<Offered>,
123 current: Option<String>,
124}
125
126/// What the rest of the app shares with the thread.
127#[derive(Default)]
128pub struct Shared {
129 now: Mutex<Now>,
130 /// The workspace a token is wanted for.
131 wanted: Mutex<Option<String>>,
132 site: Mutex<Site>,
133 /// Stopped by the person until Start, or until g1t starts again.
134 paused: Mutex<bool>,
135 tail: Mutex<VecDeque<String>>,
136 tx: Mutex<Option<Sender<Ask>>>,
137}
138
139/// What settings, the tray and the site are shown.
140#[derive(Clone, Debug, PartialEq, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct View {
143 pub diagnosis: Diagnosis,
144 /// What the site says of this computer once g1t is closed on it.
145 pub closed: Diagnosis,
146 pub running: bool,
147 pub version: Option<String>,
148 pub backend: Backend,
149 pub workspace: Option<String>,
150 /// The workspaces it may run for, from the site.
151 pub workspaces: Vec<Offered>,
152 /// The setting: run jobs while g1t is open.
153 pub enabled: bool,
154 pub paused: bool,
155 /// The workspace's Runners page.
156 pub link: Option<String>,
157 /// This computer's name, which the runner has on g1t.
158 pub machine: String,
159 pub platform: String,
160 /// The last lines of the runner's log.
161 pub log: Vec<String>,
162}
163
164/// What the thread is told.
165enum Ask {
166 /// Something changed: settings, Start or Stop, Retry.
167 Look,
168 Retry,
169 Token { workspace: String, token: String },
170 Refused { workspace: String, allowed: bool, why: String },
171 /// A line of the runner's output, or the launcher's; true from its standard output.
172 Line(u64, String, bool),
173 Exited(u64),
174 /// What `sandbox status` said.
175 Sandbox(u64, Result<Box<Status>, String>),
176 Quit(Sender<()>),
177}
178
179/// The registration the app made, besides what the runner keeps itself.
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181struct Registration {
182 server: String,
183 workspace: String,
184 backend: Backend,
185}
186
187/// Whose the sandbox's registration is. The sandbox keeps its own, and
188/// keeps it while another backend runs, so choosing Sandboxed again needs
189/// no new one; this says which workspace it is for.
190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191struct SandboxRegistration {
192 server: String,
193 workspace: String,
194 /// False from the `up` that was given a token until it registered.
195 registered: bool,
196}
197
198impl SandboxRegistration {
199 fn path(app: &AppHandle) -> PathBuf {
200 folder(app).join("sandbox-registration.json")
201 }
202 fn load(app: &AppHandle) -> Option<SandboxRegistration> {
203 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
204 }
205 fn save(&self, app: &AppHandle) {
206 let _ = std::fs::create_dir_all(folder(app));
207 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
208 }
209 fn clear(app: &AppHandle) {
210 let _ = std::fs::remove_file(Self::path(app));
211 }
212}
213
214fn folder(app: &AppHandle) -> PathBuf {
215 app.path().app_data_dir().unwrap_or_else(|_| std::env::temp_dir().join("sh.g1t.desktop")).join("runner")
216}
217
218impl Registration {
219 fn path(app: &AppHandle) -> PathBuf {
220 folder(app).join("registration.json")
221 }
222 fn load(app: &AppHandle) -> Option<Registration> {
223 std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
224 }
225 fn save(&self, app: &AppHandle) {
226 let _ = std::fs::create_dir_all(folder(app));
227 let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
228 }
229 fn clear(app: &AppHandle) {
230 let _ = std::fs::remove_file(Self::path(app));
231 }
232}
233
234/// Where a native runner of a backend keeps its configuration.
235fn native_dir(app: &AppHandle, backend: Backend) -> PathBuf {
236 folder(app).join(match backend {
237 Backend::Docker => "docker",
238 Backend::Host => "host",
239 Backend::Sandboxed => launcher::FOLDER,
240 })
241}
242
243/// The workspace the runner is for: the one settings name, else the one it
244/// is registered with, else the one the person is in on the site, else the
245/// first they own.
246fn workspace(app: &AppHandle) -> Option<String> {
247 let settings = config::settings(app);
248 if let Some(chosen) = settings.runner_workspace.filter(|w| !w.is_empty()) {
249 return Some(chosen);
250 }
251 let server = config::server(app).to_string();
252 if let Some(registration) = Registration::load(app).filter(|r| r.server == server) {
253 return Some(registration.workspace);
254 }
255 let site = app.state::<Shared>().site.lock().unwrap().clone();
256 site.current.filter(|slug| site.owned.iter().any(|o| &o.slug == slug)).or_else(|| site.owned.first().map(|o| o.slug.clone()))
257}
258
259fn facts(app: &AppHandle, now: &Now) -> Facts {
260 let server = config::server(app);
261 let backend = config::settings(app).runner_backend;
262 Facts {
263 workspace: workspace(app),
264 backend: backend.label(),
265 machine: backend::machine_name(),
266 job: now.job.clone(),
267 detail: now.detail.clone(),
268 exit_code: now.exit_code,
269 wait_seconds: now.wait_seconds,
270 server: server.host_str().unwrap_or("g1t").to_owned(),
271 platform: std::env::consts::OS.into(),
272 fix: now.fix.clone(),
273 sandbox: now.sandbox.clone().filter(|_| backend == Backend::Sandboxed),
274 }
275}
276
277pub fn view(app: &AppHandle) -> View {
278 let shared = app.state::<Shared>();
279 let now = shared.now.lock().unwrap().clone();
280 let settings = config::settings(app);
281 let facts = facts(app, &now);
282 let server = config::server(app);
283 View {
284 diagnosis: diagnosis::diagnose(now.code, &facts),
285 closed: diagnosis::closed(&facts),
286 running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline) || facts.sandbox.as_ref().is_some_and(Status::running),
287 version: now.version,
288 backend: settings.runner_backend,
289 link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()),
290 workspace: facts.workspace,
291 workspaces: shared.site.lock().unwrap().owned.clone(),
292 enabled: settings.runner,
293 paused: *shared.paused.lock().unwrap(),
294 machine: facts.machine,
295 platform: facts.platform,
296 log: shared.tail.lock().unwrap().iter().cloned().collect(),
297 }
298}
299
300/// The tray's line: `Runner: Waiting for jobs`, at most 60 characters.
301pub fn headline(view: &View) -> String {
302 let line = format!("Runner: {}", view.diagnosis.title);
303 if line.chars().count() > 60 { line.chars().take(59).collect::<String>() + "…" } else { line }
304}
305
306/// Tells everyone what the runner is doing now.
307fn publish(app: &AppHandle) {
308 let view = view(app);
309 tray::runner_changed(app, &view);
310 let _ = app.emit_to(settings::SETTINGS, STATUS_EVENT, view.clone());
311 let _ = app.emit_to(EventTarget::webview(frame::SITE), STATUS_EVENT, view);
312}
313
314fn ask(app: &AppHandle, ask: Ask) {
315 if let Some(tx) = app.state::<Shared>().tx.lock().unwrap().as_ref() {
316 let _ = tx.send(ask);
317 }
318}
319
320/// Starts the thread that keeps the runner, as the app starts.
321pub fn install(app: &AppHandle) {
322 let (tx, rx) = mpsc::channel();
323 *app.state::<Shared>().tx.lock().unwrap() = Some(tx.clone());
324 let handle = app.clone();
325 let spawned = std::thread::Builder::new().name("g1t-runner".into()).spawn(move || Keeper::new(handle, tx).keep(rx));
326 if let Err(error) = spawned {
327 eprintln!("g1t: the runner's thread could not start: {error}");
328 }
329}
330
331/// Settings changed: the thread looks again.
332pub fn settings_changed(app: &AppHandle) {
333 ask(app, Ask::Look);
334 publish(app);
335}
336
337/// Start or Stop, from settings or the tray.
338pub fn set_paused(app: &AppHandle, paused: bool) {
339 *app.state::<Shared>().paused.lock().unwrap() = paused;
340 // Start with the setting off turns the setting on: that is what was asked.
341 if !paused && !config::settings(app).runner {
342 let mut settings = config::settings(app);
343 settings.runner = true;
344 let _ = settings::apply(app, settings);
345 }
346 settings_changed(app);
347}
348
349/// The tray's Start or Stop row.
350pub fn toggle(app: &AppHandle) {
351 let view = view(app);
352 set_paused(app, view.enabled && !view.paused);
353}
354
355/// Retry and Restart: now, rather than when the wait is over.
356pub fn retry(app: &AppHandle) {
357 *app.state::<Shared>().paused.lock().unwrap() = false;
358 ask(app, Ask::Retry);
359}
360
361/// What the site says about the person, on every page: whether they are
362/// signed in, the workspaces they own, and the one they are in.
363pub fn offer(app: &AppHandle, signed_in: bool, owned: Vec<Offered>, current: Option<String>) {
364 let next = Site { signed_in: Some(signed_in), owned, current: current.map(|c| c.to_ascii_lowercase()) };
365 let changed = {
366 let shared = app.state::<Shared>();
367 let mut site = shared.site.lock().unwrap();
368 let changed = *site != next;
369 *site = next;
370 changed
371 };
372 if changed {
373 settings_changed(app);
374 }
375}
376
377/// The site handed over a registration token, or said why it could not:
378/// `allowed` is false when the person may not add runners to the workspace.
379pub fn register(app: &AppHandle, workspace: String, token: Option<String>, allowed: bool, error: Option<String>) {
380 match token {
381 Some(token) if token.starts_with("g1trt_") => ask(app, Ask::Token { workspace, token }),
382 _ => ask(app, Ask::Refused { workspace, allowed, why: error.unwrap_or_else(|| "g1t didn't make a registration token.".into()) }),
383 }
384}
385
386/// A diagnosis's button, or Start and Stop: from the settings window, or
387/// from the site's card for this computer.
388pub fn act(app: &AppHandle, action: &str) -> Result<(), String> {
389 use tauri_plugin_opener::OpenerExt;
390 let browse = |url: &str| app.opener().open_url(url, None::<&str>).map_err(|e| e.to_string());
391 match action {
392 "start" => {
393 set_paused(app, false);
394 retry(app);
395 }
396 "stop" => set_paused(app, true),
397 "retry" | "restart" => retry(app),
398 "sign_in" => crate::links::go(app, "/login"),
399 "new_workspace" => crate::links::go(app, "/workspaces/new"),
400 "view_runners" => match workspace(app) {
401 Some(workspace) => crate::links::go(app, &format!("/{workspace}/-/runners")),
402 None => crate::links::go(app, "/"),
403 },
404 "choose_workspace" | "choose_backend" => settings::open(app),
405 "view_log" => {
406 let path = log_file(app);
407 if !path.exists() {
408 return Err("The runner hasn't written a log yet.".into());
409 }
410 app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| e.to_string())?;
411 }
412 "open_docker" => open_docker(app)?,
413 "copy_wsl_install" => copy(launcher::WSL_INSTALL)?,
414 "copy_vfkit_install" => copy(launcher::VFKIT_INSTALL)?,
415 "windows_features" => windows_features()?,
416 "get_docker" => browse("https://docs.docker.com/get-started/get-docker/")?,
417 "install_sysbox" => browse("https://github.com/nestybox/sysbox#installation")?,
418 "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#how-jobs-run")?,
419 "report" => browse(config::server(app).join("support").map_err(|e| e.to_string())?.as_str())?,
420 other => return Err(format!("the runner has no action called {other}")),
421 }
422 Ok(())
423}
424
425/// Starts Docker Desktop, where it is installed in its usual place.
426fn open_docker(app: &AppHandle) -> Result<(), String> {
427 use tauri_plugin_opener::OpenerExt;
428 if cfg!(target_os = "macos") {
429 return app.opener().open_path("/Applications/Docker.app", None::<&str>).map_err(|e| e.to_string());
430 }
431 if cfg!(windows) {
432 let program = std::env::var_os("ProgramFiles").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
433 let desktop = program.join("Docker").join("Docker").join("Docker Desktop.exe");
434 if desktop.exists() {
435 return app.opener().open_path(desktop.to_string_lossy(), None::<&str>).map_err(|e| e.to_string());
436 }
437 return Err("Docker Desktop isn't installed in its usual place. Get Docker to install it.".into());
438 }
439 Err("Start Docker with your system's service manager, such as `sudo systemctl start docker`.".into())
440}
441
442/// Puts a command on the clipboard, for the person to paste where it runs.
443fn copy(text: &str) -> Result<(), String> {
444 let tools: &[(&str, &[&str])] = if cfg!(windows) {
445 &[("clip", &[])]
446 } else if cfg!(target_os = "macos") {
447 &[("pbcopy", &[])]
448 } else {
449 &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"])]
450 };
451 for (program, args) in tools {
452 let Ok(mut child) = backend::quiet(program).args(*args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { continue };
453 if let Some(mut input) = child.stdin.take() {
454 let _ = input.write_all(text.as_bytes());
455 }
456 if child.wait().is_ok_and(|status| status.success()) {
457 return Ok(());
458 }
459 }
460 Err(format!("It couldn't be copied. The command is: {text}"))
461}
462
463/// Windows' "Turn Windows features on or off", where Windows Sandbox and
464/// Virtual Machine Platform are.
465fn windows_features() -> Result<(), String> {
466 if !cfg!(windows) {
467 return Err("Windows features are only on Windows.".into());
468 }
469 backend::quiet("optionalfeatures.exe").spawn().map(|_| ()).map_err(|e| format!("Windows features couldn't be opened: {e}"))
470}
471
472/// The workspace a token is wanted for now, if one is: the site asks as it loads.
473pub fn wanted(app: &AppHandle) -> Option<String> {
474 app.state::<Shared>().wanted.lock().unwrap().clone()
475}
476
477/// The runner's log, for the settings window to open.
478pub fn log_file(app: &AppHandle) -> PathBuf {
479 folder(app).join("runner.log")
480}
481
482/// Stops the runner as g1t quits or restarts, and waits for it.
483pub fn shutdown(app: &AppHandle) {
484 let tx = app.state::<Shared>().tx.lock().unwrap().take();
485 if let Some(tx) = tx {
486 let (done, wait) = mpsc::channel();
487 if tx.send(Ask::Quit(done)).is_ok() {
488 let _ = wait.recv_timeout(Duration::from_secs(30));
489 }
490 }
491}
492
493/// How the runner is started this time.
494enum Process {
495 /// `g1t-runner`, a child of the app, whose output is the runner's log.
496 Child(Child),
497 /// The sandbox, which runs on its own once `up` is done; the launcher
498 /// command the app is waiting for, while there is one.
499 Sandbox(Option<(Step, Child)>),
500}
501
502/// A launcher command that takes a while.
503#[derive(Clone, Copy, Debug, PartialEq, Eq)]
504enum Step {
505 Up,
506 Update,
507}
508
509impl Step {
510 fn name(self) -> &'static str {
511 match self {
512 Step::Up => "up",
513 Step::Update => "update",
514 }
515 }
516}
517
518struct Run {
519 generation: u64,
520 engine: Engine,
521 backend: Backend,
522 workspace: String,
523 process: Process,
524 /// Registered and polling.
525 listening: bool,
526 busy: bool,
527 /// When a poll last failed to reach g1t.
528 offline_at: Option<Instant>,
529 /// Started with a registration token, which a container keeps in its configuration until replaced.
530 with_token: bool,
531 forgotten: bool,
532 token_refused: bool,
533 error: Option<String>,
534 /// The status a launcher command is printing, line by line.
535 json: Option<Vec<String>>,
536 /// Asking the sandbox how it is, and when it was last asked.
537 polling: bool,
538 polled_at: Option<Instant>,
539 /// A poll asked before a launcher command ended, whose answer is old.
540 stale: bool,
541}
542
543impl Run {
544 fn new(generation: u64, engine: Engine, backend: Backend, workspace: String, process: Process, with_token: bool) -> Run {
545 Run {
546 generation,
547 engine,
548 backend,
549 workspace,
550 process,
551 listening: false,
552 busy: false,
553 offline_at: None,
554 with_token,
555 forgotten: false,
556 token_refused: false,
557 error: None,
558 json: None,
559 polling: false,
560 polled_at: None,
561 stale: false,
562 }
563 }
564
565 fn step(&self) -> Option<Step> {
566 match &self.process {
567 Process::Sandbox(Some((step, _))) => Some(*step),
568 _ => None,
569 }
570 }
571}
572
573struct Keeper {
574 app: AppHandle,
575 tx: Sender<Ask>,
576 run: Option<Run>,
577 generation: u64,
578 token: Option<(String, String)>,
579 not_before: Instant,
580 failures: u32,
581 next_update: Instant,
582 log: Option<File>,
583 /// The runner was updated: the sandbox takes its image before it starts again.
584 image_pending: bool,
585 /// Why the last update failed, said until the next one works.
586 update_failed: Option<String>,
587}
588
589/// What a set of the state carries besides its code.
590#[derive(Default)]
591struct Extra {
592 detail: Option<String>,
593 exit_code: Option<i32>,
594 wait_seconds: Option<u64>,
595 fix: Option<String>,
596}
597
598fn detail(text: impl Into<String>) -> Extra {
599 Extra { detail: Some(text.into()), ..Extra::default() }
600}
601
602impl Keeper {
603 fn new(app: AppHandle, tx: Sender<Ask>) -> Keeper {
604 let now = Instant::now();
605 Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None, image_pending: false, update_failed: None }
606 }
607
608 fn keep(mut self, rx: Receiver<Ask>) {
609 self.reap();
610 self.set(Code::Starting, detail("Starting after g1t's window."));
611 loop {
612 match rx.recv_timeout(TICK) {
613 Ok(Ask::Quit(done)) => {
614 self.halt();
615 let _ = done.send(());
616 return;
617 }
618 Ok(ask) => self.handle(ask),
619 Err(RecvTimeoutError::Timeout) => {}
620 Err(RecvTimeoutError::Disconnected) => {
621 self.halt();
622 return;
623 }
624 }
625 self.look();
626 }
627 }
628
629 fn set(&mut self, code: Code, extra: Extra) {
630 let changed = {
631 let shared = self.app.state::<Shared>();
632 let mut now = shared.now.lock().unwrap();
633 let job = if code == Code::Busy { now.job.clone() } else { None };
634 let next = Now {
635 code,
636 detail: extra.detail,
637 exit_code: extra.exit_code,
638 wait_seconds: extra.wait_seconds,
639 job,
640 version: now.version.clone(),
641 fix: extra.fix,
642 sandbox: now.sandbox.clone(),
643 };
644 let changed = *now != next;
645 *now = next;
646 changed
647 };
648 if changed {
649 publish(&self.app);
650 }
651 }
652
653 fn code(&self) -> Code {
654 self.app.state::<Shared>().now.lock().unwrap().code
655 }
656
657 fn note(&mut self, job: Option<String>, version: Option<String>) {
658 let shared = self.app.state::<Shared>();
659 let mut now = shared.now.lock().unwrap();
660 if job.is_some() {
661 now.job = job;
662 }
663 if version.is_some() {
664 now.version = version;
665 }
666 }
667
668 /// A line of the runner's log, into the log file and the tail settings show.
669 fn write_log(&mut self, line: &str) {
670 {
671 let shared = self.app.state::<Shared>();
672 let mut tail = shared.tail.lock().unwrap();
673 if tail.len() == TAIL {
674 tail.pop_front();
675 }
676 tail.push_back(line.to_owned());
677 }
678 let path = log_file(&self.app);
679 if self.log.is_none() {
680 let _ = std::fs::create_dir_all(folder(&self.app));
681 if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) {
682 let _ = std::fs::rename(&path, path.with_extension("log.old"));
683 }
684 self.log = OpenOptions::new().create(true).append(true).open(&path).ok();
685 }
686 if let Some(file) = self.log.as_mut() {
687 let _ = writeln!(file, "{line}");
688 }
689 }
690
691 fn handle(&mut self, ask: Ask) {
692 match ask {
693 Ask::Look | Ask::Quit(_) => {}
694 Ask::Retry => {
695 self.not_before = Instant::now();
696 self.failures = 0;
697 match self.code() {
698 // The runner is fine: only the update is tried again.
699 Code::UpdateFailed => {
700 self.next_update = Instant::now();
701 self.update_failed = None;
702 }
703 Code::Offline => self.halt(),
704 _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(),
705 _ => {}
706 }
707 }
708 Ask::Token { workspace, token } => {
709 let shared = self.app.state::<Shared>();
710 let mut wanted = shared.wanted.lock().unwrap();
711 if wanted.as_deref() == Some(workspace.as_str()) {
712 *wanted = None;
713 self.token = Some((workspace, token));
714 self.not_before = Instant::now();
715 }
716 }
717 Ask::Refused { workspace, allowed, why } => {
718 let matched = {
719 let shared = self.app.state::<Shared>();
720 let mut wanted = shared.wanted.lock().unwrap();
721 let matched = wanted.as_deref() == Some(workspace.as_str());
722 if matched {
723 *wanted = None;
724 }
725 matched
726 };
727 if matched {
728 self.not_before = Instant::now() + MOST_BACKOFF;
729 self.set(if allowed { Code::RegisterFailed } else { Code::NotAllowed }, detail(why));
730 }
731 }
732 Ask::Line(generation, line, out) => self.line(generation, &line, out),
733 Ask::Exited(generation) => self.exited(generation),
734 Ask::Sandbox(generation, status) => self.polled(generation, status),
735 }
736 }
737
738 fn line(&mut self, generation: u64, line: &str, out: bool) {
739 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
740 if run.engine == Engine::Launcher {
741 // The launcher's log, then its status, which is read when it ends.
742 if out && (run.json.is_some() || launcher::starts_status(line)) {
743 run.json.get_or_insert_with(Vec::new).push(line.to_owned());
744 } else {
745 self.write_log(line);
746 }
747 return;
748 }
749 let mut became = None;
750 let mut job = None;
751 let mut version = None;
752 match log::read(line) {
753 log::Event::Listening { version: v } => {
754 run.listening = true;
755 version = Some(v);
756 became = Some(Code::Idle);
757 }
758 log::Event::Took { name, repo } => {
759 run.busy = true;
760 run.offline_at = None;
761 job = Some(format!("{name} ({repo})"));
762 became = Some(Code::Busy);
763 }
764 log::Event::Ended => {
765 run.busy = false;
766 became = Some(Code::Idle);
767 }
768 log::Event::Offline => {
769 run.offline_at = Some(Instant::now());
770 if !run.busy {
771 became = Some(Code::Offline);
772 }
773 }
774 log::Event::Forgotten => run.forgotten = true,
775 log::Event::TokenRefused => run.token_refused = true,
776 log::Event::Failed(error) => run.error = Some(error),
777 log::Event::Other => {}
778 }
779 let registered = version.is_some().then(|| Registration {
780 server: config::server(&self.app).to_string(),
781 workspace: run.workspace.clone(),
782 backend: run.backend,
783 });
784 self.write_log(line);
785 if let Some(registration) = registered {
786 registration.save(&self.app);
787 self.failures = 0;
788 }
789 self.note(job, version);
790 if let Some(code) = became {
791 self.set(code, Extra::default());
792 }
793 }
794
795 fn exited(&mut self, generation: u64) {
796 if self.run.as_ref().is_none_or(|run| run.generation != generation) {
797 return;
798 }
799 if self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
800 return self.launched(generation);
801 }
802 let mut run = self.run.take().expect("checked above");
803 let exit_code = match &mut run.process {
804 Process::Child(child) => child.wait().ok().and_then(|status| status.code()),
805 Process::Sandbox(_) => None,
806 };
807 self.stop(&mut run, Down::Now);
808 if run.forgotten || run.token_refused {
809 // The registration is no more, or was never made: the next start asks for a token.
810 self.forget();
811 self.not_before = Instant::now();
812 return;
813 }
814 self.failures += 1;
815 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
816 self.not_before = Instant::now() + wait;
817 let offline = run.error.as_deref().is_some_and(|e| e.contains("could not reach g1t"));
818 if offline {
819 self.set(Code::Offline, Extra::default());
820 } else if !run.listening && Registration::load(&self.app).is_none() {
821 self.set(Code::RegisterFailed, Extra { detail: run.error, ..Extra::default() });
822 } else {
823 self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
824 }
825 }
826
827 /// Stops a run's process, the way it was started: the sandbox `how` says.
828 fn stop(&mut self, run: &mut Run, how: Down) {
829 match (&mut run.process, run.engine) {
830 (Process::Sandbox(step), _) => {
831 // A launcher command still going is ended first, then the sandbox stopped.
832 if let Some((_, child)) = step.as_mut() {
833 native::stop(child);
834 }
835 let binary = native::binary(&folder(&self.app));
836 if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), how) {
837 self.write_log(&format!("g1t: the sandbox didn't stop cleanly: {why}"));
838 }
839 self.app.state::<Shared>().now.lock().unwrap().sandbox = None;
840 }
841 (Process::Child(child), Engine::Launcher) => native::stop(child),
842 (Process::Child(child), Engine::Native { docker }) => {
843 native::stop(child);
844 // A job's container outlives the runner killed under it: removed now, not at its next start.
845 if docker {
846 native::sweep(&native_dir(&self.app, run.backend));
847 }
848 }
849 }
850 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
851 }
852
853 /// Stops whatever runs now.
854 fn halt(&mut self) {
855 self.halt_as(Down::Now);
856 }
857
858 /// Stops whatever runs: the sandbox now, after its job, or for good.
859 fn halt_as(&mut self, how: Down) {
860 self.generation += 1;
861 if let Some(mut run) = self.run.take() {
862 self.stop(&mut run, how);
863 }
864 }
865
866 /// A runner an earlier g1t started and did not stop: it ended without quitting.
867 fn reap(&self) {
868 if let Some(pid) = std::fs::read_to_string(folder(&self.app).join("pid")).ok().and_then(|text| text.trim().parse().ok()) {
869 native::reap(pid);
870 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
871 }
872 }
873
874 /// Unregisters the runner the app registered, whatever backend it was.
875 fn forget(&mut self) {
876 let app = self.app.clone();
877 let Some(registration) = Registration::load(&app) else { return };
878 let binary = native::binary(&folder(&app));
879 match registration.backend {
880 // The sandbox keeps its registration for when it is chosen
881 // again (sandbox-registration.json says whose): it only stops,
882 // after its job.
883 Backend::Sandboxed => {
884 let _ = launcher::down(&binary, &native_dir(&app, Backend::Sandboxed), Down::Drain);
885 }
886 backend => native::forget(&binary, &native_dir(&app, backend)),
887 }
888 Registration::clear(&app);
889 }
890
891 /// Removes the sandbox and its registration: it was for another
892 /// workspace, or g1t no longer knows its runners.
893 fn forget_sandbox(&mut self) {
894 let binary = native::binary(&folder(&self.app));
895 if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), Down::Delete) {
896 self.write_log(&format!("g1t: the sandbox couldn't be removed: {why}"));
897 }
898 SandboxRegistration::clear(&self.app);
899 if Registration::load(&self.app).is_some_and(|r| r.backend == Backend::Sandboxed) {
900 Registration::clear(&self.app);
901 }
902 }
903
904 /// Whether what a native runner keeps of a registration is still here.
905 fn kept(&self, backend: Backend) -> bool {
906 native_dir(&self.app, backend).join("config.json").exists()
907 }
908
909 /// Asks the site for a registration token for `workspace`.
910 fn want(&mut self, workspace: &str) {
911 *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned());
912 let _ = self.app.emit_to(EventTarget::webview(frame::SITE), WANTED_EVENT, workspace.to_owned());
913 self.not_before = Instant::now() + ASK_AGAIN;
914 let signed_out = self.app.state::<Shared>().site.lock().unwrap().signed_in == Some(false);
915 self.set(if signed_out { Code::SignedOut } else { Code::Registering }, Extra::default());
916 }
917
918 /// The thread's look round: what should run, and whether it does.
919 fn look(&mut self) {
920 let settings = config::settings(&self.app);
921 let paused = *self.app.state::<Shared>().paused.lock().unwrap();
922 if !settings.runner || paused {
923 if self.run.is_some() {
924 self.halt();
925 }
926 *self.app.state::<Shared>().wanted.lock().unwrap() = None;
927 self.set(if paused { Code::Stopped } else { Code::Off }, Extra::default());
928 return;
929 }
930 let workspace = workspace(&self.app);
931 if let Some(run) = &self.run {
932 let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref();
933 let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE);
934 let (busy, listening, stepping) = (run.busy, run.listening, run.step().is_some());
935 if back {
936 if let Some(run) = self.run.as_mut() {
937 run.offline_at = None;
938 }
939 self.set(Code::Idle, Extra::default());
940 }
941 // A change of backend or workspace waits for the job to end, as an update does.
942 if moved && !busy && !stepping {
943 self.halt_as(Down::Drain);
944 self.not_before = Instant::now();
945 } else if !busy && listening && !stepping && Instant::now() >= self.next_update {
946 self.update();
947 }
948 self.poll();
949 return;
950 }
951 if Instant::now() < self.not_before {
952 return;
953 }
954 self.start(settings.runner_backend, workspace);
955 }
956
957 fn start(&mut self, backend: Backend, workspace: Option<String>) {
958 let app = self.app.clone();
959 let engine = match backend::engine(backend) {
960 Ok(engine) => engine,
961 Err(missing) => {
962 self.not_before = Instant::now() + LOOK_AGAIN;
963 let code = match missing {
964 Missing::Docker => Code::NoDocker,
965 };
966 self.set(code, Extra::default());
967 return;
968 }
969 };
970 let Some(workspace) = workspace else {
971 self.not_before = Instant::now() + LOOK_AGAIN;
972 let code = match app.state::<Shared>().site.lock().unwrap().signed_in {
973 Some(false) => Code::SignedOut,
974 Some(true) => Code::NoWorkspace,
975 None => Code::Starting,
976 };
977 self.set(code, detail("Waiting for g1t to open."));
978 return;
979 };
980 if engine == Engine::Launcher {
981 return self.start_sandbox(workspace);
982 }
983 let server = config::server(&app);
984 let registration = Registration::load(&app);
985 let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(backend);
986 let mut token = None;
987 if !ours {
988 if registration.is_some() {
989 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
990 self.forget();
991 }
992 match self.token.take() {
993 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
994 _ => return self.want(&workspace),
995 }
996 }
997 self.hold_workspace(&workspace);
998 self.set(Code::Starting, Extra::default());
999 let envs = environment(&server, backend, token.as_deref());
1000 let generation = self.generation + 1;
1001 self.generation = generation;
1002 let started = self.ensure_binary(&server).and_then(|binary| {
1003 let child = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}"))?;
1004 let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
1005 Ok(child)
1006 });
1007 match started {
1008 Ok(mut child) => {
1009 self.follow(generation, &mut child);
1010 self.run = Some(Run::new(generation, engine, backend, workspace, Process::Child(child), token.is_some()));
1011 self.set(Code::Starting, Extra::default());
1012 }
1013 Err(why) => self.not_started(workspace, token, why),
1014 }
1015 }
1016
1017 /// The runner couldn't be started: the token is kept for the next try,
1018 /// while it lasts, and it is tried again in a while.
1019 fn not_started(&mut self, workspace: String, token: Option<String>, why: String) {
1020 if let Some(given) = token {
1021 self.token = Some((workspace, given));
1022 }
1023 self.failures += 1;
1024 self.not_before = Instant::now() + LOOK_AGAIN;
1025 let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect");
1026 self.write_log(&format!("g1t: the runner could not start: {why}"));
1027 if offline {
1028 self.set(Code::Offline, Extra::default());
1029 } else {
1030 self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1031 }
1032 }
1033
1034 /// The setting names the workspace from now on, so browsing another one never moves the runner.
1035 fn hold_workspace(&self, workspace: &str) {
1036 if config::settings(&self.app).runner_workspace.as_deref() != Some(workspace) {
1037 let mut settings = config::settings(&self.app);
1038 settings.runner_workspace = Some(workspace.to_owned());
1039 let _ = config::set_settings(&self.app, settings);
1040 }
1041 }
1042
1043 /// Reads the child's output, a line at a time, onto the thread's channel.
1044 fn follow(&self, generation: u64, child: &mut Child) {
1045 fn pipe(tx: Sender<Ask>, generation: u64, from: impl Read + Send + 'static, last: bool) {
1046 std::thread::spawn(move || {
1047 for line in BufReader::new(from).lines().map_while(Result::ok) {
1048 if tx.send(Ask::Line(generation, line, last)).is_err() {
1049 return;
1050 }
1051 }
1052 if last {
1053 let _ = tx.send(Ask::Exited(generation));
1054 }
1055 });
1056 }
1057 if let Some(err) = child.stderr.take() {
1058 pipe(self.tx.clone(), generation, err, false);
1059 }
1060 if let Some(out) = child.stdout.take() {
1061 pipe(self.tx.clone(), generation, out, true);
1062 }
1063 }
1064
1065 /// `g1t-runner` installed, from the newest release when it is not.
1066 fn ensure_binary(&mut self, server: &Url) -> Result<PathBuf, String> {
1067 let folder = folder(&self.app);
1068 let binary = native::binary(&folder);
1069 if native::installed(&folder).is_some() {
1070 return Ok(binary);
1071 }
1072 self.set(Code::Updating, detail("Downloading g1t-runner."));
1073 let manifest = native::latest(server)?;
1074 let bytes = native::download(server, &manifest)?;
1075 native::put(&folder, &bytes, &manifest.version)?;
1076 self.note(None, Some(manifest.version));
1077 Ok(binary)
1078 }
1079
1080 /// A newer release or image, put in place while the runner is idle.
1081 /// The new one is downloaded and checked before the runner stops, so
1082 /// one that fails leaves it running as it was.
1083 fn update(&mut self) {
1084 self.next_update = Instant::now() + UPDATE_EVERY;
1085 if !config::settings(&self.app).auto_update {
1086 return;
1087 }
1088 let Some(engine) = self.run.as_ref().map(|run| run.engine) else { return };
1089 let server = config::server(&self.app);
1090 let folder = folder(&self.app);
1091 let release = match self.newer_release(&server) {
1092 Ok(release) => release,
1093 Err(why) => {
1094 let why = format!("g1t-runner couldn't be updated: {why}");
1095 self.write_log(&format!("g1t: {why}"));
1096 self.update_failed = Some(why.clone());
1097 return self.set(Code::UpdateFailed, detail(why));
1098 }
1099 };
1100 let Some((version, bytes)) = release else {
1101 // The program is the newest; the sandbox's image may not be
1102 // (`update` does nothing when it is).
1103 if engine == Engine::Launcher {
1104 self.sandbox_step(Step::Update);
1105 }
1106 return;
1107 };
1108 self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1109 // The sandbox's runner for Windows jobs is this program too: it
1110 // stops, after its job, before the program is replaced.
1111 self.halt_as(Down::Drain);
1112 match native::put(&folder, &bytes, &version) {
1113 Ok(()) => {
1114 self.note(None, Some(version));
1115 self.update_failed = None;
1116 // The sandbox's image comes with each release.
1117 self.image_pending = engine == Engine::Launcher;
1118 }
1119 Err(why) => self.write_log(&format!("g1t: g1t-runner {version} couldn't be put in place: {why}")),
1120 }
1121 self.not_before = Instant::now();
1122 }
1123
1124 /// A release newer than the `g1t-runner` here, downloaded and checked
1125 /// against its SHA-256: its version and program. None when this one is
1126 /// the newest, or the releases can't be asked now.
1127 fn newer_release(&self, server: &Url) -> Result<Option<(String, Vec<u8>)>, String> {
1128 let Ok(manifest) = native::latest(server) else { return Ok(None) };
1129 if !native::installed(&folder(&self.app)).is_none_or(|have| native::newer(&manifest.version, &have)) {
1130 return Ok(None);
1131 }
1132 let bytes = native::download(server, &manifest).map_err(|why| format!("{} didn't download: {why}", manifest.version))?;
1133 Ok(Some((manifest.version, bytes)))
1134 }
1135
1136 // --- The sandbox (launcher.rs) ------------------------------------------------
1137
1138 /// Starts the sandbox for `workspace`: registered with it the first
1139 /// time, with a token from the site; from its own registration after.
1140 fn start_sandbox(&mut self, workspace: String) {
1141 let app = self.app.clone();
1142 let server = config::server(&app);
1143 let binary = match self.ensure_binary(&server) {
1144 Ok(binary) => binary,
1145 Err(why) => return self.not_started(workspace, None, why),
1146 };
1147 let dir = native_dir(&app, Backend::Sandboxed);
1148 let mut status = match launcher::status(&binary, &dir) {
1149 Ok(status) => status,
1150 Err(why) => return self.no_status(why),
1151 };
1152 self.seen(&status);
1153 // Nothing here can be sandboxed: why, and how to fix it; looked at again in a while.
1154 if !status.anything_available() {
1155 self.not_before = Instant::now() + LOOK_AGAIN;
1156 let (code, extra) = shown(&status);
1157 return self.set(code, extra);
1158 }
1159 // Another backend's registration goes; the sandbox's own is kept for its workspace.
1160 if Registration::load(&app).is_some_and(|r| r.backend != Backend::Sandboxed) {
1161 self.set(Code::Starting, detail("Removing this computer's earlier registration."));
1162 self.forget();
1163 }
1164 let kept = SandboxRegistration::load(&app);
1165 let ours = kept.as_ref().is_some_and(|k| k.server == server.as_str() && k.workspace == workspace);
1166 let made = status.capabilities.iter().any(|c| c.state != State::Absent) || status.state != State::Absent;
1167 if !ours && made && kept.is_none() {
1168 // Made by hand (`g1t-runner sandbox up`), and there is one per computer: it is the person's to remove.
1169 self.not_before = Instant::now() + LOOK_AGAIN;
1170 return self.set(Code::SandboxTaken, Extra::default());
1171 }
1172 if !ours && made {
1173 // The app's sandbox, registered with another workspace or server.
1174 self.set(Code::Starting, detail("Removing this computer's sandbox for another workspace."));
1175 self.forget_sandbox();
1176 status = match launcher::status(&binary, &dir) {
1177 Ok(status) => status,
1178 Err(why) => return self.no_status(why),
1179 };
1180 }
1181 let mut token = None;
1182 if !ours || !kept.as_ref().is_some_and(|k| k.registered) || status.unregistered() {
1183 match self.token.take() {
1184 Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1185 _ => return self.want(&workspace),
1186 }
1187 SandboxRegistration { server: server.to_string(), workspace: workspace.clone(), registered: false }.save(&app);
1188 }
1189 self.hold_workspace(&workspace);
1190 let generation = self.generation + 1;
1191 self.generation = generation;
1192 self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some()));
1193 // A new release's image first, then the sandbox starts again.
1194 let step = if self.image_pending && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1195 self.image_pending = false;
1196 let name = backend::machine_name();
1197 let url = server.as_str().trim_end_matches('/').to_owned();
1198 let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref() };
1199 let args = match step {
1200 Step::Up => launcher::up_args(&dir, &up),
1201 Step::Update => launcher::update_args(&dir),
1202 };
1203 if let Err(why) = self.spawn_step(step, args) {
1204 self.run = None;
1205 return self.not_started(workspace, token, why);
1206 }
1207 self.set(Code::Starting, detail(STARTING_SANDBOX));
1208 }
1209
1210 /// `sandbox status` didn't answer: a `g1t-runner` from before its
1211 /// launcher, which a newer release replaces, or one that failed.
1212 fn no_status(&mut self, why: String) {
1213 self.write_log(&format!("g1t: the sandbox's status: {why}"));
1214 self.failures += 1;
1215 if why.contains("older than its sandbox launcher") {
1216 let server = config::server(&self.app);
1217 if let Ok(Some((version, bytes))) = self.newer_release(&server)
1218 && native::put(&folder(&self.app), &bytes, &version).is_ok()
1219 {
1220 self.note(None, Some(version));
1221 self.not_before = Instant::now();
1222 return;
1223 }
1224 self.not_before = Instant::now() + MOST_BACKOFF;
1225 return self.set(Code::NoLauncher, detail(why));
1226 }
1227 self.not_before = Instant::now() + LOOK_AGAIN;
1228 self.set(Code::SandboxFailed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1229 }
1230
1231 /// Runs a launcher command for the sandbox's run, reading its output.
1232 fn spawn_step(&mut self, step: Step, args: Vec<std::ffi::OsString>) -> Result<(), String> {
1233 let binary = native::binary(&folder(&self.app));
1234 let mut child = launcher::command(&binary, args).spawn().map_err(|e| format!("g1t-runner sandbox {} could not start: {e}", step.name()))?;
1235 let _ = std::fs::write(folder(&self.app).join("pid"), child.id().to_string());
1236 let Some(generation) = self.run.as_ref().map(|run| run.generation) else {
1237 native::stop(&mut child);
1238 return Ok(());
1239 };
1240 self.follow(generation, &mut child);
1241 let run = self.run.as_mut().expect("checked above");
1242 run.json = None;
1243 run.process = Process::Sandbox(Some((step, child)));
1244 Ok(())
1245 }
1246
1247 /// `update` on the running sandbox, while it has no job.
1248 fn sandbox_step(&mut self, step: Step) {
1249 if self.run.as_ref().is_none_or(|run| run.step().is_some()) {
1250 return;
1251 }
1252 let dir = native_dir(&self.app, Backend::Sandboxed);
1253 if let Err(why) = self.spawn_step(step, launcher::update_args(&dir)) {
1254 self.write_log(&format!("g1t: {why}"));
1255 }
1256 }
1257
1258 /// Asks the sandbox how it is, every few seconds, on a thread of its own.
1259 fn poll(&mut self) {
1260 let Some(run) = self.run.as_mut().filter(|run| run.engine == Engine::Launcher) else { return };
1261 if run.polling || run.polled_at.is_some_and(|at| at.elapsed() < launcher::STATUS_EVERY) {
1262 return;
1263 }
1264 run.polling = true;
1265 run.polled_at = Some(Instant::now());
1266 let (tx, generation) = (self.tx.clone(), run.generation);
1267 let binary = native::binary(&folder(&self.app));
1268 let dir = native_dir(&self.app, Backend::Sandboxed);
1269 let spawned = std::thread::Builder::new().name("g1t-sandbox-status".into()).spawn(move || {
1270 let _ = tx.send(Ask::Sandbox(generation, launcher::status(&binary, &dir).map(Box::new)));
1271 });
1272 if spawned.is_err() {
1273 run.polling = false;
1274 }
1275 }
1276
1277 /// What a poll said.
1278 fn polled(&mut self, generation: u64, status: Result<Box<Status>, String>) {
1279 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1280 run.polling = false;
1281 if std::mem::take(&mut run.stale) {
1282 return;
1283 }
1284 match status {
1285 Ok(status) => self.sandbox_now(*status),
1286 // Once is nothing: the next poll asks again.
1287 Err(why) => self.write_log(&format!("g1t: the sandbox's status: {why}")),
1288 }
1289 }
1290
1291 /// Keeps what a status says for the tray, settings and the site.
1292 fn seen(&mut self, status: &Status) {
1293 let shared = self.app.state::<Shared>();
1294 let mut now = shared.now.lock().unwrap();
1295 now.sandbox = Some(status.clone());
1296 if let Some(version) = status.version() {
1297 now.version = Some(version);
1298 }
1299 now.job = status.job().map(launcher::Job::said);
1300 }
1301
1302 /// The sandbox as it is now, while it is the run.
1303 fn sandbox_now(&mut self, status: Status) {
1304 let had = self.app.state::<Shared>().now.lock().unwrap().job.clone();
1305 let Some(run) = self.run.as_mut() else { return };
1306 let step = run.step();
1307 let job = status.job().map(launcher::Job::said);
1308 let was = had.filter(|_| run.busy);
1309 run.busy = job.is_some();
1310 run.listening = status.running();
1311 let update_failed = self.update_failed.clone();
1312 if was != job {
1313 if let Some(ended) = &was {
1314 self.write_log(&format!("g1t: {ended} ended."));
1315 }
1316 if let Some(took) = &job {
1317 self.write_log(&format!("g1t: Took {took}."));
1318 }
1319 }
1320 self.seen(&status);
1321 match step {
1322 Some(Step::Up) => return self.set(Code::Starting, detail(STARTING_SANDBOX)),
1323 Some(Step::Update) if status.state == State::Updating => return self.set(Code::Updating, detail("Updating the sandbox to the newest image.")),
1324 _ => {}
1325 }
1326 let (code, extra) = shown(&status);
1327 if step.is_none() && !status.running() && !matches!(status.state, State::Starting | State::Updating) {
1328 return self.sandbox_stopped(&status, code, extra);
1329 }
1330 match (code, update_failed) {
1331 (Code::Idle, Some(why)) => self.set(Code::UpdateFailed, detail(why)),
1332 (code, _) => self.set(code, extra),
1333 }
1334 }
1335
1336 /// The sandbox stopped by itself, or never got going: started again,
1337 /// waiting longer each time, or registered again when that is why.
1338 fn sandbox_stopped(&mut self, status: &Status, code: Code, extra: Extra) {
1339 let error = status.error.clone().or_else(|| extra.detail.clone()).unwrap_or_default();
1340 if self.registration_trouble(&error) {
1341 return;
1342 }
1343 self.run = None;
1344 self.generation += 1;
1345 self.failures += 1;
1346 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1347 self.not_before = Instant::now() + wait;
1348 let (code, extra) = match code {
1349 Code::Idle | Code::Busy | Code::Starting | Code::Updating => (Code::SandboxFailed, Extra { detail: status.error.clone().or_else(|| some_text("The sandbox stopped by itself.")), ..Extra::default() }),
1350 code => (code, extra),
1351 };
1352 self.set(code, Extra { wait_seconds: Some(wait.as_secs()), ..extra });
1353 }
1354
1355 /// When the sandbox's trouble is its registration: true when the app
1356 /// has seen to it, and the next start registers again.
1357 fn registration_trouble(&mut self, error: &str) -> bool {
1358 match launcher::why(error) {
1359 launcher::Why::TokenRefused | launcher::Why::Unregistered => {
1360 if let Some(mut kept) = SandboxRegistration::load(&self.app) {
1361 kept.registered = false;
1362 kept.save(&self.app);
1363 }
1364 self.halt();
1365 self.not_before = Instant::now();
1366 true
1367 }
1368 launcher::Why::Forgotten => {
1369 self.halt();
1370 self.forget_sandbox();
1371 self.not_before = Instant::now();
1372 true
1373 }
1374 _ => false,
1375 }
1376 }
1377
1378 /// A launcher command ended: what it printed last is the sandbox's status.
1379 fn launched(&mut self, generation: u64) {
1380 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1381 let Process::Sandbox(slot) = &mut run.process else { return };
1382 let Some((step, mut child)) = slot.take() else { return };
1383 let worked = child.wait().is_ok_and(|status| status.success());
1384 let _ = std::fs::remove_file(folder(&self.app).join("pid"));
1385 let printed = run.json.take().unwrap_or_default().join("\n");
1386 run.stale = run.polling;
1387 let with_token = run.with_token;
1388 let status = launcher::parse(&printed);
1389 let error = match (&status, worked) {
1390 (Ok(status), true) => status.error.clone().filter(|_| !status.running()),
1391 (Ok(status), false) => Some(status.error.clone().unwrap_or_else(|| format!("g1t-runner sandbox {} failed.", step.name()))),
1392 (Err(why), _) => Some(why.clone()),
1393 };
1394 let status = status.ok();
1395 let running = status.as_ref().is_some_and(Status::running);
1396 if let Some(error) = &error {
1397 self.write_log(&format!("g1t: sandbox {}: {error}", step.name()));
1398 }
1399 // Registered: from now on it starts with no token.
1400 if step == Step::Up && with_token && (error.is_none() || running) {
1401 let app = self.app.clone();
1402 if let Some(run) = self.run.as_mut() {
1403 run.with_token = false;
1404 let registration = Registration { server: config::server(&app).to_string(), workspace: run.workspace.clone(), backend: Backend::Sandboxed };
1405 SandboxRegistration { server: registration.server.clone(), workspace: registration.workspace.clone(), registered: true }.save(&app);
1406 registration.save(&app);
1407 }
1408 }
1409 if let Some(error) = error.clone() {
1410 if self.registration_trouble(&error) {
1411 return;
1412 }
1413 if step == Step::Update {
1414 self.update_failed = Some(format!("The sandbox's image couldn't be updated: {error}"));
1415 } else if !running {
1416 // Nothing runs: tidied away, and tried again in a while.
1417 self.halt();
1418 self.failures += 1;
1419 let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1420 self.not_before = Instant::now() + wait;
1421 let (code, cause, fix) = status
1422 .as_ref()
1423 .and_then(diagnosis::sandbox_trouble)
1424 .unwrap_or_else(|| (diagnosis::sandbox_code(launcher::why(&error)), error.clone(), None));
1425 return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
1426 }
1427 } else if step == Step::Up {
1428 self.failures = 0;
1429 } else {
1430 self.update_failed = None;
1431 }
1432 if step == Step::Update && !running {
1433 // Updated while stopped: it starts again with the new image.
1434 self.run = None;
1435 self.generation += 1;
1436 self.not_before = Instant::now();
1437 return;
1438 }
1439 if let Some(status) = status {
1440 self.sandbox_now(status);
1441 }
1442 }
1443}
1444
1445/// What the tray says while the sandbox starts.
1446const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes.";
1447
1448fn some_text(text: &str) -> Option<String> {
1449 Some(text.to_owned())
1450}
1451
1452/// What the app shows for how the sandbox is, with no launcher command
1453/// running: what is wrong with Linux jobs first, else what its runners do.
1454fn shown(status: &Status) -> (Code, Extra) {
1455 if let Some((code, cause, fix)) = diagnosis::sandbox_trouble(status) {
1456 return (code, Extra { detail: Some(cause), fix, ..Extra::default() });
1457 }
1458 let linux = status.linux();
1459 match (linux.state, status.job()) {
1460 (_, Some(_)) => (Code::Busy, Extra::default()),
1461 (State::Running, None) => (Code::Idle, Extra::default()),
1462 (State::Starting, None) => (Code::Starting, detail(STARTING_SANDBOX)),
1463 (State::Updating, None) => (Code::Updating, detail("Updating the sandbox to the newest image.")),
1464 // Linux jobs aren't running, but Windows jobs are.
1465 _ if status.running() => (Code::Idle, Extra::default()),
1466 _ => (Code::SandboxFailed, detail("The sandbox isn't running.")),
1467 }
1468}
1469
1470/// What `register-and-run` reads: where g1t is, the runner's name and
1471/// labels, and the token, the first time only. The app updates the runner,
1472/// so the runner does not update itself; registering again takes the
1473/// place of this computer's earlier runner of the same name.
1474fn environment(server: &Url, backend: Backend, token: Option<&str>) -> Vec<(&'static str, String)> {
1475 let mut envs = vec![
1476 ("G1T_RUNNER_URL", server.as_str().trim_end_matches('/').to_owned()),
1477 ("G1T_RUNNER_NAME", backend::machine_name()),
1478 ("G1T_RUNNER_LABELS", "desktop".to_owned()),
1479 ("G1T_RUNNER_REPLACE", "1".to_owned()),
1480 ("G1T_RUNNER_NO_AUTO_UPDATE", "1".to_owned()),
1481 ];
1482 if backend == Backend::Host {
1483 envs.push(("G1T_RUNNER_NO_DOCKER", "1".to_owned()));
1484 }
1485 if let Some(token) = token {
1486 envs.push(("G1T_RUNNER_TOKEN", token.to_owned()));
1487 }
1488 envs
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494
1495 fn view(code: Code, job: Option<&str>) -> View {
1496 let facts = Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "box".into(), job: job.map(str::to_owned), ..Facts::default() };
1497 View {
1498 diagnosis: diagnosis::diagnose(code, &facts),
1499 closed: diagnosis::closed(&facts),
1500 running: code == Code::Idle,
1501 version: None,
1502 backend: Backend::Sandboxed,
1503 workspace: Some("acme".into()),
1504 workspaces: vec![],
1505 enabled: true,
1506 paused: false,
1507 link: None,
1508 machine: "box".into(),
1509 platform: "windows".into(),
1510 log: vec![],
1511 }
1512 }
1513
1514 #[test]
1515 fn the_tray_says_what_the_runner_is_doing() {
1516 assert_eq!(headline(&view(Code::Idle, None)), "Runner: Waiting for jobs");
1517 assert_eq!(headline(&view(Code::Busy, Some("Build (acme/web)"))), "Runner: Running Build (acme/web)");
1518 assert_eq!(headline(&view(Code::NoDocker, None)), "Runner: Not running: Docker isn't running");
1519 assert_eq!(headline(&view(Code::Busy, Some(&"x".repeat(200)))).chars().count(), 60);
1520 }
1521
1522 #[test]
1523 fn the_runner_is_told_where_and_who_and_the_token_only_once() {
1524 let server = Url::parse("https://g1t.sh/").unwrap();
1525 let envs = environment(&server, Backend::Host, Some("g1trt_abc"));
1526 let get = |name: &str| envs.iter().find(|(n, _)| *n == name).map(|(_, v)| v.as_str());
1527 assert_eq!(get("G1T_RUNNER_URL"), Some("https://g1t.sh"));
1528 assert_eq!(get("G1T_RUNNER_LABELS"), Some("desktop"));
1529 assert_eq!(get("G1T_RUNNER_NO_DOCKER"), Some("1"));
1530 assert_eq!(get("G1T_RUNNER_NO_AUTO_UPDATE"), Some("1"));
1531 assert_eq!(get("G1T_RUNNER_TOKEN"), Some("g1trt_abc"));
1532 let again = environment(&server, Backend::Docker, None);
1533 assert!(!again.iter().any(|(name, _)| *name == "G1T_RUNNER_TOKEN" || *name == "G1T_RUNNER_NO_DOCKER"));
1534 }
1535
1536 fn status(json: &str) -> Status {
1537 launcher::parse(json).unwrap()
1538 }
1539
1540 #[test]
1541 fn the_sandbox_shows_what_its_runners_do_and_linux_trouble_first() {
1542 let running = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not)."}]}"#);
1543 assert_eq!(shown(&running).0, Code::Idle);
1544 let busy = status(r#"{"schema":1,"state":"running","job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}"#);
1545 assert_eq!(shown(&busy).0, Code::Busy);
1546 let starting = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting"}]}"#);
1547 assert_eq!(shown(&starting).0, Code::Starting);
1548 let updating = status(r#"{"schema":1,"state":"updating","capabilities":[{"id":"linux","available":true,"state":"updating"}]}"#);
1549 assert_eq!(shown(&updating).0, Code::Updating);
1550 // Windows jobs run while WSL is missing: what is wrong with Linux jobs is what it says.
1551 let no_wsl = status(r#"{"schema":1,"state":"error","error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","capabilities":[
1552 {"id":"linux","available":false,"state":"error","cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","fix":"Open a terminal as administrator and run `wsl --install --no-distribution`."},
1553 {"id":"windows","available":true,"state":"running"}]}"#);
1554 let (code, extra) = shown(&no_wsl);
1555 assert_eq!(code, Code::NoWsl);
1556 assert!(extra.detail.unwrap().starts_with("WSL is not installed"));
1557 let failed = status(r#"{"schema":1,"state":"error","error":"the sandbox did not start within 180 seconds","capabilities":[{"id":"linux","available":true,"state":"error"}]}"#);
1558 let (code, extra) = shown(&failed);
1559 assert_eq!((code, extra.detail.as_deref()), (Code::SandboxFailed, Some("the sandbox did not start within 180 seconds")));
1560 let stopped = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped"},{"id":"windows","available":true,"state":"stopped"}]}"#);
1561 assert_eq!(shown(&stopped).0, Code::SandboxFailed);
1562 }
1563
1564 #[test]
1565 fn the_site_names_workspaces_in_its_own_words() {
1566 let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap();
1567 assert_eq!(offered[1], Offered { slug: "solo".into(), name: None });
1568 }
1569}