Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74110b4d72
commit
127b25e60a
20 files changed
+1212
-143
No files matched your search
+115
-6
@@ -15,6 +15,23 @@ use std::process::Command;
|
||||
|
||||
use crate::config::SshConfig;
|
||||
|
||||
/// A port on the machine a command runs on, and the port that reaches it
|
||||
/// from the backend.
|
||||
///
|
||||
/// The second half of what a transport is (PLAN.md's SSH section): "run
|
||||
/// this" plus "reach this port". Locally the two numbers are the same one
|
||||
/// and nothing is forwarded; over ssh the connection carries an `-L`
|
||||
/// tunnel, so a model server binds loopback on the far machine and is
|
||||
/// never exposed to its network.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Forward {
|
||||
/// What the launched program should listen on, on its own machine.
|
||||
pub there: u16,
|
||||
/// What this machine connects to. The same number as `there` when the
|
||||
/// program runs here.
|
||||
pub here: u16,
|
||||
}
|
||||
|
||||
/// Options forced onto every connection. `BatchMode` makes a missing key
|
||||
/// fail immediately with a readable message instead of hanging on a
|
||||
/// password prompt that nothing can answer; the keepalives turn a silently
|
||||
@@ -45,6 +62,7 @@ pub fn command(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: Option<&Path>,
|
||||
forward: Option<Forward>,
|
||||
) -> Command {
|
||||
let Some(ssh) = remote else {
|
||||
let mut command = Command::new(program);
|
||||
@@ -64,9 +82,42 @@ pub fn command(
|
||||
};
|
||||
|
||||
let mut command = Command::new("ssh");
|
||||
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
|
||||
// CRLF translation, ^C handling) into something the parser can't read.
|
||||
command.arg("-T");
|
||||
if let Some(forward) = forward {
|
||||
// A forwarded process is not spoken to over stdio, and that
|
||||
// changes how it has to be shut down. Everything else here is a
|
||||
// CLI reading its stdin, so killing the ssh client closes that
|
||||
// stdin and the far process ends; a `llama-server` never reads
|
||||
// its own, so the same kill left it running on the far machine
|
||||
// holding the model in memory -- measured 2026-09-04, an orphan
|
||||
// per stopped session. A pty is what makes sshd hang the far side
|
||||
// up: when the connection goes, the master closes and the session
|
||||
// takes SIGHUP. `-tt` because this client has no terminal of its
|
||||
// own to inherit one from.
|
||||
//
|
||||
// The cost is that its log arrives through a line discipline
|
||||
// (CRLF, and whatever the program does when it thinks it is on a
|
||||
// terminal). Nothing parses that log, so it is a fair trade for a
|
||||
// process that reliably goes away.
|
||||
command.arg("-tt");
|
||||
// Loopback on both ends: the far side binds 127.0.0.1, so the
|
||||
// port it serves is reachable only through this connection and
|
||||
// never from that machine's network -- and the near end is bound
|
||||
// to this host alone for the same reason.
|
||||
command.args([
|
||||
"-L",
|
||||
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
|
||||
]);
|
||||
// Without this a forward that cannot be set up is a warning on
|
||||
// stderr and a session that runs anyway, answering nothing: the
|
||||
// failure would arrive as "the model never became ready", which
|
||||
// is the wrong thing to go looking at.
|
||||
command.args(["-o", "ExitOnForwardFailure=yes"]);
|
||||
} else {
|
||||
// -T: no pty. This carries JSONL, and a pty would rewrite it
|
||||
// (echo, CRLF translation, ^C handling) into something the parser
|
||||
// can't read.
|
||||
command.arg("-T");
|
||||
}
|
||||
for option in SSH_OPTIONS {
|
||||
command.args(["-o", option]);
|
||||
}
|
||||
@@ -200,6 +251,7 @@ mod tests {
|
||||
port: None,
|
||||
identity_file: None,
|
||||
options: vec![],
|
||||
models_dir: None,
|
||||
attachments_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -211,6 +263,7 @@ mod tests {
|
||||
"claude",
|
||||
&args(["-p", "--verbose"]),
|
||||
Some(Path::new("/tmp/x")),
|
||||
None,
|
||||
);
|
||||
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x")));
|
||||
@@ -223,6 +276,7 @@ mod tests {
|
||||
port: Some(2222),
|
||||
identity_file: Some("/home/me/.ssh/id_ai".into()),
|
||||
options: vec!["StrictHostKeyChecking=accept-new".to_string()],
|
||||
models_dir: None,
|
||||
attachments_dir: None,
|
||||
};
|
||||
let rendered = argv(&command(
|
||||
@@ -230,6 +284,7 @@ mod tests {
|
||||
"claude",
|
||||
&args(["-p", "--model", "haiku"]),
|
||||
Some(Path::new("/home/bob/work")),
|
||||
None,
|
||||
));
|
||||
|
||||
assert_eq!(rendered[0], "ssh");
|
||||
@@ -250,12 +305,60 @@ mod tests {
|
||||
#[test]
|
||||
fn a_remote_command_without_a_cwd_just_execs() {
|
||||
let ssh = bare_host();
|
||||
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None));
|
||||
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
|
||||
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
|
||||
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
|
||||
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
||||
}
|
||||
|
||||
/// The second half of a transport: the connection that runs the
|
||||
/// command also carries the port that reaches it.
|
||||
///
|
||||
/// Both ends are pinned to loopback, which is the property that keeps
|
||||
/// a model server off the far machine's network -- asserted here
|
||||
/// rather than trusted, because dropping the addresses is a one-word
|
||||
/// edit that still works on a machine nobody else can reach.
|
||||
#[test]
|
||||
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
|
||||
let ssh = bare_host();
|
||||
let rendered = argv(&command(
|
||||
Some(&ssh),
|
||||
"llama-server",
|
||||
&args(["--port", "24242"]),
|
||||
None,
|
||||
Some(Forward {
|
||||
there: 24242,
|
||||
here: 41000,
|
||||
}),
|
||||
));
|
||||
let forward = rendered
|
||||
.iter()
|
||||
.position(|arg| arg == "-L")
|
||||
.expect("a forward");
|
||||
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
|
||||
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
|
||||
// The half that is easy to lose: without a pty the far process
|
||||
// outlives the connection, because nothing closes a stdin it
|
||||
// never reads.
|
||||
assert!(rendered.contains(&"-tt".to_string()));
|
||||
assert!(!rendered.contains(&"-T".to_string()));
|
||||
// Options come before the host, or ssh reads them as part of the
|
||||
// remote command.
|
||||
assert!(forward < rendered.len() - 2);
|
||||
assert_eq!(
|
||||
rendered.last().unwrap(),
|
||||
"exec 'llama-server' '--port' '24242'"
|
||||
);
|
||||
|
||||
// Nothing forwarded is nothing added: every other session is one
|
||||
// of these, and an -L on it would bind a port for no reason.
|
||||
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
|
||||
assert!(!plain.contains(&"-L".to_string()));
|
||||
// And a session that *is* spoken to over stdio keeps its raw pipe.
|
||||
assert!(plain.contains(&"-T".to_string()));
|
||||
assert!(!plain.contains(&"-tt".to_string()));
|
||||
}
|
||||
|
||||
/// The one character quoting must not swallow.
|
||||
///
|
||||
/// A working directory typed as `~/repos/ai-app` was arriving as the
|
||||
@@ -300,7 +403,13 @@ mod tests {
|
||||
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
||||
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
||||
|
||||
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work")));
|
||||
let local = command(
|
||||
None,
|
||||
"claude",
|
||||
&args(["-p"]),
|
||||
Some(Path::new("~/work")),
|
||||
None,
|
||||
);
|
||||
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
||||
}
|
||||
|
||||
@@ -324,7 +433,7 @@ mod tests {
|
||||
// that tries to close the quote and start a new command.
|
||||
let ssh = bare_host();
|
||||
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
||||
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
||||
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));
|
||||
let script = rendered.last().unwrap();
|
||||
assert_eq!(
|
||||
script,
|
||||
|
||||
Reference in new issue
Block a user