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