Serve a machine's models from one shared llama-server

A llama.cpp session had its own `llama-server`: two sessions on one model
held two copies of it in memory, a model change bought a load only that
session benefited from, and the process was a session's to end. A machine's
models are now served by one `llama-server` in **router mode** -- no `-m`,
a preset file naming models and their flags, a child server per model asked
for, and each request routed by its `model` field. So one server per model
with that model's own settings is what a machine runs, while this backend
has one process, one port and one record per machine to keep track of.

The record is the mechanism every other driver already uses, so a restart
adopts it; a session records the same pid in its own directory as
`Detail::Shared`, and `process::signal` refuses to signal one of those --
which is what keeps stopping, deleting or cleaning up after one session
from unloading a model every other session is using. Nothing stops a router
on its own. That is deliberate (a loaded model is minutes of disk) and it is
why the machines tab now has a card per provider that opens its own screen:
how each model is loaded, how many stay in memory, Unload, and Stop.

How a model is *loaded* therefore belongs to the model on its machine rather
than to a session -- context size, GPU layers, threads, slots, speculative
decoding -- written into the preset as llama-server's own argument names.
Saving them re-reads that file, which unloads the model; that is the change
taking effect, and the dialog says so before you save. What stays a
session's is everything that rides on a request, including which tools it
offers: the router hosts one set for the machine and the choice is a filter
applied here, so it costs no reload (2,181 tokens of prompt with all seven,
698 with none).

Verified end to end against the scratch backend and the emulator: two
sessions sharing one loaded model with one child process, a second session
joining it with a 26ms prefill, a backend restart adopting the router and
answering with the prompt cache intact, the same over ssh to this VM, a
model's settings reaching the running server, Unload, and Stop leaving every
session `exited` with no error line.
This commit is contained in:
iris-ai committed 2026-09-19 17:37:31 -04:00
1 parent 74cda485e5
commit 8c323fc7a9
19 files changed
+2601 -511

No files matched your search

+65 -9
View File
@@ -56,6 +56,16 @@ pub enum Detail {
/// Spoken to over HTTP on a loopback port, which is all it takes to find
/// it again -- there is no stream to be partway through.
Http { port: u16 },
/// The same, for a process this session reaches but does not own: the
/// llama.cpp router serving every session on its machine.
///
/// A variant rather than a flag because of what it forbids. Liveness is
/// the identical question -- a session whose router has gone has no model
/// -- but ending it is not this session's to ask, and [`signal`] is where
/// that is enforced: stopping, deleting or cleaning up after a session
/// must not take a model out of memory for every other session on that
/// machine.
Shared { port: u16 },
}
/// Whether a recorded process is still there.
@@ -82,6 +92,15 @@ impl Record {
})
}
/// Whether this server may end that process.
///
/// False for the one it shares -- see [`Detail::Shared`]. Liveness is the
/// identical question for both, which is why this is separate from it:
/// "is it there?" and "is it mine to end?" are asked in different places.
pub fn ours(&self) -> bool {
!matches!(self.detail, Detail::Shared { .. })
}
pub fn liveness(&self) -> Liveness {
match stat_of(self.pid) {
// A different start time is a reused pid, so definitely not ours.
@@ -256,10 +275,10 @@ pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
/// period because a session the phone has deleted must not still be running.
pub fn stop(record: &Record, grace: std::time::Duration) {
if record.liveness() != Liveness::Alive {
if !record.ours() || record.liveness() != Liveness::Alive {
return;
}
signal(record.pid, libc::SIGTERM);
signal(record, libc::SIGTERM);
let record = record.clone();
tokio::spawn(async move {
tokio::time::sleep(grace).await;
@@ -285,6 +304,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
let deadline = std::time::Instant::now() + grace;
for record in records {
// Never asked to stop, so there is nothing to wait out.
if !record.ours() {
continue;
}
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
std::thread::sleep(LOOK);
}
@@ -303,17 +326,25 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
record.pid,
grace
);
signal(record.pid, libc::SIGKILL);
signal(record, libc::SIGKILL);
}
}
fn signal(pid: u32, signal: libc::c_int) {
/// The one place a session's process is signalled, which is why the refusal to
/// signal a shared one lives here rather than at each caller: every path out of
/// a session -- stopped, deleted, cleaned up on the way down -- ends in this
/// function, and the one that forgot would be a model unloaded under somebody
/// else's turn.
fn signal(record: &Record, signal: libc::c_int) {
if !record.ours() {
return;
}
// SAFETY: `kill` with a positive pid touches only that process, and the pid
// came from a record whose start time was just confirmed to match -- so it
// is still the process this server started, not a reused number. A failure
// (already gone) is nothing to act on.
unsafe {
libc::kill(pid as libc::pid_t, signal);
libc::kill(record.pid as libc::pid_t, signal);
}
}
@@ -424,10 +455,12 @@ mod tests {
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
// And the other shape round trips through the same file.
record.detail = Detail::Http { port: 8080 };
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record));
// And the other shapes round trip through the same file.
for detail in [Detail::Http { port: 8080 }, Detail::Shared { port: 8080 }] {
record.detail = detail;
write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone()));
}
mark_stopping(dir.path()).expect("mark stopping");
assert!(stopping(dir.path()));
@@ -463,6 +496,29 @@ mod tests {
assert!(stray.is_empty(), "left behind {stray:?}");
}
/// The whole of what [`Detail::Shared`] is for: a session ending must not
/// take the machine's llama.cpp router with it.
#[tokio::test]
async fn a_shared_process_is_not_stopped_with_the_session_that_reached_it() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let shared = Record::of(child.id(), Detail::Shared { port: 1 }).expect("start time");
stop(&shared, std::time::Duration::from_millis(50));
std::thread::sleep(std::time::Duration::from_millis(200));
assert_eq!(shared.liveness(), Liveness::Alive, "the router was killed");
// The same process, recorded as one this session owns, does stop.
let owned = Record {
detail: Detail::Http { port: 1 },
..shared
};
stop(&owned, std::time::Duration::from_millis(50));
let _ = child.wait();
assert_eq!(owned.liveness(), Liveness::Dead);
}
#[test]
fn a_dead_or_unreadable_record_is_not_live() {
let dir = tempfile::tempdir().expect("tempdir");