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