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"];
24const 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
52pub 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
67fn 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
75enum PreRelease {
76 Number(u64),
77 Word(String),
78}
79
80fn 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
95pub 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum Installed {
283 Managed { versions: PathBuf, version: String },
285 InPlace { prefix: PathBuf },
287 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 pub published: String,
327 pub tarball: Asset,
328 pub sha256_url: String,
329 pub abi: Option<u32>,
331 pub compat_url: Option<String>,
332}
333
334pub 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#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum Engine {
370 None,
372 Abi(u32),
373 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
401pub 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
418pub 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
423pub fn listing_source() -> String {
425 std::env::var(LISTING_ENV).ok().filter(|s| !s.is_empty()).unwrap_or_else(|| LISTING.to_string())
426}
427
428fn 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#[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#[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 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 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 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 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#[cfg(target_os = "macos")]
535fn started_at(pid: i32) -> Option<u64> {
536 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 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
554pub fn running_from(dir: &Path) -> bool {
556 !processes_from(dir).is_empty()
557}
558
559fn 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 pub source: Option<String>,
572}
573
574#[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
587fn 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
605fn probe(versions: &Versions, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
607 probe_binary(&versions.binary(v), v, home, engine)
608}
609
610fn probe_binary(binary: &Path, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
612 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
661fn 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 move_tree(&top, &dest).inspect_err(|_| {
693 let _ = std::fs::remove_dir_all(&dest);
694 })
695}
696
697fn 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
730fn 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
739fn 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
748fn 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
773fn 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
783pub 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 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 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 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 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
880pub 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
909pub 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 report.say(format!("superfluid {v} is installed here already and works; the copy given was not used"));
924 } else {
925 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 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
954pub 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#[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 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
1042pub 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
1060fn 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
1071pub 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 std::thread::sleep(Duration::from_secs(60 * 60));
1084 })
1085 .ok();
1086}
1087
1088pub 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
1102pub 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 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 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 #[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 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 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 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 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 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 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 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}