Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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