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