Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -1,23 +1,3 @@
|
||||
//! GGUF models on this machine, and the downloads that produce them.
|
||||
//!
|
||||
//! The registry pattern again: one owner, one lock, so what is on disk and what
|
||||
//! this server believes cannot come apart. Three things shape the design, all
|
||||
//! consequences of a model file being gigabytes rather than kilobytes:
|
||||
//!
|
||||
//! **A download belongs to the model, not to whoever asked for it.** It is
|
||||
//! keyed by the model it produces and lives here, so any device can watch it --
|
||||
//! including one that did not start it. State in a per-connection channel would
|
||||
//! not survive the phone locking its screen, which for an hour-long download is
|
||||
//! the normal case.
|
||||
//!
|
||||
//! **Every run has an id, and its outcome outlives it.** Without those, "not
|
||||
//! downloading" is three answers at once -- it finished, it never started, or a
|
||||
//! different run finished while you were away.
|
||||
//!
|
||||
//! **Progress is measured, never estimated.** `total` is whatever
|
||||
//! `Content-Length` said and nothing else; when the server does not send one it
|
||||
//! stays `None` and the phone shows that it does not know.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -31,21 +11,13 @@ use wg_app_link::private;
|
||||
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
/// Identifies this client to HuggingFace. They ask for one, and a request
|
||||
/// without it is more likely to be rate-limited.
|
||||
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Read size per loop iteration. Big enough that the syscall overhead is
|
||||
/// nothing against a multi-gigabyte file, small enough that a cancel is noticed
|
||||
/// promptly -- the flag is only checked between chunks.
|
||||
const CHUNK: usize = 256 * 1024;
|
||||
|
||||
/// A model file sitting on this machine, ready to run.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalModel {
|
||||
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already
|
||||
/// unique, so nothing has to invent an id.
|
||||
pub key: String,
|
||||
pub repo: String,
|
||||
pub file: String,
|
||||
@@ -69,13 +41,10 @@ pub enum DownloadState {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// One download run, as the phone sees it.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadStatus {
|
||||
pub key: String,
|
||||
/// Distinguishes this run from any earlier one for the same model, so a
|
||||
/// device that was watching run 3 can tell it is now looking at run 4.
|
||||
pub run: u64,
|
||||
pub repo: String,
|
||||
pub file: String,
|
||||
@@ -92,7 +61,6 @@ pub struct DownloadStatus {
|
||||
pub finished: Option<f64>,
|
||||
}
|
||||
|
||||
/// The mutable half of a run, behind one lock.
|
||||
#[derive(Debug)]
|
||||
struct Progress {
|
||||
state: DownloadState,
|
||||
@@ -103,15 +71,12 @@ struct Progress {
|
||||
finished: Option<f64>,
|
||||
}
|
||||
|
||||
/// A run, shared between the thread doing the work and everyone watching.
|
||||
struct Run {
|
||||
id: u64,
|
||||
key: String,
|
||||
repo: String,
|
||||
file: String,
|
||||
progress: Mutex<Progress>,
|
||||
/// Set by [`ModelStore::cancel`]; the download loop checks it between
|
||||
/// chunks and stops, leaving the partial file for a later resume.
|
||||
cancel: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -140,11 +105,8 @@ impl Run {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every model this machine has, and every download in flight or finished.
|
||||
pub struct ModelStore {
|
||||
dir: PathBuf,
|
||||
/// Keyed by model key: one run per model at a time, and the last run for a
|
||||
/// model stays here after it ends so its outcome can still be read.
|
||||
runs: Mutex<HashMap<String, Arc<Run>>>,
|
||||
next_run: AtomicU64,
|
||||
}
|
||||
@@ -158,9 +120,6 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a model's file lives, refusing anything that would escape the
|
||||
/// models directory.
|
||||
///
|
||||
/// The repo and file come from a phone, so they are treated as hostile:
|
||||
/// every component must be an ordinary name. Rejecting rather than
|
||||
/// sanitising, since a silently rewritten path would download the right
|
||||
@@ -180,9 +139,6 @@ impl ModelStore {
|
||||
format!("{repo}/{file}")
|
||||
}
|
||||
|
||||
/// Every `.gguf` found under the models directory, newest first. Read from
|
||||
/// disk on each call rather than cached: a file deleted by hand should stop
|
||||
/// being offered.
|
||||
pub fn list(&self) -> Vec<LocalModel> {
|
||||
let mut found = Vec::new();
|
||||
collect(&self.dir, &self.dir, &mut found);
|
||||
@@ -190,7 +146,6 @@ impl ModelStore {
|
||||
found
|
||||
}
|
||||
|
||||
/// The status of every run this server remembers.
|
||||
pub fn downloads(&self) -> Vec<DownloadStatus> {
|
||||
let runs = self.runs.lock().unwrap();
|
||||
let mut all: Vec<_> = runs.values().map(|run| run.status()).collect();
|
||||
@@ -235,9 +190,6 @@ impl ModelStore {
|
||||
let status = run.status();
|
||||
drop(runs);
|
||||
|
||||
// A dedicated thread rather than the blocking pool: this holds its
|
||||
// thread for as long as the download takes, which is minutes to hours,
|
||||
// and the pool exists for short work.
|
||||
let store = Arc::clone(self);
|
||||
std::thread::spawn(move || {
|
||||
let outcome = store.fetch(&run, &target);
|
||||
@@ -260,8 +212,6 @@ impl ModelStore {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Asks a running download to stop. The partial file stays, so starting
|
||||
/// again resumes rather than refetching.
|
||||
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
|
||||
let runs = self.runs.lock().unwrap();
|
||||
let Some(run) = runs.get(key) else {
|
||||
@@ -271,7 +221,6 @@ impl ModelStore {
|
||||
Ok(run.status())
|
||||
}
|
||||
|
||||
/// Removes a downloaded model, and any partial file for it.
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
let (repo, file) = key.rsplit_once('/').context("a key is repo/file")?;
|
||||
let target = self.path_for(repo, file)?;
|
||||
@@ -295,8 +244,6 @@ impl ModelStore {
|
||||
private::create_dir(parent)?;
|
||||
}
|
||||
|
||||
// What we have, and what it was part of. A partial with no recorded
|
||||
// identity is not resumable -- it could be a fragment of any revision.
|
||||
let known = std::fs::read_to_string(&identity)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
@@ -313,12 +260,6 @@ impl ModelStore {
|
||||
let (mut response, mut resumed) = request(&url, have)?;
|
||||
let mut etag = etag_of(&response);
|
||||
|
||||
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
|
||||
// deliberately stale validator still answers 206 with the ranged bytes.
|
||||
// So the header cannot be relied on to restart us, and the check is done
|
||||
// here instead: if what arrived is not the revision our partial belongs
|
||||
// to, resuming would splice two files into something of exactly the
|
||||
// right length and the wrong contents.
|
||||
if resumed && etag.is_some() && etag != known {
|
||||
tracing::info!(
|
||||
"{} changed upstream since the partial was written -- starting again",
|
||||
@@ -330,10 +271,6 @@ impl ModelStore {
|
||||
etag = etag_of(&response);
|
||||
}
|
||||
|
||||
// On a 206, Content-Length is the length of the *range*, not of the file
|
||||
// -- taken at face value it would fill the bar at 72 MB of a 234 MB
|
||||
// model. The whole size is the last field of Content-Range, which has
|
||||
// the further merit of not depending on where the range began.
|
||||
let total: Option<u64> = if resumed {
|
||||
response
|
||||
.headers()
|
||||
@@ -357,10 +294,6 @@ impl ModelStore {
|
||||
p.total = total;
|
||||
}
|
||||
|
||||
// `truncate(false)` is the whole resume story: the file is opened to be
|
||||
// seeked into and appended to, and truncating would throw away exactly
|
||||
// the bytes the Range request just asked the server not to send again.
|
||||
// Stated rather than left to the default.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
@@ -374,8 +307,6 @@ impl ModelStore {
|
||||
file.set_len(0)
|
||||
.context("truncate a partial we cannot resume onto")?;
|
||||
}
|
||||
// Written before the body, so an interrupted download leaves a partial
|
||||
// that can still say which revision it belongs to.
|
||||
if let Some(etag) = &etag {
|
||||
std::fs::write(&identity, etag).ok();
|
||||
}
|
||||
@@ -420,8 +351,6 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Renamed only once complete, so a file at its real name is always a
|
||||
// whole model -- `list` needs no other way to tell.
|
||||
std::fs::rename(&partial, target)
|
||||
.with_context(|| format!("finish {}", target.display()))?;
|
||||
std::fs::remove_file(&identity).ok();
|
||||
@@ -429,8 +358,6 @@ impl ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
|
||||
/// into memory to hash it would be the largest allocation this server makes.
|
||||
fn sha256_of(path: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut file =
|
||||
@@ -444,8 +371,6 @@ fn sha256_of(path: &Path) -> Result<String> {
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
// Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
|
||||
// this sha2 version's output type does not implement LowerHex.
|
||||
Ok(hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
@@ -453,15 +378,12 @@ fn sha256_of(path: &Path) -> Result<String> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One GET, ranged when there is something to resume onto.
|
||||
fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bool)> {
|
||||
let mut get = ureq::get(url).header("User-Agent", USER_AGENT);
|
||||
if from > 0 {
|
||||
get = get.header("Range", &format!("bytes={from}-"));
|
||||
}
|
||||
let response = get.call().with_context(|| format!("GET {url}"))?;
|
||||
// Trust the status, not the request: a server that ignores Range answers
|
||||
// 200 with the whole file, and appending to that would corrupt it.
|
||||
let resumed = response.status() == 206;
|
||||
Ok((response, resumed))
|
||||
}
|
||||
@@ -478,22 +400,18 @@ fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
|
||||
/// is a piece of.
|
||||
fn identity_of(target: &Path) -> PathBuf {
|
||||
let mut name = target.as_os_str().to_os_string();
|
||||
name.push(".part.etag");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// `x.gguf` -> `x.gguf.part`, the in-progress name.
|
||||
fn partial_of(target: &Path) -> PathBuf {
|
||||
let mut name = target.as_os_str().to_os_string();
|
||||
name.push(".part");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Walks `dir` collecting `.gguf` files, keyed by their path under `root`.
|
||||
fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
@@ -523,17 +441,8 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a machine reached over ssh keeps its models, when its setup does
|
||||
/// not say.
|
||||
///
|
||||
/// The same place this backend puts its own downloads, written out rather
|
||||
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
|
||||
/// environment, and the far machine's is the far machine's business. A
|
||||
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
|
||||
|
||||
/// Which directory holds the models on the machine `transport` reaches.
|
||||
///
|
||||
/// One answer, because two things ask: the list a spawn screen offers,
|
||||
/// and the path a session hands `llama-server`. A machine that listed one
|
||||
/// directory and served from another would offer models that then failed
|
||||
@@ -550,9 +459,6 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every GGUF on the machine a setup names, which is the machine that
|
||||
/// would have to serve it.
|
||||
///
|
||||
/// The local half of this is [`ModelStore::list`], reading the same shape
|
||||
/// off this machine's disk; a caller picks by transport, since a setup
|
||||
/// with no ssh *is* this machine and asking a shell about it would be a
|
||||
@@ -584,7 +490,6 @@ pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalMod
|
||||
let mut found: Vec<LocalModel> = out
|
||||
.split('\0')
|
||||
.filter(|record| !record.is_empty())
|
||||
// Two fields, and the name last, so a `\t` in a filename survives.
|
||||
.filter_map(|record| record.split_once('\t'))
|
||||
.filter_map(|(bytes, key)| {
|
||||
let (repo, file) = key.rsplit_once('/')?;
|
||||
@@ -600,33 +505,22 @@ pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalMod
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// A model repository on HuggingFace, as the browse screen shows it.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteRepo {
|
||||
/// `owner/name`, which is what everything else here is keyed by.
|
||||
pub id: String,
|
||||
pub downloads: u64,
|
||||
pub likes: u64,
|
||||
}
|
||||
|
||||
/// One downloadable file inside a repository.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteFile {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
/// Already on this machine, so the phone can say so rather than offering to
|
||||
/// fetch it again.
|
||||
pub have: bool,
|
||||
}
|
||||
|
||||
/// Searches HuggingFace for GGUF repositories matching `query`.
|
||||
///
|
||||
/// Proxied through this server rather than called from the phone, for two
|
||||
/// reasons that both matter: the app trusts exactly one certificate -- this
|
||||
/// server's -- and has no general internet trust to spend on huggingface.co,
|
||||
/// and the machine that has to do the downloading is this one.
|
||||
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
||||
let url = format!(
|
||||
"https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1",
|
||||
@@ -654,9 +548,6 @@ pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The sha256 HuggingFace publishes for one file, if it publishes one. It is
|
||||
/// the LFS object id, which for these repositories is the sha256 of the content
|
||||
/// -- so it is a free integrity check rather than a second source of truth.
|
||||
fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
||||
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
|
||||
let body = get_json(&url).ok()?;
|
||||
@@ -666,8 +557,6 @@ fn published_sha256(repo: &str, file: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The GGUF files in one repository, largest last, with the ones already
|
||||
/// downloaded marked.
|
||||
pub fn files(repo: &str, store: &ModelStore) -> Result<Vec<RemoteFile>> {
|
||||
let url = format!("https://huggingface.co/api/models/{repo}/tree/main");
|
||||
let body = get_json(&url)?;
|
||||
@@ -705,9 +594,6 @@ fn get_json(url: &str) -> Result<serde_json::Value> {
|
||||
serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON"))
|
||||
}
|
||||
|
||||
/// Percent-encodes a query string. Deliberately minimal -- this escapes what a
|
||||
/// model search actually contains rather than implementing the whole rule set,
|
||||
/// and anything unexpected becomes `%XX` rather than being passed through.
|
||||
fn urlencode(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
|
||||
Reference in new issue
Block a user