use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio_util::sync::CancellationToken;
use crate::shared::api::OpenAiClient;
use crate::shared::i18n::Locale;
#[derive(Debug, Clone)]
pub struct ManagedConfig {
pub binary: PathBuf,
pub model_path: Option<String>,
pub mmproj: Option<String>,
pub gpu_layers: i32,
pub context_size: u32,
pub batch_size: Option<u32>,
pub parallel: u32,
pub jinja: bool,
pub reasoning_format: Option<String>,
pub embeddings: bool,
pub no_mmap: bool,
pub flash_attn: Option<String>,
pub spec_type: Option<String>,
pub draft_model: Option<String>,
pub draft_gpu_layers: Option<i32>,
pub draft_n_max: Option<u32>,
pub draft_n_min: Option<u32>,
pub host: String,
pub port: u16,
pub extra_args: Vec<String>,
}
impl ManagedConfig {
pub fn base_url(&self) -> String {
format!("http://127.0.0.1:{}/v1", self.port)
}
pub fn is_runnable(&self) -> bool {
!self.binary.as_os_str().is_empty()
&& self
.model_path
.as_deref()
.is_some_and(|m| !m.trim().is_empty())
}
}
pub const CPU_BATCH: u32 = 256;
pub const SERVER_UBATCH: u32 = 512;
pub const LLAMA_DEFAULT_BATCH: u32 = 2048;
pub const PREFILL_HOLD_LIMIT_SECS: u32 = 5;
pub const PREFILL_SAMPLE_MIN: u32 = 256;
pub fn launched_batch(batch_size: Option<u32>, gpu_layers: i32) -> u32 {
batch_size
.or((gpu_layers == 0).then_some(CPU_BATCH))
.unwrap_or(LLAMA_DEFAULT_BATCH)
}
pub fn prefill_hold(batch: u32, prefill: crate::shared::api::contract::Prefill) -> Option<u32> {
if prefill.tokens < PREFILL_SAMPLE_MIN || batch <= CPU_BATCH {
return None;
}
let tps = prefill.tokens_per_second()?;
let hold = f64::from(batch) / tps;
(hold > f64::from(PREFILL_HOLD_LIMIT_SECS)).then(|| hold.round() as u32)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoMmapSpelling {
LoadMode,
NoMmap,
}
impl NoMmapSpelling {
fn args(self) -> Vec<String> {
match self {
NoMmapSpelling::LoadMode => vec!["--load-mode".into(), "none".into()],
NoMmapSpelling::NoMmap => vec!["--no-mmap".into()],
}
}
}
pub fn spelling_from_help(help: &str) -> NoMmapSpelling {
if help.contains("--load-mode") {
NoMmapSpelling::LoadMode
} else {
NoMmapSpelling::NoMmap
}
}
pub fn no_mmap_spelling(binary: &std::path::Path) -> NoMmapSpelling {
match std::process::Command::new(binary).arg("--help").output() {
Ok(out) => {
let help = String::from_utf8_lossy(&out.stdout).into_owned()
+ &String::from_utf8_lossy(&out.stderr);
let spelling = spelling_from_help(&help);
tracing::debug!(?spelling, "the binary was asked how it spells no-mmap");
spelling
}
Err(err) => {
tracing::debug!(%err, "the binary did not answer --help; assuming the current spelling");
NoMmapSpelling::LoadMode
}
}
}
pub fn build_args(cfg: &ManagedConfig, no_mmap: NoMmapSpelling) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
cfg.host.clone(),
"--port".to_string(),
cfg.port.to_string(),
"-ngl".to_string(),
cfg.gpu_layers.to_string(),
"-c".to_string(),
cfg.context_size.to_string(),
];
if cfg.parallel > 1 {
args.push("-np".into());
args.push(cfg.parallel.to_string());
args.push("--kv-unified".into());
}
if !cfg.embeddings
&& let Some(batch) = cfg
.batch_size
.or((cfg.gpu_layers == 0).then_some(CPU_BATCH))
{
args.push("-b".into());
args.push(batch.to_string());
args.push("-ub".into());
args.push(batch.min(SERVER_UBATCH).to_string());
}
if let Some(m) = &cfg.model_path {
args.push("-m".into());
args.push(m.clone());
}
if let Some(p) = &cfg.mmproj {
args.push("--mmproj".into());
args.push(p.clone());
}
if cfg.jinja {
args.push("--jinja".into());
}
if let Some(rf) = &cfg.reasoning_format {
args.push("--reasoning-format".into());
args.push(rf.clone());
}
if cfg.embeddings {
args.push("--embeddings".into());
args.push("-ub".into());
args.push(cfg.context_size.to_string());
args.push("-b".into());
args.push(cfg.context_size.to_string());
}
if cfg.no_mmap {
args.extend(no_mmap.args());
}
if let Some(fa) = &cfg.flash_attn {
args.push("--flash-attn".into());
args.push(fa.clone());
}
if let Some(st) = &cfg.spec_type {
args.push("--spec-type".into());
args.push(st.clone());
}
if let Some(md) = &cfg.draft_model {
args.push("-md".into());
args.push(md.clone());
}
if let Some(ngld) = cfg.draft_gpu_layers {
args.push("-ngld".into());
args.push(ngld.to_string());
}
if let Some(n) = cfg.draft_n_max {
args.push("--spec-draft-n-max".into());
args.push(n.to_string());
}
if let Some(n) = cfg.draft_n_min {
args.push("--spec-draft-n-min".into());
args.push(n.to_string());
}
args.extend(cfg.extra_args.iter().cloned());
args
}
pub struct ServerHandle {
kill: CancellationToken,
exited: CancellationToken,
base_url: String,
}
impl Drop for ServerHandle {
fn drop(&mut self) {
self.kill.cancel();
}
}
impl ServerHandle {
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn exited(&self) -> CancellationToken {
self.exited.clone()
}
pub fn launch(cfg: &ManagedConfig, loc: &'static Locale) -> Result<Self> {
if let Some(model) = &cfg.model_path {
if !std::path::Path::new(model).is_file() {
bail!(
"{}",
loc.tf("ui.err.managed.model_not_found", &[("path", model)])
);
}
check_split_model(model, loc)?;
}
if let Some(draft) = &cfg.draft_model
&& !std::path::Path::new(draft).is_file()
{
bail!(
"{}",
loc.tf("ui.err.managed.draft_not_found", &[("path", draft)])
);
}
if let Some(mmproj) = &cfg.mmproj
&& !std::path::Path::new(mmproj).is_file()
{
bail!(
"{}",
loc.tf("ui.err.managed.mmproj_not_found", &[("path", mmproj)])
);
}
let no_mmap = if cfg.no_mmap {
no_mmap_spelling(&cfg.binary)
} else {
NoMmapSpelling::LoadMode
};
let args = build_args(cfg, no_mmap);
tracing::info!(binary = %cfg.binary.display(), ?args, "launching managed llama-server");
let mut child = Command::new(&cfg.binary)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.with_context(|| {
loc.tf(
"ui.err.managed.spawn",
&[("path", &cfg.binary.display().to_string())],
)
})?;
if let Some(out) = child.stdout.take() {
tokio::spawn(forward_lines(out, false));
}
if let Some(err) = child.stderr.take() {
tokio::spawn(forward_lines(err, true));
}
let kill = CancellationToken::new();
let exited = CancellationToken::new();
spawn_monitor(child, kill.clone(), exited.clone());
Ok(Self {
kill,
exited,
base_url: cfg.base_url(),
})
}
}
fn check_split_model(model: &str, loc: &'static Locale) -> Result<()> {
let Some(shard) = crate::shared::gguf::parse_shard(model) else {
return Ok(());
};
if shard.index != 1 {
bail!(
"{}",
loc.tf(
"ui.err.managed.shard_not_first",
&[("path", &shard.first())]
)
);
}
for part in shard.all().iter().skip(1) {
if !std::path::Path::new(part).is_file() {
bail!(
"{}",
loc.tf("ui.err.managed.shard_missing", &[("path", part)])
);
}
}
Ok(())
}
fn spawn_monitor(mut child: Child, kill: CancellationToken, exited: CancellationToken) {
tokio::spawn(async move {
tokio::select! {
status = child.wait() => {
match status {
Ok(s) => tracing::warn!(status = ?s, "managed llama-server exited on its own"),
Err(e) => tracing::warn!(error = %e, "error waiting for the child llama-server"),
}
exited.cancel();
}
_ = kill.cancelled() => {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
});
}
pub async fn wait_until_ready(
client: &OpenAiClient,
timeout: Duration,
exited: Option<CancellationToken>,
loc: &'static Locale,
) -> Result<()> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if client.probe().await.is_ok() {
return Ok(());
}
if exited.as_ref().is_some_and(|e| e.is_cancelled()) {
bail!("{}", loc.t("ui.err.managed.early_exit"));
}
if tokio::time::Instant::now() >= deadline {
bail!(
"{}",
loc.tf(
"ui.err.managed.timeout",
&[("timeout", &format!("{timeout:?}"))]
)
);
}
let sleep = tokio::time::sleep(Duration::from_millis(500));
match &exited {
Some(ex) => {
tokio::select! {
_ = sleep => {}
_ = ex.cancelled() => {}
}
}
None => sleep.await,
}
}
}
async fn forward_lines<R>(reader: R, is_err: bool)
where
R: tokio::io::AsyncRead + Unpin,
{
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if is_err {
tracing::warn!(target: "llama-server", "{line}");
} else {
tracing::info!(target: "llama-server", "{line}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
fn base_cfg() -> ManagedConfig {
ManagedConfig {
binary: PathBuf::from("llama-server"),
model_path: None,
mmproj: None,
gpu_layers: 99,
context_size: 8192,
batch_size: None,
parallel: 1,
jinja: true,
reasoning_format: None,
embeddings: false,
no_mmap: false,
flash_attn: None,
spec_type: None,
draft_model: None,
draft_gpu_layers: None,
draft_n_max: None,
draft_n_min: None,
host: "127.0.0.1".into(),
port: 8000,
extra_args: vec![],
}
}
#[test]
fn a_cpu_only_line_gets_the_measured_batch() {
let cfg = ManagedConfig {
gpu_layers: 0,
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let at = |flag: &str| {
args.iter()
.position(|a| a == flag)
.map(|i| args[i + 1].clone())
};
assert_eq!(at("-b").as_deref(), Some("256"));
assert_eq!(at("-ub").as_deref(), Some("256"));
}
#[test]
fn a_gpu_line_is_byte_for_byte_what_it_was() {
let args = build_args(&base_cfg(), NoMmapSpelling::LoadMode);
assert!(!args.iter().any(|a| a == "-b" || a == "-ub"), "{args:?}");
let partial = ManagedConfig {
gpu_layers: 20,
..base_cfg()
};
assert!(
!build_args(&partial, NoMmapSpelling::LoadMode)
.iter()
.any(|a| a == "-b")
);
}
#[test]
fn a_typed_batch_is_passed_as_is_with_the_micro_batch_clamped() {
let at = |cfg: &ManagedConfig, flag: &str| {
let args = build_args(cfg, NoMmapSpelling::LoadMode);
args.iter()
.position(|a| a == flag)
.map(|i| args[i + 1].clone())
};
let big = ManagedConfig {
batch_size: Some(1024),
..base_cfg()
};
assert_eq!(at(&big, "-b").as_deref(), Some("1024"));
assert_eq!(at(&big, "-ub").as_deref(), Some("512"));
let small = ManagedConfig {
batch_size: Some(128),
gpu_layers: 0,
..base_cfg()
};
assert_eq!(at(&small, "-b").as_deref(), Some("128"));
assert_eq!(at(&small, "-ub").as_deref(), Some("128"));
let cpu_default = ManagedConfig {
batch_size: Some(2048),
gpu_layers: 0,
..base_cfg()
};
assert_eq!(at(&cpu_default, "-b").as_deref(), Some("2048"));
assert_eq!(at(&cpu_default, "-ub").as_deref(), Some("512"));
}
#[test]
fn the_embedding_server_keeps_its_own_batch() {
let cfg = ManagedConfig {
embeddings: true,
gpu_layers: 0,
batch_size: Some(128),
context_size: 8192,
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let values: Vec<&String> = args
.iter()
.enumerate()
.filter(|(i, a)| (*a == "-b" || *a == "-ub") && *i + 1 < args.len())
.map(|(i, _)| &args[i + 1])
.collect();
assert_eq!(values, vec!["8192", "8192"], "{args:?}");
}
#[test]
fn sessions_above_one_add_np_with_a_unified_pool() {
let one = build_args(&base_cfg(), NoMmapSpelling::LoadMode);
assert!(
!one.iter().any(|a| a == "-np" || a == "--kv-unified"),
"{one:?}"
);
let cfg = ManagedConfig {
parallel: 3,
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let np = args.iter().position(|a| a == "-np").expect("-np present");
assert_eq!(args[np + 1], "3");
assert!(args.contains(&"--kv-unified".to_string()), "{args:?}");
let c = args.iter().position(|a| a == "-c").unwrap();
assert_eq!(args[c + 1], "8192");
}
#[test]
fn args_include_host_port_ngl_ctx_jinja() {
let args = build_args(&base_cfg(), NoMmapSpelling::LoadMode);
let host = args.iter().position(|a| a == "--host").unwrap();
assert_eq!(args[host + 1], "127.0.0.1");
let p = args.iter().position(|a| a == "--port").unwrap();
assert_eq!(args[p + 1], "8000");
let ngl = args.iter().position(|a| a == "-ngl").unwrap();
assert_eq!(args[ngl + 1], "99");
let c = args.iter().position(|a| a == "-c").unwrap();
assert_eq!(args[c + 1], "8192");
assert!(args.contains(&"--jinja".to_string()));
}
#[test]
fn args_for_model_with_reasoning() {
let cfg = ManagedConfig {
model_path: Some("gemma.gguf".into()),
reasoning_format: Some("auto".into()),
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let m = args.iter().position(|a| a == "-m").unwrap();
assert_eq!(args[m + 1], "gemma.gguf");
let rf = args.iter().position(|a| a == "--reasoning-format").unwrap();
assert_eq!(args[rf + 1], "auto");
assert!(!args.contains(&"--embeddings".to_string()));
}
#[test]
fn embeddings_flag_and_no_jinja() {
let cfg = ManagedConfig {
embeddings: true,
jinja: false,
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
assert!(args.contains(&"--embeddings".to_string()));
assert!(!args.contains(&"--jinja".to_string()));
let ub = args.iter().position(|a| a == "-ub").expect("-ub present");
assert_eq!(args[ub + 1], cfg.context_size.to_string());
let b = args.iter().position(|a| a == "-b").expect("-b present");
assert_eq!(args[b + 1], cfg.context_size.to_string());
}
#[test]
fn no_batch_flags_for_non_embedding_server() {
let args = build_args(&base_cfg(), NoMmapSpelling::LoadMode);
assert!(!args.contains(&"-ub".to_string()));
}
#[test]
fn flash_attn_flag_present_only_when_set() {
assert!(
!build_args(&base_cfg(), NoMmapSpelling::LoadMode)
.contains(&"--flash-attn".to_string())
);
let cfg = ManagedConfig {
flash_attn: Some("on".into()),
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let fa = args.iter().position(|a| a == "--flash-attn").unwrap();
assert_eq!(args[fa + 1], "on");
}
#[test]
fn spec_decoding_args_for_mtp_draft() {
let cfg = ManagedConfig {
spec_type: Some("draft-mtp".into()),
draft_model: Some("mtp.gguf".into()),
draft_gpu_layers: Some(99),
draft_n_max: Some(5),
draft_n_min: Some(1),
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let st = args.iter().position(|a| a == "--spec-type").unwrap();
assert_eq!(args[st + 1], "draft-mtp");
let md = args.iter().position(|a| a == "-md").unwrap();
assert_eq!(args[md + 1], "mtp.gguf");
let ngld = args.iter().position(|a| a == "-ngld").unwrap();
assert_eq!(args[ngld + 1], "99");
let nmax = args.iter().position(|a| a == "--spec-draft-n-max").unwrap();
assert_eq!(args[nmax + 1], "5");
let nmin = args.iter().position(|a| a == "--spec-draft-n-min").unwrap();
assert_eq!(args[nmin + 1], "1");
}
#[test]
fn no_spec_args_by_default() {
let args = build_args(&base_cfg(), NoMmapSpelling::LoadMode);
assert!(!args.contains(&"--spec-type".to_string()));
assert!(!args.contains(&"-md".to_string()));
assert!(!args.contains(&"-ngld".to_string()));
}
#[test]
fn launch_missing_draft_model_file_errors_before_spawn() {
let cfg = ManagedConfig {
model_path: None,
draft_model: Some("definitely/missing/draft-xyz.gguf".into()),
..base_cfg()
};
let err = match ServerHandle::launch(&cfg, ru()) {
Err(e) => e,
Ok(_) => panic!("expected an error about a missing draft model file"),
};
assert!(err.to_string().contains("черновой модели"), "{err}");
}
#[test]
fn mmproj_flag_present_only_when_set() {
assert!(
!build_args(&base_cfg(), NoMmapSpelling::LoadMode).contains(&"--mmproj".to_string())
);
let cfg = ManagedConfig {
model_path: Some("gemma.gguf".into()),
mmproj: Some("mmproj-gemma.gguf".into()),
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
let p = args
.iter()
.position(|a| a == "--mmproj")
.expect("--mmproj present");
assert_eq!(args[p + 1], "mmproj-gemma.gguf");
}
#[test]
fn launch_missing_mmproj_file_errors_before_spawn() {
const MISSING: &str = "definitely/missing/mmproj-xyz.gguf";
let cfg = ManagedConfig {
model_path: None,
mmproj: Some(MISSING.into()),
..base_cfg()
};
let err = match ServerHandle::launch(&cfg, ru()) {
Err(e) => e,
Ok(_) => panic!("expected an error about a missing projector file"),
};
let expected = ru().tf("ui.err.managed.mmproj_not_found", &[("path", MISSING)]);
assert!(err.to_string().contains(&expected), "{err}");
}
#[test]
fn no_mmap_flag_present_only_when_enabled() {
for spelling in [NoMmapSpelling::LoadMode, NoMmapSpelling::NoMmap] {
let off = build_args(&base_cfg(), spelling);
assert!(!off.iter().any(|a| a == "--no-mmap" || a == "--load-mode"));
}
let cfg = ManagedConfig {
no_mmap: true,
..base_cfg()
};
let load_mode = build_args(&cfg, NoMmapSpelling::LoadMode);
let at = load_mode
.iter()
.position(|a| a == "--load-mode")
.expect("the current spelling");
assert_eq!(
load_mode[at + 1],
"none",
"`none` is llama.cpp's own word for no special loading mode"
);
assert!(
!load_mode.iter().any(|a| a == "--no-mmap"),
"a build that takes --load-mode is refused by the old flag, not helped by it"
);
assert!(
build_args(&cfg, NoMmapSpelling::NoMmap).contains(&"--no-mmap".to_string()),
"and a binary from before the rename takes only the old one"
);
}
#[test]
fn the_spelling_is_read_out_of_the_binarys_own_help() {
let current = "-lm, --load-mode MODE model loading mode (default: auto)\n\
- auto: mmap, unless a device does not support it\n\
- none: no special loading mode\n";
assert_eq!(spelling_from_help(current), NoMmapSpelling::LoadMode);
let older = " --no-mmap do not memory-map model (slower load but may reduce pageouts)\n\
--mlock force system to keep model in RAM\n";
assert_eq!(spelling_from_help(older), NoMmapSpelling::NoMmap);
assert_eq!(
spelling_from_help(""),
NoMmapSpelling::NoMmap,
"a help text that mentions neither predates the rename"
);
}
#[test]
fn a_binary_that_cannot_be_run_answers_the_current_spelling() {
assert_eq!(
no_mmap_spelling(std::path::Path::new("definitely/missing/llama-server-xyz")),
NoMmapSpelling::LoadMode
);
}
#[test]
fn base_url_uses_port() {
assert_eq!(base_cfg().base_url(), "http://127.0.0.1:8000/v1");
}
#[test]
fn launch_missing_model_file_errors_before_spawn() {
let cfg = ManagedConfig {
model_path: Some("definitely/missing/model-xyz.gguf".into()),
..base_cfg()
};
let err = match ServerHandle::launch(&cfg, ru()) {
Err(e) => e,
Ok(_) => panic!("expected an error about a missing model file"),
};
assert!(err.to_string().contains("файл модели"), "{err}");
}
fn preflight_error(dir: &std::path::Path, parts: &[&str], model: &str) -> String {
for part in parts {
std::fs::write(dir.join(part), b"gguf").expect("write a part");
}
let cfg = ManagedConfig {
binary: dir.join("no-such-llama-server"),
model_path: Some(dir.join(model).display().to_string()),
..base_cfg()
};
match ServerHandle::launch(&cfg, ru()) {
Err(e) => e.to_string(),
Ok(_) => panic!("launch cannot succeed: the binary does not exist"),
}
}
fn expect_about(dir: &std::path::Path, key: &str, file: &str) -> String {
ru().tf(key, &[("path", &dir.join(file).display().to_string())])
}
fn expect_spawn(dir: &std::path::Path) -> String {
expect_about(dir, "ui.err.managed.spawn", "no-such-llama-server")
}
#[test]
fn launch_missing_split_part_errors_before_spawn() {
let dir = tempfile::tempdir().expect("tempdir");
let (d, first) = (dir.path(), "model-00001-of-00003.gguf");
let err = preflight_error(d, &[first], first);
let missing = expect_about(
d,
"ui.err.managed.shard_missing",
"model-00002-of-00003.gguf",
);
assert!(err.contains(&missing), "{err}");
let all = ["model-00002-of-00003.gguf", "model-00003-of-00003.gguf"];
let err = preflight_error(d, &all, first);
assert!(err.contains(&expect_spawn(d)), "{err}");
}
#[test]
fn launch_from_a_later_split_part_points_at_the_first() {
let dir = tempfile::tempdir().expect("tempdir");
let d = dir.path();
let err = preflight_error(
d,
&["model-00002-of-00003.gguf"],
"model-00002-of-00003.gguf",
);
let first = expect_about(
d,
"ui.err.managed.shard_not_first",
"model-00001-of-00003.gguf",
);
assert!(err.contains(&first), "{err}");
}
#[test]
fn a_single_file_model_is_not_split_checked() {
let dir = tempfile::tempdir().expect("tempdir");
let err = preflight_error(dir.path(), &["gemma-4-it.gguf"], "gemma-4-it.gguf");
assert!(err.contains(&expect_spawn(dir.path())), "{err}");
}
#[test]
fn launch_error_is_localized() {
let cfg = ManagedConfig {
model_path: Some("definitely/missing/model-xyz.gguf".into()),
..base_cfg()
};
let en = match ServerHandle::launch(&cfg, locale(Lang::En)) {
Err(e) => e.to_string(),
Ok(_) => panic!("expected an error about a missing model file"),
};
assert!(en.contains("model file not found"), "{en}");
assert!(!en.chars().any(|c| ('а'..='я').contains(&c)), "{en}");
}
#[tokio::test]
async fn wait_until_ready_bails_on_early_exit() {
let client = OpenAiClient::new("http://127.0.0.1:1/v1");
let exited = CancellationToken::new();
exited.cancel();
let err = wait_until_ready(&client, Duration::from_secs(600), Some(exited), ru())
.await
.expect_err("expected an early-exit error");
assert!(
err.to_string().contains("завершился до готовности"),
"{err}"
);
}
#[tokio::test]
async fn monitor_cancels_exited_when_child_dies() {
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd");
c.args(["/C", "exit"]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", "exit 0"]);
c
};
let child = cmd
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.expect("spawn of a short-lived process");
let kill = CancellationToken::new();
let exited = CancellationToken::new();
spawn_monitor(child, kill, exited.clone());
tokio::time::timeout(Duration::from_secs(5), exited.cancelled())
.await
.expect("the monitor must arm exited on process exit");
assert!(exited.is_cancelled());
}
#[tokio::test]
#[ignore = "requires a local llama-server binary + model (MINDFORK_LLAMA_BIN, MINDFORK_MODEL)"]
async fn managed_cpu_line_launches_with_the_measured_batch_live() {
use crate::shared::api::EngineBackend;
let (Ok(bin), Ok(model)) = (
std::env::var("MINDFORK_LLAMA_BIN"),
std::env::var("MINDFORK_MODEL"),
) else {
eprintln!("skip: MINDFORK_LLAMA_BIN / MINDFORK_MODEL not set");
return;
};
let cfg = ManagedConfig {
binary: bin.into(),
model_path: Some(model),
gpu_layers: 0,
context_size: 2048,
port: 18124,
..base_cfg()
};
let args = build_args(&cfg, NoMmapSpelling::LoadMode);
eprintln!("the line: {}", args.join(" "));
let at = |flag: &str| {
args.iter()
.position(|a| a == flag)
.map(|i| args[i + 1].clone())
};
assert_eq!(at("-b").as_deref(), Some("256"));
assert_eq!(at("-ub").as_deref(), Some("256"));
let handle = ServerHandle::launch(&cfg, locale(Lang::En)).expect("launch");
let client = OpenAiClient::new(handle.base_url());
wait_until_ready(
&client,
Duration::from_secs(600),
Some(handle.exited()),
locale(Lang::En),
)
.await
.expect("the server should come up on the CPU line");
let slots = client.parallel_slots().await;
let window = client.context_budget().await;
eprintln!("live CPU launch: slots {slots:?}, window {window:?}");
assert_eq!(
slots,
Some(4),
"the server's own default: four unified slots"
);
assert_eq!(window, Some(2048));
}
#[tokio::test]
#[ignore = "requires a local llama-server binary + model (MINDFORK_LLAMA_BIN, MINDFORK_MODEL)"]
async fn managed_sessions_launch_three_slots_over_one_pool_live() {
use crate::shared::api::EngineBackend;
let (Ok(bin), Ok(model)) = (
std::env::var("MINDFORK_LLAMA_BIN"),
std::env::var("MINDFORK_MODEL"),
) else {
eprintln!("skip: MINDFORK_LLAMA_BIN / MINDFORK_MODEL not set");
return;
};
let cfg = ManagedConfig {
binary: bin.into(),
model_path: Some(model),
parallel: 3,
context_size: 4096,
port: 18123,
..base_cfg()
};
let handle = ServerHandle::launch(&cfg, locale(Lang::En)).expect("launch");
let client = OpenAiClient::new(handle.base_url());
wait_until_ready(
&client,
Duration::from_secs(600),
Some(handle.exited()),
locale(Lang::En),
)
.await
.expect("the server should come up");
let slots = client.parallel_slots().await;
let window = client.context_budget().await;
eprintln!("live managed launch: slots {slots:?}, per-slot window {window:?}");
assert_eq!(slots, Some(3));
assert_eq!(
window,
Some(4096),
"unified pool: every slot may use the whole -c"
);
}
#[test]
fn the_launched_batch_follows_the_launch_line() {
assert_eq!(launched_batch(Some(1024), 99), 1024, "typed wins");
assert_eq!(launched_batch(Some(1024), 0), 1024);
assert_eq!(launched_batch(None, 0), CPU_BATCH, "the CPU auto");
assert_eq!(
launched_batch(None, 99),
LLAMA_DEFAULT_BATCH,
"a GPU host at its defaults runs the server's"
);
}
#[test]
fn the_prefill_hold_is_said_only_when_worth_it() {
use crate::shared::api::contract::Prefill;
let slow = Prefill {
tokens: 1800,
ms: 20_000,
};
assert_eq!(prefill_hold(LLAMA_DEFAULT_BATCH, slow), Some(23));
assert_eq!(prefill_hold(CPU_BATCH, slow), None);
let fast = Prefill {
tokens: 1800,
ms: 900,
};
assert_eq!(prefill_hold(LLAMA_DEFAULT_BATCH, fast), None);
let short = Prefill {
tokens: 100,
ms: 5_000,
};
assert_eq!(prefill_hold(LLAMA_DEFAULT_BATCH, short), None);
let mid = Prefill {
tokens: 1500,
ms: 10_000,
};
assert_eq!(prefill_hold(1024, mid), Some(7));
assert_eq!(
prefill_hold(LLAMA_DEFAULT_BATCH, Prefill { tokens: 0, ms: 0 }),
None
);
}
}