Skip to main content

superfluid_daemon/
selfupdate.rs

1//! `superfluid self update|list|use|check|install`: releases side by side under `$SUPERFLUID_HOME/versions`, a
2//! `current` link the commands on PATH go through, and the daily check `serve` runs.
3
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Output};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde_json::{json, Value};
10use superfluid_adapter_kit::install::{fetch, single_top, unpack, version_key, Asset};
11
12use crate::runtime_pick::RuntimeId;
13use crate::runtimes::Catalog;
14
15pub const REPO: &str = "basecompute/superfluid";
16const LISTING: &str = "https://api.github.com/repos/basecompute/superfluid/releases?per_page=20";
17/// A release younger than this is not announced by the daily check, so early reports land first.
18pub const HOLD_OFF_DAYS: i64 = 3;
19const CHECK_EVERY: Duration = Duration::from_secs(24 * 60 * 60);
20pub const NO_CHECK_ENV: &str = "SUPERFLUID_NO_UPDATE_CHECK";
21/// Where the release listing is read from, for a mirror or a test: a URL or a file.
22pub const LISTING_ENV: &str = "SUPERFLUID_RELEASES";
23const LINKED: &[&str] = &["superfluid", "superfluid-workerd", "superfluid-noded"];
24/// The file at the root of a release a package manager installed (Homebrew, pip, npm), one line `<manager>: <command>`:
25/// the copy is that manager's to update, with that command.
26pub const INSTALLED_BY: &str = "installed-by";
27/// How long a new copy's `--version` or `runtimes --json` may take: it runs under the lock, which a copy that hangs
28/// would hold for every later update. Longer than the runtime checks `runtimes --json` makes, a minute each at most.
29const PROBE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
30
31pub const HELP: &str = "Update superfluid itself, or switch between the versions installed here.
32
33Usage:
34  superfluid self update [--version <v>] [--dry-run]   Install the newest release that fits this machine and make it current
35  superfluid self check                                 Ask whether a newer release exists, without installing
36  superfluid self list                                  The versions installed here, and which is current
37  superfluid self use <v>                               Make an installed version current (the one an update replaced stays)
38  superfluid self install --from <dir> [--prefix <dir>] Place an unpacked release here; what the install script runs
39
40Releases live side by side under $SUPERFLUID_HOME/versions/<v>, with a `current` link that the
41`superfluid` on PATH goes through. An update unpacks the new release beside the old one, proves
42it starts, flips the link, keeps the version it replaced and removes older ones (a version with
43a `keep` file inside, or one a running server started from, stays). A running server keeps the
44version it started with until it is started again.
45
46With a baseRT engine here, a release must declare the engine's ABI number; with llama.cpp or
47MLX only, the newest release fits. A superfluid installed by a package manager or cargo is
48updated the way it was installed, and this command says so.
49
50`superfluid serve` checks for a newer release once a day, after the server is up, and prints
51one line; SUPERFLUID_NO_UPDATE_CHECK=1 or --offline turns that off. A release is announced a
52few days after it is published.
53";
54
55/// The release asset name for this build's platform.
56pub fn target() -> Option<&'static str> {
57    match (std::env::consts::OS, std::env::consts::ARCH) {
58        ("macos", "aarch64") => Some("aarch64-apple-darwin"),
59        ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
60        ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
61        _ => None,
62    }
63}
64
65pub fn running() -> &'static str {
66    env!("CARGO_PKG_VERSION")
67}
68
69
70/// Whether `v` can name a version directory: it starts with a digit and holds only what a version does, so it is one
71/// path component and never empty, `.` or `..`, which would make the version's directory the layout or above it.
72fn is_version(v: &str) -> bool {
73    v.starts_with(|c: char| c.is_ascii_digit()) && v.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
74}
75
76/// A part of a pre-release, `rc` or `1` in `0.2.0-rc.1`: a number comes before a word, as semver orders them.
77#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
78enum PreRelease {
79    Number(u64),
80    Word(String),
81}
82
83/// A version's place in release order: by its numbers, then a release after the pre-releases that lead to it
84/// (`0.2.0-rc.1` before `0.2.0`, which the numbers alone put the other way), then the pre-release part by part; build
85/// metadata after a `+` counts for nothing.
86fn release_order(v: &str) -> (Vec<u64>, bool, Vec<PreRelease>) {
87    let v = v.split('+').next().unwrap_or(v);
88    let (numbers, pre) = match v.split_once('-') {
89        Some((numbers, pre)) => (numbers, Some(pre)),
90        None => (v, None),
91    };
92    let parts = pre
93        .map(|p| p.split('.').map(|id| id.parse().map(PreRelease::Number).unwrap_or_else(|_| PreRelease::Word(id.to_string()))).collect())
94        .unwrap_or_default();
95    (version_key(numbers), pre.is_none(), parts)
96}
97
98/// `$SUPERFLUID_HOME/versions`: a directory per release, `current`, `previous`, and `prefix` naming the bin directory that links here.
99pub struct Versions {
100    pub dir: PathBuf,
101}
102
103impl Versions {
104    pub fn of(home: &Path) -> Versions {
105        Versions { dir: home.join("versions") }
106    }
107
108    pub fn version_dir(&self, v: &str) -> PathBuf {
109        self.dir.join(v)
110    }
111
112    fn binary(&self, v: &str) -> PathBuf {
113        self.version_dir(v).join("bin").join("superfluid")
114    }
115
116    pub fn current(&self) -> Option<String> {
117        let target = std::fs::read_link(self.dir.join("current")).ok()?;
118        target.file_name().and_then(|n| n.to_str()).map(str::to_string)
119    }
120
121    pub fn previous(&self) -> Option<String> {
122        let v = std::fs::read_to_string(self.dir.join("previous")).ok()?.trim().to_string();
123        (!v.is_empty() && self.binary(&v).is_file()).then_some(v)
124    }
125
126    /// The complete versions here, newest first.
127    pub fn on_disk(&self) -> Vec<String> {
128        let mut all: Vec<String> = std::fs::read_dir(&self.dir)
129            .map(|rd| {
130                rd.flatten()
131                    .filter_map(|e| e.file_name().to_str().map(str::to_string))
132                    .filter(|n| !n.starts_with('.') && n != "current" && n.chars().next().is_some_and(|c| c.is_ascii_digit()))
133                    .filter(|n| self.binary(n).is_file())
134                    .collect()
135            })
136            .unwrap_or_default();
137        all.sort_by_key(|v| std::cmp::Reverse(release_order(v)));
138        all
139    }
140
141    pub fn prefix(&self) -> Option<PathBuf> {
142        let p = std::fs::read_to_string(self.dir.join("prefix")).ok()?.trim().to_string();
143        (!p.is_empty()).then(|| PathBuf::from(p))
144    }
145
146    /// Point `current` at `v`, atomically, remembering what it pointed at.
147    pub fn set_current(&self, v: &str) -> Result<(), String> {
148        if !self.binary(v).is_file() {
149            return Err(format!("{} is not an installed version (see `superfluid self list`)", v));
150        }
151        if let Some(was) = self.current().filter(|was| was != v) {
152            std::fs::write(self.dir.join("previous"), format!("{was}\n")).map_err(|e| format!("{}: {e}", self.dir.display()))?;
153        }
154        let tmp = self.dir.join(".current.tmp");
155        let _ = std::fs::remove_file(&tmp);
156        std::os::unix::fs::symlink(v, &tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
157        std::fs::rename(&tmp, self.dir.join("current")).map_err(|e| format!("{}: {e}", self.dir.join("current").display()))
158    }
159
160    /// Removes every version but `current`, `previous`, one with a `keep` file, and one a running process started from.
161    pub fn prune(&self) -> Result<Vec<String>, String> {
162        let (current, previous) = (self.current(), self.previous());
163        let mut gone = Vec::new();
164        for v in self.on_disk() {
165            let dir = self.version_dir(&v);
166            if Some(&v) == current.as_ref() || Some(&v) == previous.as_ref() || dir.join("keep").exists() || running_from(&dir) {
167                continue;
168            }
169            std::fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
170            gone.push(v);
171        }
172        Ok(gone)
173    }
174
175    /// The names in `prefix` that are the install script's real files, not links.
176    fn legacy_files(&self, prefix: &Path) -> Vec<&'static str> {
177        LINKED.iter().copied().filter(|name| prefix.join(name).symlink_metadata().is_ok_and(|m| m.file_type().is_file())).collect()
178    }
179
180    /// The links in `prefix`: the three commands through `current`, and `superfluid-<v>` per version. The install script's
181    /// real files are replaced on a migration, except `superfluid-workerd` while a server still runs from it (it starts its
182    /// workers by that path), which a `migrating` note has the next link finish once nothing runs from there. The note
183    /// names the processes that ran from there, by pid and start time: once a link is renamed over a server's own
184    /// executable, macOS may give no path for it, and a server found by its path alone would be missed.
185    pub fn link(&self, prefix: &Path, replace_files: bool) -> Result<Vec<String>, String> {
186        std::fs::create_dir_all(prefix).map_err(|e| format!("{}: {e}", prefix.display()))?;
187        std::fs::write(self.dir.join("prefix"), format!("{}\n", prefix.display())).map_err(|e| format!("{}: {e}", self.dir.display()))?;
188        let mut notes = Vec::new();
189        let pending = self.dir.join("migrating");
190        let legacy = self.legacy_files(prefix);
191        let migrating = !legacy.is_empty() && (replace_files || pending.exists());
192        // A process the note names counts while it is the one that started then, whatever path is given for it now.
193        let mut holding: Vec<(i32, u64)> = match migrating {
194            true => std::fs::read_to_string(&pending)
195                .unwrap_or_default()
196                .lines()
197                .skip(1)
198                .filter_map(|l| l.split_once(' ').and_then(|(p, at)| Some((p.parse().ok()?, at.parse().ok()?))))
199                .filter(|&(p, at)| started_at(p) == Some(at))
200                .collect(),
201            false => Vec::new(),
202        };
203        if migrating {
204            for p in processes_from(prefix) {
205                if !holding.contains(&p) {
206                    holding.push(p);
207                }
208            }
209        }
210        let in_use = !holding.is_empty();
211        let replace: Vec<&str> = match (migrating, in_use) {
212            (true, true) => vec!["superfluid"],
213            (true, false) => LINKED.to_vec(),
214            (false, _) => Vec::new(),
215        };
216        let ours = |link: &Path| std::fs::read_link(link).map(|t| t.starts_with(&self.dir)).unwrap_or(false);
217        let relink = |link: &Path, target: &Path| -> Result<(), String> {
218            let tmp = link.with_file_name(format!(".{}.tmp", link.file_name().and_then(|n| n.to_str()).unwrap_or("link")));
219            let _ = std::fs::remove_file(&tmp);
220            std::os::unix::fs::symlink(target, &tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
221            std::fs::rename(&tmp, link).map_err(|e| format!("{}: {e}", link.display()))
222        };
223        for name in LINKED {
224            let link = prefix.join(name);
225            let target = self.dir.join("current").join("bin").join(name);
226            match link.symlink_metadata() {
227                Ok(m) if m.file_type().is_symlink() && ours(&link) => continue,
228                Ok(m) if m.file_type().is_symlink() || replace.contains(name) => relink(&link, &target)?,
229                Ok(_) if migrating => continue,
230                Ok(_) => {
231                    notes.push(format!("{} is not a link into {}; left as it is", link.display(), self.dir.display()));
232                    continue;
233                }
234                Err(_) => relink(&link, &target)?,
235            }
236        }
237        if migrating {
238            let libexec = prefix.join("..").join("libexec").join("superfluid");
239            if in_use {
240                let note: String = holding.iter().map(|(p, at)| format!("{p} {at}\n")).collect();
241                std::fs::write(&pending, format!("{}\n{note}", prefix.display())).map_err(|e| format!("{}: {e}", pending.display()))?;
242                notes.push(format!(
243                    "a server still runs from the copy the install script placed in {}: its superfluid-workerd, superfluid-noded and workers stay until it stops; the next update, switch or install finishes the move",
244                    prefix.display()
245                ));
246            } else {
247                if let Ok(rd) = std::fs::read_dir(&libexec) {
248                    for e in rd.flatten() {
249                        let name = e.file_name().to_string_lossy().to_string();
250                        if name.starts_with("superfluid-worker-") || name.starts_with("libsuperfluid_tokenizer_") {
251                            let _ = std::fs::remove_file(e.path());
252                        }
253                    }
254                    let _ = std::fs::remove_dir(&libexec);
255                }
256                let _ = std::fs::remove_file(&pending);
257                notes.push(format!("the install script's copy in {} now links into {}; its workers were removed", prefix.display(), self.dir.display()));
258            }
259        } else if pending.exists() && legacy.is_empty() {
260            let _ = std::fs::remove_file(&pending);
261        }
262        let here = self.on_disk();
263        for v in &here {
264            let link = prefix.join(format!("superfluid-{v}"));
265            if !ours(&link) {
266                relink(&link, &self.binary(v))?;
267            }
268        }
269        if let Ok(rd) = std::fs::read_dir(prefix) {
270            for e in rd.flatten() {
271                let name = e.file_name().to_string_lossy().to_string();
272                if let Some(v) = name.strip_prefix("superfluid-") {
273                    if v.chars().next().is_some_and(|c| c.is_ascii_digit()) && !here.iter().any(|h| h == v) && ours(&e.path()) {
274                        let _ = std::fs::remove_file(e.path());
275                    }
276                }
277            }
278        }
279        Ok(notes)
280    }
281}
282
283/// Where the running executable came from, which decides who updates it.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum Installed {
286    /// `<home>/versions/<v>/bin/superfluid`: ours to update.
287    Managed { versions: PathBuf, version: String },
288    /// The install script's layout before versions existed: real files in `<prefix>/bin` and `<prefix>/libexec/superfluid`.
289    InPlace { prefix: PathBuf },
290    /// A package manager (an `installed-by` file says which), `cargo install`, or a source build.
291    Other(PathBuf),
292}
293
294pub fn installed_as(exe: &Path) -> Installed {
295    let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
296    let parts: Vec<&std::ffi::OsStr> = exe.iter().collect();
297    let n = parts.len();
298    if n >= 4 && parts[n - 2] == "bin" && parts[n - 4] == "versions" {
299        let versions = exe.ancestors().nth(3).map(Path::to_path_buf).unwrap_or_default();
300        return Installed::Managed { versions, version: parts[n - 3].to_string_lossy().to_string() };
301    }
302    if let Some(prefix) = exe.parent().filter(|b| b.file_name().is_some_and(|n| n == "bin")).and_then(Path::parent) {
303        // A package keeps the release's layout too, so it is told apart by its file, not by where it is.
304        if prefix.join(INSTALLED_BY).is_file() {
305            return Installed::Other(exe);
306        }
307        if prefix.join("libexec").join("superfluid").is_dir() {
308            return Installed::InPlace { prefix: prefix.to_path_buf() };
309        }
310    }
311    Installed::Other(exe)
312}
313
314/// The package manager that installed `exe` and the command that updates it, from the `installed-by` file at the root
315/// of its release.
316fn package_manager(exe: &Path) -> Option<(String, String)> {
317    let line = std::fs::read_to_string(exe.parent()?.parent()?.join(INSTALLED_BY)).ok()?;
318    let (manager, command) = line.trim().split_once(": ")?;
319    Some((manager.trim().to_string(), command.trim().to_string()))
320}
321
322/// The command that updates the running superfluid, for the line `serve` prints when a newer release is out.
323fn update_command(exe: &Path) -> String {
324    let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
325    package_manager(&exe).map(|(_, command)| command).unwrap_or_else(|| "superfluid self update".to_string())
326}
327
328fn not_ours(exe: &Path) -> String {
329    let p = exe.to_string_lossy();
330    if let Some((manager, command)) = package_manager(exe) {
331        return format!("{p} was installed by {manager}; `{command}` updates it");
332    }
333    let how = if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
334        "Homebrew installed it: `brew upgrade superfluid`"
335    } else if p.contains("/.cargo/bin/") {
336        "cargo installed it: `cargo install superfluid`"
337    } else if p.contains("/target/") {
338        "it is a source build: build it again"
339    } else {
340        "update it the way it was installed"
341    };
342    format!("{p} was not installed by the install script or by `superfluid self update`; {how}")
343}
344
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct Release {
348    pub version: String,
349    /// YYYY-MM-DD
350    pub published: String,
351    pub tarball: Asset,
352    pub sha256_url: String,
353    /// The basert engine ABI the release declares in an asset name.
354    pub abi: Option<u32>,
355    pub compat_url: Option<String>,
356}
357
358/// The releases of the listing with a tarball for `target`, newest first; drafts and prereleases left out.
359pub fn releases(listing: &Value, target: &str) -> Vec<Release> {
360    let tar = format!("superfluid-{target}.tar.gz");
361    let mut out: Vec<Release> = listing
362        .as_array()
363        .map(|a| a.iter().filter_map(|r| release_of(r, &tar, target)).collect())
364        .unwrap_or_default();
365    out.sort_by_key(|r| std::cmp::Reverse(release_order(&r.version)));
366    out
367}
368
369fn release_of(r: &Value, tar: &str, target: &str) -> Option<Release> {
370    if r["draft"].as_bool().unwrap_or(false) || r["prerelease"].as_bool().unwrap_or(false) {
371        return None;
372    }
373    let version = r["tag_name"].as_str()?.trim_start_matches('v').to_string();
374    if !is_version(&version) {
375        return None;
376    }
377    let assets = r["assets"].as_array()?;
378    let url = |name: &str| assets.iter().find(|a| a["name"] == name).and_then(|a| a["browser_download_url"].as_str()).map(str::to_string);
379    let size = assets.iter().find(|a| a["name"] == tar).and_then(|a| a["size"].as_u64());
380    let abi = assets.iter().find_map(|a| a["name"].as_str()?.strip_prefix("superfluid-basert-abi-")?.strip_suffix(".txt")?.parse().ok());
381    Some(Release {
382        published: r["published_at"].as_str().unwrap_or_default().chars().take(10).collect(),
383        tarball: Asset { name: tar.to_string(), url: url(tar)?, sha256: None, size },
384        sha256_url: url(&format!("{tar}.sha256"))?,
385        abi,
386        compat_url: url(&format!("superfluid-{target}.compat.json")),
387        version,
388    })
389}
390
391/// What a release must declare to drive the baseRT engine here.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub enum Engine {
394    /// No engine, or none this superfluid can tell: the newest release fits.
395    None,
396    Abi(u32),
397    /// A 0.3.x engine, which states no number: the release must accept that line.
398    Unnumbered(String),
399}
400
401pub fn engine_here(catalog: &Catalog) -> Engine {
402    if std::env::var_os("BASERT_LIB").is_none() && !catalog.package_dir(RuntimeId::new("basert")).is_dir() {
403        return Engine::None;
404    }
405    match catalog.inspect(RuntimeId::new("basert")).usable {
406        Ok(rt) => match (rt.abi, rt.version.as_deref()) {
407            (Some(n), _) => Engine::Abi(n as u32),
408            (None, Some(v)) => Engine::Unnumbered(v.split(|c: char| !c.is_ascii_digit() && c != '.').next().unwrap_or("").split('.').take(2).collect::<Vec<_>>().join(".")),
409            (None, None) => Engine::None,
410        },
411        Err(_) => Engine::None,
412    }
413}
414
415impl Engine {
416    pub fn describe(&self) -> String {
417        match self {
418            Engine::None => "no baseRT engine here".into(),
419            Engine::Abi(n) => format!("the baseRT engine here states ABI {n}"),
420            Engine::Unnumbered(minor) => format!("the baseRT engine here is {minor}.x and states no ABI"),
421        }
422    }
423}
424
425/// Whether `release` drives the engine; `compat` fetches its compat.json when the asset names alone do not say.
426pub fn fits(release: &Release, engine: &Engine, compat: &dyn Fn(&str) -> Option<Value>) -> bool {
427    match engine {
428        Engine::None => true,
429        Engine::Abi(n) => match release.abi {
430            Some(have) => have == *n,
431            None => release.compat_url.as_deref().and_then(compat).and_then(|c| c["runtimes"]["basert"]["abi"].as_u64()) == Some(u64::from(*n)),
432        },
433        Engine::Unnumbered(minor) => release
434            .compat_url
435            .as_deref()
436            .and_then(compat)
437            .and_then(|c| c["runtimes"]["basert"]["unnumbered_minor"].as_str().map(str::to_string))
438            .is_some_and(|m| m == *minor),
439    }
440}
441
442/// The newest release that fits, skipping ones published fewer than `hold_off` days ago when given.
443pub fn newest<'a>(releases: &'a [Release], engine: &Engine, hold_off: Option<i64>, compat: &dyn Fn(&str) -> Option<Value>) -> Option<&'a Release> {
444    releases.iter().find(|r| hold_off.is_none_or(|days| age_days(&r.published).is_some_and(|age| age >= days)) && fits(r, engine, compat))
445}
446
447/// The release listing to read: `SUPERFLUID_RELEASES`, else GitHub's.
448pub fn listing_source() -> String {
449    std::env::var(LISTING_ENV).ok().filter(|s| !s.is_empty()).unwrap_or_else(|| LISTING.to_string())
450}
451
452/// A small text or JSON document: a file, or an https URL through curl.
453fn get_text(url: &str, max_time: u32) -> Result<String, String> {
454    if let Some(path) = url.strip_prefix("file://").or_else(|| (!url.contains("://")).then_some(url)) {
455        return std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"));
456    }
457    if !url.starts_with("https://") {
458        return Err(format!("{url}: only https:// URLs are fetched"));
459    }
460    let out = Command::new("curl")
461        .args(["-fsSL", "--proto", "=https", "--tlsv1.2", "--max-time", &max_time.to_string(), "-H", "Accept: application/vnd.github+json"])
462        .arg(url)
463        .output()
464        .map_err(|e| format!("curl: {e}"))?;
465    if !out.status.success() {
466        return Err(format!("fetching {url} failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
467    }
468    Ok(String::from_utf8_lossy(&out.stdout).to_string())
469}
470
471fn get_json(url: &str, max_time: u32) -> Result<Value, String> {
472    serde_json::from_str(&get_text(url, max_time)?).map_err(|e| format!("{url}: {e}"))
473}
474
475pub fn fetch_releases(source: &str, target: &str, max_time: u32) -> Result<Vec<Release>, String> {
476    let listing = get_json(source, max_time)?;
477    let found = releases(&listing, target);
478    if found.is_empty() {
479        return Err(format!("{source} lists no release with a superfluid-{target}.tar.gz"));
480    }
481    Ok(found)
482}
483
484fn compat_fetcher(max_time: u32) -> impl Fn(&str) -> Option<Value> {
485    move |url: &str| get_json(url, max_time).ok()
486}
487
488
489fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
490    let y = if m <= 2 { y - 1 } else { y };
491    let era = if y >= 0 { y } else { y - 399 } / 400;
492    let yoe = y - era * 400;
493    let mp = (m + 9) % 12;
494    let doy = (153 * mp + 2) / 5 + d - 1;
495    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
496    era * 146097 + doe - 719468
497}
498
499fn age_days(published: &str) -> Option<i64> {
500    let mut parts = published.split('-').map(|p| p.parse::<i64>().ok());
501    let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??);
502    let today = (SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs() / 86_400) as i64;
503    Some(today - days_from_civil(y, m, d))
504}
505
506fn now_secs() -> u64 {
507    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
508}
509
510
511/// The other processes on this machine, each with its executable as a real path.
512#[cfg(target_os = "linux")]
513fn other_processes() -> Vec<(i32, PathBuf)> {
514    let me = std::process::id() as i32;
515    std::fs::read_dir("/proc")
516        .map(|rd| {
517            rd.flatten()
518                .filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok().filter(|&p| p != me).map(|p| (p, e.path())))
519                .filter_map(|(p, dir)| Some((p, std::fs::read_link(dir.join("exe")).ok()?)))
520                .collect()
521        })
522        .unwrap_or_default()
523}
524
525/// When `pid` started, in clock ticks since boot: with the pid, what tells a process from a later one given its number.
526#[cfg(target_os = "linux")]
527fn started_at(pid: i32) -> Option<u64> {
528    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
529    // The command name, in parentheses, may hold spaces; the start time is the 20th field after it.
530    stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
531}
532
533#[cfg(target_os = "macos")]
534fn other_processes() -> Vec<(i32, PathBuf)> {
535    let me = std::process::id() as libc::c_int;
536    // SAFETY: with a null buffer, proc_listallpids only reports how many pids there are.
537    let n = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
538    if n <= 0 {
539        return Vec::new();
540    }
541    let mut pids = vec![0 as libc::c_int; n as usize * 2 + 16];
542    let bytes = (pids.len() * std::mem::size_of::<libc::c_int>()) as libc::c_int;
543    // SAFETY: the buffer is this function's, and its size in bytes is what is passed.
544    let got = unsafe { libc::proc_listallpids(pids.as_mut_ptr() as *mut libc::c_void, bytes) };
545    pids.truncate(got.max(0) as usize);
546    pids.into_iter()
547        .filter(|&p| p > 0 && p != me)
548        .filter_map(|p| {
549            let mut buf = vec![0u8; 4096];
550            // SAFETY: proc_pidpath writes at most the buffer's length, which it is told.
551            let len = unsafe { libc::proc_pidpath(p, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
552            (len > 0).then(|| (p, PathBuf::from(String::from_utf8_lossy(&buf[..len as usize]).to_string())))
553        })
554        .collect()
555}
556
557/// When `pid` started, in microseconds since the epoch.
558#[cfg(target_os = "macos")]
559fn started_at(pid: i32) -> Option<u64> {
560    // SAFETY: proc_bsdinfo is plain data, for which all zeroes is a value.
561    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
562    let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
563    // SAFETY: proc_pidinfo writes at most `size` bytes into `info`, which is that big.
564    let got = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, &mut info as *mut _ as *mut libc::c_void, size) };
565    (got == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
566}
567
568#[cfg(not(any(target_os = "linux", target_os = "macos")))]
569fn other_processes() -> Vec<(i32, PathBuf)> {
570    Vec::new()
571}
572
573#[cfg(not(any(target_os = "linux", target_os = "macos")))]
574fn started_at(_pid: i32) -> Option<u64> {
575    None
576}
577
578/// Whether another process runs an executable under `dir`.
579pub fn running_from(dir: &Path) -> bool {
580    !processes_from(dir).is_empty()
581}
582
583/// The other processes running an executable under `dir`, by pid and when each started.
584fn processes_from(dir: &Path) -> Vec<(i32, u64)> {
585    let Ok(dir) = std::fs::canonicalize(dir) else { return Vec::new() };
586    other_processes().into_iter().filter(|(_, exe)| exe.starts_with(&dir)).map(|(p, _)| (p, started_at(p).unwrap_or(0))).collect()
587}
588
589
590#[derive(Debug, Default, Clone)]
591pub struct Options {
592    pub version: Option<String>,
593    pub dry_run: bool,
594    /// The release listing; `listing_source()` when not set.
595    pub source: Option<String>,
596}
597
598/// What `self update` did, line by line.
599#[derive(Debug, Default)]
600pub struct Report {
601    pub lines: Vec<String>,
602    pub changed: bool,
603}
604
605impl Report {
606    fn say(&mut self, line: impl Into<String>) {
607        self.lines.push(line.into());
608    }
609}
610
611/// `cmd`'s output, or an error once it has run for `limit`, when it is killed.
612fn output_within(cmd: &mut Command, limit: Duration) -> Result<Output, String> {
613    let what = cmd.get_program().to_string_lossy().to_string();
614    match crate::exec::output_within(cmd, limit) {
615        Ok(Some(out)) => Ok(out),
616        Ok(None) => Err(format!("{what} did not answer within {}s", limit.as_secs())),
617        Err(e) => Err(format!("{what}: {e}")),
618    }
619}
620
621fn version_of(binary: &Path) -> Result<String, String> {
622    let out = output_within(Command::new(binary).arg("--version"), PROBE_TIMEOUT)?;
623    let text = String::from_utf8_lossy(&out.stdout);
624    text.split_whitespace().nth(1).map(str::to_string).filter(|v| out.status.success() && is_version(v)).ok_or_else(|| {
625        format!("{} did not say its version ({}): {}", binary.display(), out.status, String::from_utf8_lossy(&out.stderr).trim())
626    })
627}
628
629/// The proof a host runs: the copy's `--version` and `runtimes --json`, the basert row ready when an engine is in use.
630fn probe(versions: &Versions, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
631    probe_binary(&versions.binary(v), v, home, engine)
632}
633
634/// The same proof of a copy wherever it is, so a replacement is proved before anything it replaces is touched.
635fn probe_binary(binary: &Path, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
636    // A copy cut short can hold the command and nothing beside it; it starts, and its server finds no workerd.
637    if !binary.with_file_name("superfluid-workerd").is_file() {
638        return Err(format!("{} has no superfluid-workerd beside it", binary.display()));
639    }
640    let said = version_of(binary)?;
641    if said != v {
642        return Err(format!("{} says it is {said}, not {v}", binary.display()));
643    }
644    let out = output_within(Command::new(binary).args(["runtimes", "--json"]).env("SUPERFLUID_HOME", home).env(NO_CHECK_ENV, "1"), PROBE_TIMEOUT)?;
645    if !out.status.success() {
646        return Err(format!("{} runtimes --json failed ({}): {}", binary.display(), out.status, String::from_utf8_lossy(&out.stderr).trim()));
647    }
648    if *engine != Engine::None {
649        let rows: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("{} runtimes --json: {e}", binary.display()))?;
650        let basert = rows.as_array().and_then(|a| a.iter().find(|r| r["id"] == "basert"));
651        match basert.map(|r| &r["ready"]) {
652            Some(Value::Bool(true)) => {}
653            Some(Value::String(why)) => return Err(format!("{v} cannot drive the engine here: {why}")),
654            _ => return Err(format!("{v} reports no basert runtime, which this machine serves with")),
655        }
656    }
657    Ok(())
658}
659
660fn move_tree(from: &Path, to: &Path) -> Result<(), String> {
661    if std::fs::rename(from, to).is_ok() {
662        return Ok(());
663    }
664    copy_tree(from, to)?;
665    std::fs::remove_dir_all(from).map_err(|e| format!("{}: {e}", from.display()))
666}
667
668fn copy_tree(from: &Path, to: &Path) -> Result<(), String> {
669    std::fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
670    for e in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))?.flatten() {
671        let (src, dest) = (e.path(), to.join(e.file_name()));
672        let kind = e.file_type().map_err(|e| format!("{}: {e}", src.display()))?;
673        if kind.is_dir() {
674            copy_tree(&src, &dest)?;
675        } else if kind.is_symlink() {
676            let target = std::fs::read_link(&src).map_err(|e| format!("{}: {e}", src.display()))?;
677            std::os::unix::fs::symlink(target, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
678        } else {
679            std::fs::copy(&src, &dest).map_err(|e| format!("{} -> {}: {e}", src.display(), dest.display()))?;
680        }
681    }
682    Ok(())
683}
684
685/// Download, check and unpack `release` into the layout; nothing is placed until the whole tree is verified, and what
686/// was downloaded goes whether or not it was placed.
687fn place(versions: &Versions, release: &Release, report: &mut Report) -> Result<(), String> {
688    let staging = versions.dir.join(".staging").join(&release.version);
689    let _ = std::fs::remove_dir_all(&staging);
690    let placed = place_through(versions, release, &staging, report);
691    let _ = std::fs::remove_dir_all(&staging);
692    placed
693}
694
695fn place_through(versions: &Versions, release: &Release, staging: &Path, report: &mut Report) -> Result<(), String> {
696    let v = &release.version;
697    std::fs::create_dir_all(staging).map_err(|e| format!("{}: {e}", staging.display()))?;
698    let want = get_text(&release.sha256_url, 30)?.split_whitespace().next().unwrap_or_default().to_ascii_lowercase();
699    if want.len() != 64 || !want.bytes().all(|b| b.is_ascii_hexdigit()) {
700        return Err(format!("{}: not a SHA-256", release.sha256_url));
701    }
702    let asset = Asset { sha256: Some(want), ..release.tarball.clone() };
703    report.say(format!("downloading {} ({})", asset.name, asset.size.map(|s| format!("{:.1} MB", s as f64 / 1e6)).unwrap_or_else(|| "size unknown".into())));
704    let (archive, _) = fetch(&asset, staging)?;
705    let unpacked = staging.join("unpacked");
706    unpack(&archive, &unpacked)?;
707    let top = single_top(&unpacked)?;
708    for needed in ["bin/superfluid", "bin/superfluid-workerd"] {
709        if !top.join(needed).is_file() {
710            return Err(format!("{} holds no {needed}", asset.name));
711        }
712    }
713    let dest = versions.version_dir(v);
714    let _ = std::fs::remove_dir_all(&dest);
715    // A move cut short across filesystems leaves part of a tree, which is not a version.
716    move_tree(&top, &dest).inspect_err(|_| {
717        let _ = std::fs::remove_dir_all(&dest);
718    })
719}
720
721/// The install script's in-place copy, kept under the layout as the version to go back to. It is copied beside the
722/// layout and moved in whole, so a copy cut short (a full disk, an interrupt) is never taken for the version and its
723/// original workers are not removed for it; a version missing a file of the copy is copied again.
724fn keep_in_place(versions: &Versions, prefix: &Path) -> Result<String, String> {
725    let bin = prefix.join("bin");
726    let libexec = prefix.join("libexec").join("superfluid");
727    let old = version_of(&bin.join("superfluid"))?;
728    let dest = versions.version_dir(&old);
729    if holds_in_place(&dest, &bin, &libexec) {
730        return Ok(old);
731    }
732    let staging = versions.dir.join(".staging").join(format!("kept-{old}"));
733    let _ = std::fs::remove_dir_all(&staging);
734    let copied = (|| {
735        std::fs::create_dir_all(staging.join("bin")).map_err(|e| format!("{}: {e}", staging.display()))?;
736        for name in LINKED {
737            let src = bin.join(name);
738            if src.is_file() {
739                std::fs::copy(&src, staging.join("bin").join(name)).map_err(|e| format!("{}: {e}", src.display()))?;
740            }
741        }
742        if libexec.is_dir() {
743            copy_tree(&libexec, &staging.join("libexec").join("superfluid"))?;
744        }
745        let _ = std::fs::remove_dir_all(&dest);
746        std::fs::rename(&staging, &dest).map_err(|e| format!("{} -> {}: {e}", staging.display(), dest.display()))
747    })();
748    if copied.is_err() {
749        let _ = std::fs::remove_dir_all(&staging);
750    }
751    copied.map(|()| old)
752}
753
754/// Whether `dest` holds every command and worker of the in-place copy in `bin` and `libexec`.
755fn holds_in_place(dest: &Path, bin: &Path, libexec: &Path) -> bool {
756    let commands = LINKED.iter().filter(|name| bin.join(name).is_file()).all(|name| dest.join("bin").join(name).is_file());
757    let workers = std::fs::read_dir(libexec)
758        .map(|rd| rd.flatten().all(|e| dest.join("libexec").join("superfluid").join(e.file_name()).exists()))
759        .unwrap_or(true);
760    dest.join("bin").join("superfluid").is_file() && commands && workers
761}
762
763/// The install script's in-place copies become links into the layout (see `Versions::link` for a running server).
764fn migrate(versions: &Versions, prefix: &Path, report: &mut Report) -> Result<(), String> {
765    for note in versions.link(&prefix.join("bin"), true)? {
766        report.say(note);
767    }
768    report.say(format!("the copy the install script placed in {}/bin is kept under {} as the version to go back to", prefix.display(), versions.dir.display()));
769    Ok(())
770}
771
772/// `from` takes the place of `dest`, set aside until `from` is in place; a failed move leaves nothing at `dest` and
773/// brings the copy back, and if that fails too both failures are reported.
774fn replace_tree(from: &Path, dest: &Path, aside: &Path, mover: impl Fn(&Path, &Path) -> Result<(), String>) -> Result<(), String> {
775    let _ = std::fs::remove_dir_all(aside);
776    let had = dest.exists();
777    if had {
778        std::fs::rename(dest, aside).map_err(|e| format!("{}: {e}", dest.display()))?;
779    }
780    match mover(from, dest) {
781        Ok(()) => {
782            let _ = std::fs::remove_dir_all(aside);
783            Ok(())
784        }
785        Err(e) => {
786            let _ = std::fs::remove_dir_all(dest);
787            if had {
788                if let Err(back) = std::fs::rename(aside, dest) {
789                    return Err(format!("{e}; and {} could not be put back from {}: {back}", dest.display(), aside.display()));
790                }
791            }
792            Err(e)
793        }
794    }
795}
796
797/// The older versions removed, once the new one is current: one that cannot be removed is said, not an error, since
798/// the update itself is done; the next prune tries it again.
799fn prune_older(versions: &Versions, report: &mut Report) {
800    match versions.prune() {
801        Ok(gone) if !gone.is_empty() => report.say(format!("removed {}", gone.join(", "))),
802        Ok(_) => {}
803        Err(e) => report.say(format!("an older version was not removed ({e}); the next update tries again")),
804    }
805}
806
807/// `self update`: the newest release that fits, or the one named.
808pub fn update(home: &Path, catalog: &Catalog, exe: &Path, opts: &Options) -> Result<Report, String> {
809    let mut report = Report::default();
810    let installed = installed_as(exe);
811    let (versions, migrating) = match &installed {
812        Installed::Managed { versions, .. } => (Versions { dir: versions.clone() }, None),
813        Installed::InPlace { prefix } => (Versions::of(home), Some(prefix.clone())),
814        Installed::Other(p) => return Err(not_ours(p)),
815    };
816    let target = target().ok_or_else(|| format!("no release is published for {} {}", std::env::consts::OS, std::env::consts::ARCH))?;
817    // Taken before anything is read or decided, so an update queued behind another sees the layout it left.
818    let _one_at_a_time = match opts.dry_run {
819        true => None,
820        false => {
821            std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
822            Some(crate::pulls::Lock::take(&versions.dir, "self", "update of superfluid")?)
823        }
824    };
825    let engine = engine_here(catalog);
826    let compat = compat_fetcher(15);
827    let found = fetch_releases(opts.source.as_deref().unwrap_or(&listing_source()), target, 30)?;
828    let chosen = match &opts.version {
829        Some(want) => {
830            let want = want.trim_start_matches('v');
831            let r = found.iter().find(|r| r.version == want).ok_or_else(|| {
832                format!("no release {want} for {target} (the newest is {})", found.first().map(|r| r.version.as_str()).unwrap_or("none"))
833            })?;
834            if !fits(r, &engine, &compat) {
835                return Err(format!("{want} does not declare support for the engine here ({}); `superfluid runtimes --json` shows what this build requires", engine.describe()));
836            }
837            r
838        }
839        None => newest(&found, &engine, None, &compat).ok_or_else(|| {
840            format!("no release fits: {}, and the newest release for {target} is {}", engine.describe(), found.first().map(|r| r.version.as_str()).unwrap_or("none"))
841        })?,
842    };
843    let v = chosen.version.clone();
844    let current = versions.current();
845    if migrating.is_none() && current.as_deref() == Some(v.as_str()) {
846        report.say(format!("superfluid {v} is current, and the newest release that fits ({})", engine.describe()));
847        return Ok(report);
848    }
849    // Without a version named an update only moves forward: a build newer than every release stays.
850    let here = current.clone().unwrap_or_else(|| running().to_string());
851    if opts.version.is_none() && migrating.is_none() && release_order(&v) < release_order(&here) {
852        report.say(format!("superfluid {here} is current; the newest release that fits is {v} ({}), and `--version {v}` goes to it", engine.describe()));
853        return Ok(report);
854    }
855    let on_disk = versions.binary(&v).is_file();
856    report.say(format!(
857        "{} -> {v} (published {}; {}){}",
858        current.as_deref().unwrap_or(running()),
859        chosen.published,
860        engine.describe(),
861        if on_disk { ", already installed here" } else { "" }
862    ));
863    if opts.dry_run {
864        report.say("dry run: nothing fetched or changed");
865        return Ok(report);
866    }
867    // The in-place copy goes under the layout first, as the version to go back to.
868    let kept = match &migrating {
869        Some(prefix) => Some(keep_in_place(&versions, prefix)?),
870        None => None,
871    };
872    let on_disk = on_disk || versions.binary(&v).is_file();
873    if !on_disk {
874        place(&versions, chosen, &mut report)?;
875    }
876    if let Err(e) = probe(&versions, &v, home, &engine) {
877        if !on_disk {
878            let _ = std::fs::remove_dir_all(versions.version_dir(&v));
879        }
880        return Err(format!("{v} was not made current: {e}"));
881    }
882    versions.set_current(&v)?;
883    if let Some(old) = kept.filter(|old| *old != v) {
884        std::fs::write(versions.dir.join("previous"), format!("{old}\n")).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
885    }
886    report.changed = true;
887    report.say(format!("superfluid {v} is current ({})", versions.dir.join("current").display()));
888    if let Some(prev) = versions.previous() {
889        report.say(format!("{prev} stays installed: `superfluid self use {prev}` goes back to it"));
890    }
891    // Pruned before the links are made, so a removed version's `superfluid-<v>` goes with it.
892    prune_older(&versions, &mut report);
893    if let Some(prefix) = &migrating {
894        migrate(&versions, prefix, &mut report)?;
895    } else if let Some(prefix) = versions.prefix() {
896        for note in versions.link(&prefix, false)? {
897            report.say(note);
898        }
899    }
900    report.say("a running server keeps the version it started with; starting it again uses the new one");
901    Ok(report)
902}
903
904/// `self use <v>`.
905pub fn use_version(home: &Path, catalog: &Catalog, exe: &Path, v: &str) -> Result<Report, String> {
906    let versions = match installed_as(exe) {
907        Installed::Managed { versions, .. } => Versions { dir: versions },
908        _ => Versions::of(home),
909    };
910    let v = v.trim_start_matches('v');
911    if !is_version(v) {
912        return Err(format!("{v:?} is not a version (see `superfluid self list`)"));
913    }
914    let mut report = Report::default();
915    std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
916    let _one_at_a_time = crate::pulls::Lock::take(&versions.dir, "self", "switch of superfluid")?;
917    if versions.current().as_deref() == Some(v) {
918        report.say(format!("superfluid {v} is current already"));
919        return Ok(report);
920    }
921    probe(&versions, v, home, &engine_here(catalog))?;
922    versions.set_current(v)?;
923    report.changed = true;
924    report.say(format!("superfluid {v} is current"));
925    if let Some(prefix) = versions.prefix() {
926        for note in versions.link(&prefix, false)? {
927            report.say(note);
928        }
929    }
930    Ok(report)
931}
932
933/// `self install --from <dir> [--prefix <dir>]`: an unpacked release placed
934/// into the layout and made current; the install script's last step.
935pub fn install_from(home: &Path, catalog: &Catalog, from: &Path, prefix: Option<&Path>) -> Result<Report, String> {
936    let versions = Versions::of(home);
937    let mut report = Report::default();
938    std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
939    let _one_at_a_time = crate::pulls::Lock::take(&versions.dir, "self", "install of superfluid")?;
940    let v = version_of(&from.join("bin").join("superfluid"))?;
941    let engine = engine_here(catalog);
942    let dest = versions.version_dir(&v);
943    if dest == from {
944        probe(&versions, &v, home, &engine).map_err(|e| format!("{v} was not made current: {e}"))?;
945    } else if versions.binary(&v).is_file() && probe(&versions, &v, home, &engine).is_ok() {
946        // An installed version is kept as it is; a copy of it that works is not replaced.
947        report.say(format!("superfluid {v} is installed here already and works; the copy given was not used"));
948    } else {
949        // Proved where it is, then set in place of a copy that does not work.
950        probe_binary(&from.join("bin").join("superfluid"), &v, home, &engine).map_err(|e| format!("{v} was not made current: {e}"))?;
951        replace_tree(from, &dest, &versions.dir.join(format!(".replaced-{v}")), move_tree)?;
952    }
953    // The install script's earlier copy under the prefix is kept as the version to go back to.
954    let kept = match prefix.and_then(|p| match installed_as(&p.join("superfluid")) {
955        Installed::InPlace { prefix: root } => Some(root),
956        _ => None,
957    }) {
958        Some(root) => Some(keep_in_place(&versions, &root)?),
959        None => None,
960    };
961    versions.set_current(&v)?;
962    if let Some(old) = kept.filter(|old| *old != v) {
963        std::fs::write(versions.dir.join("previous"), format!("{old}\n")).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
964        report.say(format!("the copy that was in the prefix is kept as {old}: `superfluid self use {old}` goes back to it"));
965    }
966    report.changed = true;
967    report.say(format!("superfluid {v} is current ({})", versions.dir.join("current").display()));
968    prune_older(&versions, &mut report);
969    if let Some(prefix) = prefix.map(Path::to_path_buf).or_else(|| versions.prefix()) {
970        for note in versions.link(&prefix, true)? {
971            report.say(note);
972        }
973        report.say(format!("{} links to it", prefix.join("superfluid").display()));
974    }
975    Ok(report)
976}
977
978/// `self list`.
979pub fn list(home: &Path, exe: &Path) -> String {
980    let (versions, note) = match installed_as(exe) {
981        Installed::Managed { versions, .. } => (Versions { dir: versions }, None),
982        Installed::InPlace { prefix } => (Versions::of(home), Some(format!("this superfluid is the install script's copy in {}/bin; `superfluid self update` moves it under {}", prefix.display(), home.join("versions").display()))),
983        Installed::Other(p) => (Versions::of(home), Some(not_ours(&p))),
984    };
985    let mut out = String::new();
986    let here = versions.on_disk();
987    if here.is_empty() {
988        out.push_str(&format!("no versions in {}\n", versions.dir.display()));
989    } else {
990        out.push_str(&format!("versions in {}:\n", versions.dir.display()));
991        let (current, previous) = (versions.current(), versions.previous());
992        for v in &here {
993            let mut marks = Vec::new();
994            if Some(v) == current.as_ref() {
995                marks.push("current".to_string());
996            }
997            if Some(v) == previous.as_ref() {
998                marks.push(format!("previous (`superfluid self use {v}` goes back)"));
999            }
1000            if versions.version_dir(v).join("keep").exists() {
1001                marks.push("kept (a `keep` file)".into());
1002            }
1003            out.push_str(&format!("  {v}  {}\n", marks.join(", ")));
1004        }
1005    }
1006    out.push_str(&format!("running: {}\n", running()));
1007    if let Some(cache) = Check::read(home) {
1008        out.push_str(&format!("{}\n", cache.describe()));
1009    }
1010    if let Some(n) = note {
1011        out.push_str(&format!("{n}\n"));
1012    }
1013    out
1014}
1015
1016
1017/// `$SUPERFLUID_HOME/update-check.json`: the last read, the newest release that fit, and the version announced.
1018#[derive(Debug, Clone, Default, PartialEq, Eq)]
1019pub struct Check {
1020    pub checked_at: u64,
1021    pub newest: Option<String>,
1022    pub published: Option<String>,
1023    pub notified: Option<String>,
1024}
1025
1026impl Check {
1027    fn path(home: &Path) -> PathBuf {
1028        home.join("update-check.json")
1029    }
1030
1031    pub fn read(home: &Path) -> Option<Check> {
1032        let v: Value = serde_json::from_str(&std::fs::read_to_string(Self::path(home)).ok()?).ok()?;
1033        Some(Check {
1034            checked_at: v["checked_at"].as_u64().unwrap_or(0),
1035            newest: v["newest"].as_str().map(str::to_string),
1036            published: v["published"].as_str().map(str::to_string),
1037            notified: v["notified"].as_str().map(str::to_string),
1038        })
1039    }
1040
1041    fn write(&self, home: &Path) -> Result<(), String> {
1042        std::fs::create_dir_all(home).map_err(|e| format!("{}: {e}", home.display()))?;
1043        let v = json!({"checked_at": self.checked_at, "newest": self.newest, "published": self.published, "notified": self.notified});
1044        std::fs::write(Self::path(home), format!("{v:#}\n")).map_err(|e| format!("{}: {e}", Self::path(home).display()))
1045    }
1046
1047    pub fn due(&self) -> bool {
1048        now_secs().saturating_sub(self.checked_at) >= CHECK_EVERY.as_secs()
1049    }
1050
1051    /// The newest release known, when it is newer than this one.
1052    pub fn newer(&self) -> Option<&str> {
1053        self.newest.as_deref().filter(|n| release_order(n) > release_order(running()))
1054    }
1055
1056    pub fn describe(&self) -> String {
1057        let ago = now_secs().saturating_sub(self.checked_at);
1058        let when = if ago < 3600 { format!("{}m ago", ago / 60) } else if ago < 86_400 { format!("{}h ago", ago / 3600) } else { format!("{}d ago", ago / 86_400) };
1059        match self.newer() {
1060            Some(n) => format!("newest release: {n} (published {}; checked {when}): `superfluid self update` installs it", self.published.as_deref().unwrap_or("?")),
1061            None => format!("newest release that fits: {} (checked {when})", self.newest.as_deref().unwrap_or("none known")),
1062        }
1063    }
1064}
1065
1066/// Read the listing now and record the newest release that fits here and is past the hold-off.
1067pub fn check_now(home: &Path, catalog: &Catalog, hold_off: Option<i64>, source: &str) -> Result<Check, String> {
1068    let target = target().ok_or("no release is published for this platform")?;
1069    let found = fetch_releases(source, target, 5)?;
1070    let engine = engine_here(catalog);
1071    let compat = compat_fetcher(5);
1072    let fit = newest(&found, &engine, hold_off, &compat);
1073    let previous = Check::read(home).unwrap_or_default();
1074    let check = Check {
1075        checked_at: now_secs(),
1076        newest: fit.map(|r| r.version.clone()),
1077        published: fit.map(|r| r.published.clone()),
1078        notified: previous.notified,
1079    };
1080    check.write(home)?;
1081    Ok(check)
1082}
1083
1084/// The one line `serve` prints when a newer release is out, once per release; `command` installs it.
1085fn announce(home: &Path, mut check: Check, command: &str) -> Option<String> {
1086    let newer = check.newer()?.to_string();
1087    if check.notified.as_deref() == Some(newer.as_str()) {
1088        return None;
1089    }
1090    check.notified = Some(newer.clone());
1091    let _ = check.write(home);
1092    Some(format!("superfluid {newer} is available (this is {}): `{command}` installs it; {NO_CHECK_ENV}=1 silences this", running()))
1093}
1094
1095/// What `serve` runs once the server is up: one listing read a day at most, one line per new release.
1096pub fn check_in_background(home: PathBuf, offline: bool) {
1097    if offline || std::env::var_os(NO_CHECK_ENV).is_some_and(|v| !v.is_empty() && v != "0") {
1098        return;
1099    }
1100    std::thread::Builder::new()
1101        .name("update-check".into())
1102        .spawn(move || loop {
1103            if let Some(line) = tick(&home, &Catalog::from_env(), &listing_source()) {
1104                eprintln!("superfluid: {line}");
1105            }
1106            // Hourly, so a server that stays up learns of a later release, and a check another process made is not repeated.
1107            std::thread::sleep(Duration::from_secs(60 * 60));
1108        })
1109        .ok();
1110}
1111
1112/// One round: the cache when not yet due, else the listing; the line to print for a release not yet announced.
1113pub fn tick(home: &Path, catalog: &Catalog, source: &str) -> Option<String> {
1114    let command = update_command(&std::env::current_exe().unwrap_or_default());
1115    match Check::read(home).filter(|c| !c.due()) {
1116        Some(cached) => announce(home, cached, &command),
1117        None => announce(home, check_now(home, catalog, Some(HOLD_OFF_DAYS), source).ok()?, &command),
1118    }
1119}
1120
1121
1122fn usage() -> ! {
1123    eprint!("{HELP}");
1124    std::process::exit(2);
1125}
1126
1127/// `superfluid self <action> ...`; the exit code.
1128pub fn run(args: &[String]) -> i32 {
1129    let home = crate::runtimes::default_home();
1130    let exe = std::env::current_exe().unwrap_or_default();
1131    let catalog = Catalog::from_env();
1132    let args = crate::cli::split_equals(args, |f| Some(matches!(f, "--version" | "--from" | "--prefix")));
1133    let print = |r: Result<Report, String>| -> i32 {
1134        match r {
1135            Ok(report) => {
1136                for line in report.lines {
1137                    println!("superfluid: {line}");
1138                }
1139                0
1140            }
1141            Err(e) => {
1142                eprintln!("superfluid: {e}");
1143                1
1144            }
1145        }
1146    };
1147    match args.first().map(String::as_str) {
1148        Some("update") => {
1149            let mut opts = Options::default();
1150            let mut it = args[1..].iter();
1151            while let Some(a) = it.next() {
1152                match a.as_str() {
1153                    "--version" => opts.version = it.next().cloned().or_else(|| usage()),
1154                    "--dry-run" => opts.dry_run = true,
1155                    _ => usage(),
1156                }
1157            }
1158            print(update(&home, &catalog, &exe, &opts))
1159        }
1160        Some("check") if args.len() == 1 => match check_now(&home, &catalog, Some(HOLD_OFF_DAYS), &listing_source()) {
1161            Ok(check) => {
1162                println!("superfluid: {}", check.describe());
1163                0
1164            }
1165            Err(e) => {
1166                eprintln!("superfluid: {e}");
1167                1
1168            }
1169        },
1170        Some("list") if args.len() == 1 => {
1171            let _ = std::io::stdout().write_all(list(&home, &exe).as_bytes());
1172            0
1173        }
1174        Some("use") if args.len() == 2 => print(use_version(&home, &catalog, &exe, &args[1])),
1175        Some("install") => {
1176            let (mut from, mut prefix) = (None, None);
1177            let mut it = args[1..].iter();
1178            while let Some(a) = it.next() {
1179                match a.as_str() {
1180                    "--from" => from = it.next().map(PathBuf::from).or_else(|| usage()),
1181                    "--prefix" => prefix = it.next().map(PathBuf::from).or_else(|| usage()),
1182                    _ => usage(),
1183                }
1184            }
1185            let Some(from) = from else { usage() };
1186            print(install_from(&home, &catalog, &from, prefix.as_deref()))
1187        }
1188        _ => usage(),
1189    }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195    use std::os::unix::fs::PermissionsExt;
1196    use std::process::Stdio;
1197    use superfluid_adapter_kit::install::sha256_of;
1198
1199    fn scratch(tag: &str) -> PathBuf {
1200        let d = std::env::temp_dir().join(format!("superfluid-self-{tag}-{}", std::process::id()));
1201        let _ = std::fs::remove_dir_all(&d);
1202        std::fs::create_dir_all(&d).unwrap();
1203        d
1204    }
1205
1206    /// A release tree whose `superfluid` answers `--version` and `runtimes --json` as a real one would.
1207    fn release_tree(dir: &Path, v: &str) -> PathBuf {
1208        let bin = dir.join("bin");
1209        std::fs::create_dir_all(&bin).unwrap();
1210        std::fs::create_dir_all(dir.join("libexec").join("superfluid")).unwrap();
1211        let script = format!(
1212            "#!/bin/sh\ncase \"$1\" in\n  --version) echo \"superfluid {v}\";;\n  runtimes) echo '[{{\"id\":\"basert\",\"ready\":true}}]';;\n  *) exit 2;;\nesac\n"
1213        );
1214        for name in LINKED {
1215            let p = bin.join(name);
1216            std::fs::write(&p, &script).unwrap();
1217            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1218        }
1219        std::fs::write(dir.join("libexec").join("superfluid").join("superfluid-worker-toy"), "#!/bin/sh\n").unwrap();
1220        dir.to_path_buf()
1221    }
1222
1223    /// A listing with one release per version, its assets as local files.
1224    fn listing(dir: &Path, versions: &[(&str, &str, Option<u32>)]) -> PathBuf {
1225        let target = target().unwrap();
1226        let mut rs = Vec::new();
1227        for (v, published, abi) in versions {
1228            let name = format!("superfluid-{target}");
1229            let tree = dir.join(format!("tree-{v}")).join(&name);
1230            release_tree(&tree, v);
1231            let tar = dir.join(format!("{name}-{v}.tar.gz"));
1232            let st = Command::new("tar").args(["-C"]).arg(tree.parent().unwrap()).arg("-czf").arg(&tar).arg(&name).status().unwrap();
1233            assert!(st.success());
1234            let sum = sha256_of(&tar).unwrap();
1235            let sha = dir.join(format!("{name}-{v}.tar.gz.sha256"));
1236            std::fs::write(&sha, format!("{sum}  {name}.tar.gz\n")).unwrap();
1237            let compat = dir.join(format!("{name}-{v}.compat.json"));
1238            std::fs::write(&compat, json!({"runtimes": {"basert": {"abi": abi, "unnumbered_minor": abi.map(|_| "0.3")}}}).to_string()).unwrap();
1239            let mut assets = vec![
1240                json!({"name": format!("{name}.tar.gz"), "browser_download_url": tar, "size": std::fs::metadata(&tar).unwrap().len()}),
1241                json!({"name": format!("{name}.tar.gz.sha256"), "browser_download_url": sha}),
1242                json!({"name": format!("{name}.compat.json"), "browser_download_url": compat}),
1243            ];
1244            if let Some(n) = abi {
1245                assets.push(json!({"name": format!("superfluid-basert-abi-{n}.txt"), "browser_download_url": "x"}));
1246            }
1247            rs.push(json!({"tag_name": format!("v{v}"), "draft": false, "prerelease": false, "published_at": format!("{published}T12:00:00Z"), "assets": assets}));
1248        }
1249        rs.push(json!({"tag_name": "v9.9.9", "draft": true, "prerelease": false, "published_at": "2026-01-01T00:00:00Z", "assets": []}));
1250        let p = dir.join("releases.json");
1251        std::fs::write(&p, Value::Array(rs).to_string()).unwrap();
1252        p
1253    }
1254
1255    fn no_compat(_: &str) -> Option<Value> {
1256        None
1257    }
1258
1259    #[test]
1260    fn a_listing_is_read_newest_first_and_a_release_fits_by_the_engine_it_declares() {
1261        let dir = scratch("listing");
1262        let p = listing(&dir, &[("0.1.3", "2026-10-01", Some(1)), ("0.2.0", "2026-10-20", Some(2)), ("0.1.2", "2026-09-01", None)]);
1263        let found = releases(&serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(), target().unwrap());
1264        assert_eq!(found.iter().map(|r| r.version.as_str()).collect::<Vec<_>>(), ["0.2.0", "0.1.3", "0.1.2"], "newest first, the draft left out");
1265        assert_eq!((found[0].abi, found[1].abi, found[2].abi), (Some(2), Some(1), None));
1266        assert_eq!(found[1].published, "2026-10-01");
1267        let odd = json!([{"tag_name": "v", "assets": []}, {"tag_name": "v../..", "assets": []}, {"tag_name": "vnext", "assets": []}]);
1268        assert_eq!(odd.as_array().unwrap().iter().filter_map(|r| release_of(r, "x.tar.gz", target().unwrap())).count(), 0, "a tag that is no version names no directory");
1269        assert!(["0.1.3", "0.2.0-rc.1", "1.0.0+build.5"].iter().all(|v| is_version(v)));
1270        let mut order = ["0.2.0-rc.1", "0.1.9", "0.2.0", "0.2.0-rc.10", "0.2.1-alpha", "0.2.0-beta", "0.2.0-rc.2", "0.2.0-rc", "0.10.0+build.5"];
1271        order.sort_by_key(|v| std::cmp::Reverse(release_order(v)));
1272        assert_eq!(order, ["0.10.0+build.5", "0.2.1-alpha", "0.2.0", "0.2.0-rc.10", "0.2.0-rc.2", "0.2.0-rc.1", "0.2.0-rc", "0.2.0-beta", "0.1.9"]);
1273        assert!(["", ".", "..", "../0.1.3", "0.1.3/..", "v0.1.3"].iter().all(|v| !is_version(v)));
1274        let compat = |url: &str| get_json(url, 1).ok();
1275        assert_eq!(newest(&found, &Engine::None, None, &no_compat).map(|r| r.version.as_str()), Some("0.2.0"));
1276        assert_eq!(newest(&found, &Engine::Abi(1), None, &no_compat).map(|r| r.version.as_str()), Some("0.1.3"), "by the marker alone");
1277        assert_eq!(newest(&found, &Engine::Abi(3), None, &compat), None);
1278        assert_eq!(newest(&found, &Engine::Unnumbered("0.3".into()), None, &compat).map(|r| r.version.as_str()), Some("0.2.0"), "compat.json names the line");
1279        assert_eq!(newest(&found, &Engine::Unnumbered("0.2".into()), None, &compat), None);
1280        assert_eq!(newest(&found, &Engine::None, Some(100_000), &no_compat), None);
1281        assert_eq!(age_days("2000-01-01").map(|d| d > 9000), Some(true));
1282        assert_eq!(days_from_civil(1970, 1, 1), 0);
1283        assert_eq!(days_from_civil(2000, 3, 1), 11017);
1284        let _ = std::fs::remove_dir_all(&dir);
1285    }
1286
1287    #[test]
1288    fn where_an_executable_came_from_decides_who_updates_it() {
1289        let dir = scratch("installed");
1290        let managed = dir.join("home").join("versions").join("0.1.3").join("bin").join("superfluid");
1291        std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
1292        std::fs::write(&managed, "").unwrap();
1293        assert_eq!(installed_as(&managed), Installed::Managed { versions: std::fs::canonicalize(dir.join("home").join("versions")).unwrap(), version: "0.1.3".into() });
1294        let in_place = dir.join("local").join("bin").join("superfluid");
1295        std::fs::create_dir_all(in_place.parent().unwrap()).unwrap();
1296        std::fs::create_dir_all(dir.join("local").join("libexec").join("superfluid")).unwrap();
1297        std::fs::write(&in_place, "").unwrap();
1298        assert_eq!(installed_as(&in_place), Installed::InPlace { prefix: std::fs::canonicalize(dir.join("local")).unwrap() });
1299        let other = dir.join("Cellar").join("superfluid").join("0.1.3").join("bin").join("superfluid");
1300        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
1301        std::fs::write(&other, "").unwrap();
1302        assert!(matches!(installed_as(&other), Installed::Other(_)));
1303        assert!(not_ours(&other).contains("brew upgrade superfluid"));
1304        // A package keeps the release's layout, and its `installed-by` file says whose it is.
1305        let packaged = release_tree(&dir.join("site-packages").join("superfluid_cli").join("release"), "0.1.3").join("bin").join("superfluid");
1306        assert!(matches!(installed_as(&packaged), Installed::InPlace { .. }), "without the file it looks like the install script's copy");
1307        std::fs::write(dir.join("site-packages").join("superfluid_cli").join("release").join(INSTALLED_BY), "pip: pip install --upgrade superfluid-cli\n").unwrap();
1308        assert!(matches!(installed_as(&packaged), Installed::Other(_)));
1309        let packaged = std::fs::canonicalize(&packaged).unwrap();
1310        assert_eq!(not_ours(&packaged), format!("{} was installed by pip; `pip install --upgrade superfluid-cli` updates it", packaged.display()));
1311        assert_eq!(update_command(&packaged), "pip install --upgrade superfluid-cli");
1312        assert_eq!(update_command(&in_place), "superfluid self update");
1313        let home = dir.join("home");
1314        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1315        let refused = update(&home, &catalog, &packaged, &Options::default()).unwrap_err();
1316        assert!(refused.contains("installed by pip"), "{refused}");
1317        assert!(list(&home, &packaged).contains("`pip install --upgrade superfluid-cli` updates it"));
1318        assert!(running_from(std::env::current_exe().unwrap().parent().unwrap()) || true, "other processes may or may not run from here");
1319        assert!(!running_from(&dir.join("nowhere")));
1320        let _ = std::fs::remove_dir_all(&dir);
1321    }
1322
1323    #[test]
1324    fn an_update_places_the_release_beside_the_old_one_flips_current_and_keeps_one_back() {
1325        let dir = scratch("update");
1326        let home = dir.join("home");
1327        let prefix = dir.join("local");
1328        let p = listing(&dir, &[("0.1.3", "2026-10-01", Some(1)), ("0.1.4", "2026-10-10", Some(1)), ("0.1.5", "2026-10-12", Some(1))]);
1329        let from = |version: Option<&str>, dry_run: bool| Options { version: version.map(str::to_string), dry_run, source: Some(p.to_string_lossy().to_string()) };
1330        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1331        let unpacked = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1332        let r = install_from(&home, &catalog, &unpacked, Some(&prefix.join("bin"))).unwrap();
1333        assert!(r.changed, "{:?}", r.lines);
1334        let versions = Versions::of(&home);
1335        assert_eq!(versions.current().as_deref(), Some("0.1.3"));
1336        assert_eq!(std::fs::read_link(prefix.join("bin").join("superfluid")).unwrap(), versions.dir.join("current").join("bin").join("superfluid"));
1337        assert!(prefix.join("bin").join("superfluid-0.1.3").exists() && prefix.join("bin").join("superfluid-workerd").exists());
1338        assert!(!unpacked.exists(), "moved, not copied");
1339        let exe = versions.version_dir("0.1.3").join("bin").join("superfluid");
1340        let r = update(&home, &catalog, &exe, &from(None, true)).unwrap();
1341        assert!(r.lines[0].starts_with("0.1.3 -> 0.1.5 (published 2026-10-12;") && !r.changed, "{:?}", r.lines);
1342        assert_eq!(versions.on_disk(), ["0.1.3"]);
1343        let r = update(&home, &catalog, &exe, &from(None, false)).unwrap();
1344        assert!(r.changed, "{:?}", r.lines);
1345        assert_eq!(versions.current().as_deref(), Some("0.1.5"));
1346        assert_eq!(versions.previous().as_deref(), Some("0.1.3"));
1347        assert_eq!(versions.on_disk(), ["0.1.5", "0.1.3"]);
1348        assert!(r.lines.iter().any(|l| l == "0.1.3 stays installed: `superfluid self use 0.1.3` goes back to it"), "{:?}", r.lines);
1349        assert!(prefix.join("bin").join("superfluid-0.1.5").exists());
1350        assert!(!versions.dir.join(".staging").join("0.1.5").exists());
1351        let r = update(&home, &catalog, &versions.version_dir("0.1.5").join("bin").join("superfluid"), &from(None, false)).unwrap();
1352        assert!(!r.changed && r.lines[0].starts_with("superfluid 0.1.5 is current"), "{:?}", r.lines);
1353        release_tree(&versions.version_dir("0.9.0"), "0.9.0");
1354        versions.set_current("0.9.0").unwrap();
1355        let r = update(&home, &catalog, &exe, &from(None, false)).unwrap();
1356        assert!(!r.changed && r.lines[0].starts_with("superfluid 0.9.0 is current; the newest release that fits is 0.1.5"), "{:?}", r.lines);
1357        versions.set_current("0.1.5").unwrap();
1358        std::fs::remove_dir_all(versions.version_dir("0.9.0")).unwrap();
1359        std::fs::write(versions.dir.join("previous"), "0.1.3\n").unwrap();
1360        let r = update(&home, &catalog, &exe, &from(Some("v0.1.3"), false)).unwrap();
1361        assert!(r.changed && versions.current().as_deref() == Some("0.1.3") && versions.previous().as_deref() == Some("0.1.5"), "{:?}", r.lines);
1362        let r = update(&home, &catalog, &exe, &from(Some("0.1.4"), false)).unwrap();
1363        assert_eq!((versions.current().as_deref(), versions.previous().as_deref()), (Some("0.1.4"), Some("0.1.3")));
1364        assert_eq!(versions.on_disk(), ["0.1.4", "0.1.3"], "0.1.5 was neither current nor previous: {:?}", r.lines);
1365        assert!(prefix.join("bin").join("superfluid-0.1.5").symlink_metadata().is_err(), "its link went with it, not left dangling");
1366        std::fs::write(versions.version_dir("0.1.3").join("keep"), "").unwrap();
1367        let r = use_version(&home, &catalog, &exe, "0.1.3").unwrap();
1368        assert!(r.changed && versions.current().as_deref() == Some("0.1.3"));
1369        assert!(use_version(&home, &catalog, &exe, "..").unwrap_err().contains("is not a version"));
1370        assert_eq!(list(&home, &exe).lines().filter(|l| l.contains("current") || l.contains("previous")).count(), 2);
1371        let unreachable = Options { source: Some("/nonexistent/releases.json".into()), ..Options::default() };
1372        assert!(update(&home, &catalog, &exe, &unreachable).unwrap_err().contains("releases.json"), "the listing is needed for an update");
1373        let _ = std::fs::remove_dir_all(&dir);
1374    }
1375
1376    #[test]
1377    fn an_installed_version_is_kept_and_only_a_proved_copy_replaces_a_broken_one() {
1378        let dir = scratch("reinstall");
1379        let home = dir.join("home");
1380        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1381        let versions = Versions::of(&home);
1382        let r = install_from(&home, &catalog, &release_tree(&dir.join("first").join("superfluid-x"), "0.1.3"), None).unwrap();
1383        assert!(r.changed);
1384        std::fs::write(versions.version_dir("0.1.3").join("mine"), "").unwrap();
1385        let r = install_from(&home, &catalog, &release_tree(&dir.join("second").join("superfluid-x"), "0.1.3"), None).unwrap();
1386        assert!(r.lines.iter().any(|l| l.contains("installed here already and works")), "{:?}", r.lines);
1387        assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1388        let bad = release_tree(&dir.join("bad").join("superfluid-x"), "0.1.3");
1389        std::fs::write(bad.join("bin").join("superfluid"), "#!/bin/sh\nexit 1\n").unwrap();
1390        let e = install_from(&home, &catalog, &bad, None).unwrap_err();
1391        assert!(e.contains("did not say its version"), "refused before anything is touched: {e}");
1392        assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1393        let wrong = release_tree(&dir.join("wrong").join("superfluid-x"), "0.1.3");
1394        std::fs::write(wrong.join("bin").join("superfluid"), "#!/bin/sh\n[ \"$1\" = --version ] && echo 'superfluid 0.1.3' && exit 0\nexit 1\n").unwrap();
1395        let r = install_from(&home, &catalog, &wrong, None).unwrap();
1396        assert!(r.lines.iter().any(|l| l.contains("the copy given was not used")), "{:?}", r.lines);
1397        assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1398        std::fs::write(versions.binary("0.1.3"), "#!/bin/sh\nexit 1\n").unwrap();
1399        let e = install_from(&home, &catalog, &wrong, None).unwrap_err();
1400        assert!(e.contains("was not made current") && e.contains("runtimes --json failed"), "{e}");
1401        assert!(versions.version_dir("0.1.3").join("mine").exists(), "a copy that fails the probe replaces nothing");
1402        let good = release_tree(&dir.join("good").join("superfluid-x"), "0.1.3");
1403        let r = install_from(&home, &catalog, &good, None).unwrap();
1404        assert!(r.changed && !versions.version_dir("0.1.3").join("mine").exists() && version_of(&versions.binary("0.1.3")).is_ok(), "{:?}", r.lines);
1405        assert!(!versions.dir.join(".replaced-0.1.3").exists());
1406        let _ = std::fs::remove_dir_all(&dir);
1407    }
1408
1409    const SLEEP_ENV: &str = "SUPERFLUID_SELF_TEST_SLEEP";
1410
1411    /// This test binary copied under a prefix and told to sleep: a server running from the install script's layout.
1412    #[test]
1413    fn sleeps_when_asked_to() {
1414        if std::env::var_os(SLEEP_ENV).is_some() {
1415            std::thread::sleep(Duration::from_secs(60));
1416        }
1417    }
1418
1419    #[test]
1420    fn a_replacement_that_fails_leaves_the_copy_set_aside_back_in_place() {
1421        let dir = scratch("replace");
1422        let (from, dest, aside) = (dir.join("from"), dir.join("dest"), dir.join(".replaced"));
1423        std::fs::create_dir_all(&from).unwrap();
1424        std::fs::create_dir_all(&dest).unwrap();
1425        std::fs::write(dest.join("mine"), "").unwrap();
1426        let half = |from: &Path, dest: &Path| -> Result<(), String> {
1427            std::fs::create_dir_all(dest).unwrap();
1428            std::fs::write(dest.join("partial"), "").unwrap();
1429            Err(format!("{} -> {}: no space", from.display(), dest.display()))
1430        };
1431        let e = replace_tree(&from, &dest, &aside, half).unwrap_err();
1432        assert!(e.contains("no space") && !e.contains("could not be put back"), "{e}");
1433        assert!(dest.join("mine").exists() && !dest.join("partial").exists(), "the copy set aside is back, whole");
1434        assert!(!aside.exists());
1435        let whole = |from: &Path, dest: &Path| -> Result<(), String> { copy_tree(from, dest) };
1436        std::fs::write(from.join("new"), "").unwrap();
1437        replace_tree(&from, &dest, &aside, whole).unwrap();
1438        assert!(dest.join("new").exists() && !dest.join("mine").exists() && !aside.exists());
1439        let _ = std::fs::remove_dir_all(&dir);
1440    }
1441
1442    /// Killed and waited for on every way out of a test.
1443    struct Sleeper(std::process::Child);
1444
1445    impl Drop for Sleeper {
1446        fn drop(&mut self) {
1447            let _ = self.0.kill();
1448            let _ = self.0.wait();
1449        }
1450    }
1451
1452    /// This test binary copied to `exe` and run from there, asleep: a server running from the install script's layout.
1453    /// A copy of this test binary rather than a system one: macOS kills a platform binary run from elsewhere.
1454    fn sleeper_at(exe: &Path) -> Sleeper {
1455        std::fs::copy(std::env::current_exe().unwrap(), exe).unwrap();
1456        Sleeper(
1457            Command::new(exe)
1458                .args(["selfupdate::tests::sleeps_when_asked_to", "--exact", "--quiet"])
1459                .env(SLEEP_ENV, "1")
1460                .stdout(Stdio::null())
1461                .stderr(Stdio::null())
1462                .spawn()
1463                .unwrap(),
1464        )
1465    }
1466
1467    /// Whether a process is seen running from `dir` within five seconds.
1468    fn seen_running_from(dir: &Path) -> bool {
1469        (0..50).any(|_| {
1470            std::thread::sleep(Duration::from_millis(100));
1471            running_from(dir)
1472        })
1473    }
1474
1475    #[test]
1476    fn a_server_still_running_from_the_install_scripts_copy_keeps_its_workerd_until_it_stops() {
1477        let dir = scratch("legacy-running");
1478        let (home, prefix) = (dir.join("home"), dir.join("local"));
1479        let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1480        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1481        release_tree(&prefix, "0.1.2");
1482        let bin = prefix.join("bin");
1483        let mut sleeper = sleeper_at(&bin.join("superfluid-workerd"));
1484        if !seen_running_from(&bin) {
1485            eprintln!("SKIP: this platform does not report the sleeper's executable");
1486            return;
1487        }
1488        let exe = bin.join("superfluid");
1489        let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1490        let r = update(&home, &catalog, &exe, &opts).unwrap();
1491        let versions = Versions::of(&home);
1492        assert_eq!(versions.current().as_deref(), Some("0.1.3"), "{:?}", r.lines);
1493        assert!(std::fs::symlink_metadata(&exe).unwrap().file_type().is_symlink(), "the command moved");
1494        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file(), "the running server's workerd stayed a file: {:?}", r.lines);
1495        assert!(bin.join("superfluid-noded").symlink_metadata().unwrap().file_type().is_file());
1496        assert!(prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "and its workers");
1497        assert!(versions.dir.join("migrating").exists());
1498        assert!(r.lines.iter().any(|l| l.contains("still runs from the copy")), "{:?}", r.lines);
1499        assert_eq!(versions.previous().as_deref(), Some("0.1.2"));
1500        versions.link(&bin, false).unwrap();
1501        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file());
1502        drop(std::mem::replace(&mut sleeper, Sleeper(Command::new("true").spawn().unwrap())));
1503        let notes = versions.link(&bin, false).unwrap();
1504        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_symlink(), "{notes:?}");
1505        assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1506        assert!(!versions.dir.join("migrating").exists());
1507        assert!(notes.iter().any(|n| n.contains("now links into")), "{notes:?}");
1508        let _ = std::fs::remove_dir_all(&dir);
1509    }
1510
1511    #[test]
1512    fn a_server_whose_own_command_became_a_link_still_holds_the_install_scripts_copy() {
1513        let dir = scratch("legacy-server");
1514        let (home, prefix) = (dir.join("home"), dir.join("local"));
1515        let versions = Versions::of(&home);
1516        release_tree(&versions.version_dir("0.1.3"), "0.1.3");
1517        versions.set_current("0.1.3").unwrap();
1518        release_tree(&prefix, "0.1.2");
1519        let bin = prefix.join("bin");
1520        let toy = prefix.join("libexec").join("superfluid").join("superfluid-worker-toy");
1521        let mut sleeper = sleeper_at(&bin.join("superfluid"));
1522        if !seen_running_from(&bin) {
1523            eprintln!("SKIP: this platform does not report the sleeper's executable");
1524            return;
1525        }
1526        let notes = versions.link(&bin, true).unwrap();
1527        assert!(bin.join("superfluid").symlink_metadata().unwrap().file_type().is_symlink(), "the command moved: {notes:?}");
1528        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file() && toy.exists(), "{notes:?}");
1529        // macOS gives the path of an executable a link was renamed over only while that name still leads somewhere: with
1530        // the link dangling it gives none, and the server is known by the note alone.
1531        std::fs::remove_file(versions.binary("0.1.3")).unwrap();
1532        let notes = versions.link(&bin, false).unwrap();
1533        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file(), "the server's workerd stayed a file: {notes:?}");
1534        assert!(toy.exists() && versions.dir.join("migrating").exists(), "and its workers: {notes:?}");
1535        assert!(notes.iter().any(|n| n.contains("still runs from the copy")), "{notes:?}");
1536        drop(std::mem::replace(&mut sleeper, Sleeper(Command::new("true").spawn().unwrap())));
1537        // A process that took a recorded id later is not the one recorded.
1538        let note = std::fs::read_to_string(versions.dir.join("migrating")).unwrap();
1539        std::fs::write(versions.dir.join("migrating"), format!("{note}{} 1\n", std::process::id())).unwrap();
1540        let notes = versions.link(&bin, false).unwrap();
1541        assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_symlink(), "{notes:?}");
1542        assert!(!toy.exists() && !versions.dir.join("migrating").exists(), "{notes:?}");
1543        let _ = std::fs::remove_dir_all(&dir);
1544    }
1545
1546    #[test]
1547    fn the_install_script_over_an_earlier_copy_keeps_that_copy_to_go_back_to() {
1548        let dir = scratch("script-over-legacy");
1549        let (home, prefix) = (dir.join("home"), dir.join("local"));
1550        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1551        release_tree(&prefix, "0.1.2");
1552        let tree = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1553        let r = install_from(&home, &catalog, &tree, Some(&prefix.join("bin"))).unwrap();
1554        let versions = Versions::of(&home);
1555        assert_eq!((versions.current().as_deref(), versions.previous().as_deref()), (Some("0.1.3"), Some("0.1.2")), "{:?}", r.lines);
1556        assert_eq!(version_of(&versions.binary("0.1.2")).unwrap(), "0.1.2");
1557        for name in LINKED {
1558            assert!(prefix.join("bin").join(name).symlink_metadata().unwrap().file_type().is_symlink(), "{name}");
1559        }
1560        assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1561        assert!(r.lines.iter().any(|l| l.contains("kept as 0.1.2")), "{:?}", r.lines);
1562        let _ = std::fs::remove_dir_all(&dir);
1563    }
1564
1565    #[test]
1566    fn a_half_finished_copy_of_the_install_scripts_copy_is_copied_again_not_taken_for_it() {
1567        let dir = scratch("half-kept");
1568        let (home, prefix) = (dir.join("home"), dir.join("local"));
1569        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1570        let versions = Versions::of(&home);
1571        release_tree(&prefix, "0.1.2");
1572        // What a copy cut short leaves: the command and nothing beside it.
1573        std::fs::create_dir_all(versions.version_dir("0.1.2").join("bin")).unwrap();
1574        std::fs::copy(prefix.join("bin").join("superfluid"), versions.binary("0.1.2")).unwrap();
1575        let e = use_version(&home, &catalog, &versions.binary("0.1.2"), "0.1.2").unwrap_err();
1576        assert!(e.contains("no superfluid-workerd"), "{e}");
1577        let tree = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1578        let r = install_from(&home, &catalog, &tree, Some(&prefix.join("bin"))).unwrap();
1579        assert_eq!(versions.previous().as_deref(), Some("0.1.2"), "{:?}", r.lines);
1580        assert!(versions.version_dir("0.1.2").join("bin").join("superfluid-workerd").is_file(), "{:?}", r.lines);
1581        assert!(versions.version_dir("0.1.2").join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "the workers came along before the originals went");
1582        assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1583        assert!(!versions.dir.join(".staging").join("kept-0.1.2").exists());
1584        assert!(use_version(&home, &catalog, &versions.binary("0.1.3"), "0.1.2").unwrap().changed);
1585        let _ = std::fs::remove_dir_all(&dir);
1586    }
1587
1588    #[test]
1589    fn a_download_that_does_not_check_out_leaves_nothing_behind() {
1590        let dir = scratch("bad-download");
1591        let home = dir.join("home");
1592        let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1593        std::fs::write(dir.join(format!("superfluid-{}-0.1.3.tar.gz.sha256", target().unwrap())), format!("{}  x\n", "0".repeat(64))).unwrap();
1594        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1595        let versions = Versions::of(&home);
1596        release_tree(&versions.version_dir("0.1.2"), "0.1.2");
1597        versions.set_current("0.1.2").unwrap();
1598        let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1599        let e = update(&home, &catalog, &versions.binary("0.1.2"), &opts).unwrap_err();
1600        assert!(!versions.dir.join(".staging").join("0.1.3").exists(), "{e}");
1601        assert!(!versions.version_dir("0.1.3").exists() && versions.current().as_deref() == Some("0.1.2"), "{e}");
1602        let _ = std::fs::remove_dir_all(&dir);
1603    }
1604
1605    #[test]
1606    fn an_older_version_that_cannot_be_removed_does_not_fail_the_install() {
1607        let dir = scratch("prune-fails");
1608        let home = dir.join("home");
1609        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1610        let versions = Versions::of(&home);
1611        release_tree(&versions.version_dir("0.1.0"), "0.1.0");
1612        release_tree(&versions.version_dir("0.1.1"), "0.1.1");
1613        versions.set_current("0.1.1").unwrap();
1614        let stuck = versions.version_dir("0.1.0").join("bin");
1615        std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)).unwrap();
1616        let r = install_from(&home, &catalog, &release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.2"), None);
1617        std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)).unwrap();
1618        let r = r.unwrap();
1619        assert!(r.changed && versions.current().as_deref() == Some("0.1.2"), "{:?}", r.lines);
1620        assert!(r.lines.iter().any(|l| l.starts_with("an older version was not removed (")), "{:?}", r.lines);
1621        let _ = std::fs::remove_dir_all(&dir);
1622    }
1623
1624    #[test]
1625    fn a_switch_waits_for_an_update_that_holds_the_lock() {
1626        let dir = scratch("lock");
1627        let home = dir.join("home");
1628        let versions = Versions::of(&home);
1629        release_tree(&versions.version_dir("0.1.3"), "0.1.3");
1630        release_tree(&versions.version_dir("0.1.4"), "0.1.4");
1631        versions.set_current("0.1.4").unwrap();
1632        let held = crate::pulls::Lock::take(&versions.dir, "self", "update of superfluid").unwrap();
1633        let exe = versions.binary("0.1.4");
1634        let (home2, exe2) = (home.clone(), exe.clone());
1635        let switching = std::thread::spawn(move || {
1636            let catalog = Catalog::new(home2.clone(), None, home2.join("superfluid-workerd"));
1637            use_version(&home2, &catalog, &exe2, "0.1.3").map(|r| r.changed)
1638        });
1639        std::thread::sleep(Duration::from_millis(300));
1640        assert!(!switching.is_finished(), "the switch does not run while the lock is held");
1641        assert_eq!(versions.current().as_deref(), Some("0.1.4"));
1642        drop(held);
1643        assert_eq!(switching.join().unwrap(), Ok(true));
1644        assert_eq!(versions.current().as_deref(), Some("0.1.3"));
1645        let _ = std::fs::remove_dir_all(&dir);
1646    }
1647
1648    #[test]
1649    fn the_check_goes_on_from_the_cache_and_asks_again_when_due() {
1650        let dir = scratch("tick");
1651        let home = dir.join("home");
1652        std::fs::create_dir_all(&home).unwrap();
1653        let p = listing(&dir, &[("99.0.0", "2026-01-01", None)]);
1654        let source = p.to_string_lossy().to_string();
1655        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1656        let first = tick(&home, &catalog, &source).unwrap();
1657        assert!(first.starts_with("superfluid 99.0.0 is available"), "{first}");
1658        assert_eq!(tick(&home, &catalog, "/nonexistent/releases.json"), None, "not due: the cache answers, nothing is read, and the release was announced already");
1659        let stale = Check { checked_at: 1, ..Check::read(&home).unwrap() };
1660        stale.write(&home).unwrap();
1661        assert_eq!(tick(&home, &catalog, "/nonexistent/releases.json"), None, "due, but the listing is unreachable: nothing, no panic");
1662        assert_eq!(Check::read(&home).unwrap().checked_at, 1, "an unreachable listing leaves the cache as it was");
1663        let p = listing(&dir.join("later"), &[("99.0.0", "2026-01-01", None), ("99.5.0", "2026-01-02", None)]);
1664        let later = tick(&home, &catalog, &p.to_string_lossy()).unwrap();
1665        assert!(later.starts_with("superfluid 99.5.0 is available"), "a release published since is announced on the next due check: {later}");
1666        let _ = std::fs::remove_dir_all(&dir);
1667    }
1668
1669    #[test]
1670    fn a_release_that_does_not_start_or_drive_the_engine_is_not_made_current() {
1671        let dir = scratch("probe");
1672        let home = dir.join("home");
1673        let versions = Versions::of(&home);
1674        let tree = release_tree(&versions.version_dir("0.5.0"), "0.5.0");
1675        std::fs::write(
1676            tree.join("bin").join("superfluid"),
1677            "#!/bin/sh\nif [ \"$1\" = runtimes ]; then echo '[{\"id\":\"basert\",\"ready\":\"libbaseRT states ABI 2\"}]'; else echo 'superfluid 0.5.0'; fi\n",
1678        )
1679        .unwrap();
1680        assert!(probe(&versions, "0.5.0", &home, &Engine::None).is_ok(), "no engine in use: starting is enough");
1681        let e = probe(&versions, "0.5.0", &home, &Engine::Abi(1)).unwrap_err();
1682        assert!(e.contains("cannot drive the engine here: libbaseRT states ABI 2"), "{e}");
1683        std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\necho 'superfluid ..'\n").unwrap();
1684        assert!(probe(&versions, "0.5.0", &home, &Engine::None).unwrap_err().contains("did not say its version"));
1685        std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\necho 'superfluid 0.4.9'\n").unwrap();
1686        assert!(probe(&versions, "0.5.0", &home, &Engine::None).unwrap_err().contains("says it is 0.4.9"));
1687        // A copy that hangs is killed, so the lock it runs under is let go.
1688        std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\nexec sleep 30\n").unwrap();
1689        let began = std::time::Instant::now();
1690        let e = output_within(Command::new(versions.binary("0.5.0")).arg("--version"), Duration::from_secs(1)).unwrap_err();
1691        assert!(e.contains("did not answer within 1s") && began.elapsed() < Duration::from_secs(10), "{e}");
1692        let _ = std::fs::remove_dir_all(&dir);
1693    }
1694
1695    #[test]
1696    fn the_in_place_install_is_migrated_into_the_layout() {
1697        let dir = scratch("migrate");
1698        let (home, prefix) = (dir.join("home"), dir.join("local"));
1699        let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1700        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1701        release_tree(&prefix, "0.1.2");
1702        let exe = prefix.join("bin").join("superfluid");
1703        assert!(matches!(installed_as(&exe), Installed::InPlace { .. }));
1704        let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1705        let r = update(&home, &catalog, &exe, &opts).unwrap();
1706        let versions = Versions::of(&home);
1707        assert_eq!(versions.current().as_deref(), Some("0.1.3"), "{:?}", r.lines);
1708        assert!(std::fs::symlink_metadata(&exe).unwrap().file_type().is_symlink(), "the copy became a link");
1709        assert_eq!(std::fs::read_link(prefix.join("bin").join("superfluid-noded")).unwrap(), versions.dir.join("current").join("bin").join("superfluid-noded"));
1710        assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "the old workers are gone");
1711        assert_eq!(versions.previous().as_deref(), Some("0.1.2"), "{:?}", r.lines);
1712        assert_eq!(version_of(&versions.binary("0.1.2")).unwrap(), "0.1.2");
1713        assert!(versions.version_dir("0.1.2").join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "with its workers");
1714        assert_eq!(versions.on_disk(), ["0.1.3", "0.1.2"]);
1715        assert_eq!(versions.prefix().as_deref(), Some(std::fs::canonicalize(prefix.join("bin")).unwrap().as_path()));
1716        assert!(r.lines.iter().any(|l| l.contains("now links into")), "{:?}", r.lines);
1717        let _ = std::fs::remove_dir_all(&dir);
1718    }
1719
1720    #[test]
1721    fn the_daily_check_records_the_newest_release_and_announces_it_once() {
1722        let dir = scratch("check");
1723        let home = dir.join("home");
1724        std::fs::create_dir_all(&home).unwrap();
1725        let p = listing(&dir, &[("99.0.0", "2026-01-01", None), ("99.1.0", "2099-01-01", None)]);
1726        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1727        let c = check_now(&home, &catalog, Some(HOLD_OFF_DAYS), &p.to_string_lossy()).unwrap();
1728        assert_eq!(c.newest.as_deref(), Some("99.0.0"), "99.1.0 is inside the hold-off");
1729        assert!(!c.due());
1730        assert_eq!(Check::read(&home), Some(c.clone()));
1731        let line = announce(&home, c.clone(), "superfluid self update").unwrap();
1732        assert!(line.starts_with("superfluid 99.0.0 is available (this is ") && line.contains("`superfluid self update`"), "{line}");
1733        assert_eq!(announce(&home, Check::read(&home).unwrap(), "superfluid self update"), None, "announced once");
1734        let again = Check { notified: None, ..c.clone() };
1735        assert!(announce(&home, again, "brew upgrade superfluid").unwrap().contains("`brew upgrade superfluid` installs it"));
1736        assert!(Check::read(&home).unwrap().describe().starts_with("newest release: 99.0.0 (published 2026-01-01;"));
1737        let stale = Check { checked_at: 1, ..Check::default() };
1738        assert!(stale.due());
1739        let _ = std::fs::remove_dir_all(&dir);
1740    }
1741}