From 753f8887f01566aa2f1b7e2f5460e19ed2592915 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 09:56:47 +0000 Subject: [PATCH 1/5] build(deps): bump lemonade embeddable to 11.5.1 Move the pinned Lemonade embeddable runtime from 10.10.0 to 11.5.1. Asset naming is unchanged between the two releases, so the download URLs only need the version substituted. Also refresh a stale lemonade version in an e2e-report test fixture so it stops drifting from the pinned version. Signed-off-by: Roman Inflianskas --- crates/e2e-report/src/lib.rs | 4 ++-- engines/lemonade/src/lib.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/e2e-report/src/lib.rs b/crates/e2e-report/src/lib.rs index c5867e76..b7bf2109 100644 --- a/crates/e2e-report/src/lib.rs +++ b/crates/e2e-report/src/lib.rs @@ -2409,7 +2409,7 @@ mod tests { let platform = r#"{ "platform_slug": "mi300x", "capability": {"effective_serve_engine": "vllm"}, - "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"10.6.0"}, + "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"11.5.1"}, "expectations": [ {"id":"serve-x","effective_engine":"vllm","expected":"pass"} ] @@ -2421,7 +2421,7 @@ mod tests { "Ubuntu 24.04.3 LTS", "ROCm 7.13.0", "vLLM 0.23.0+rocm723", - "lemonade 10.6.0", + "lemonade 11.5.1", ] { assert!(md.contains(token), "matrix cell missing {token:?}:\n{md}"); } diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 260a8318..81aeab59 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -31,7 +31,7 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const ENGINE_NAME: &str = "lemonade"; -const LEMONADE_VERSION: &str = "10.10.0"; +const LEMONADE_VERSION: &str = "11.5.1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; const DEFAULT_MODEL_REPO_DIR: &str = "models--unsloth--Qwen3-4B-Instruct-2507-GGUF"; @@ -56,10 +56,10 @@ const STARTUP_FAILURE_LOG_TAIL_LINES: usize = 80; /// Prevents reading entire gigabyte-sized logs on startup timeout. const MAX_TAIL_READ: u64 = 4 * 1024 * 1024; // 4MB -const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; -const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; +const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-windows-x64.zip"; +const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; +const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-windows-x64.zip"; +const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; #[derive(Parser)] #[command(name = "rocm-engine-lemonade")] From aa8eb7e3a12d2d6e943b4df21bfc42f0617c6048 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 11:16:53 +0000 Subject: [PATCH 2/5] build(deps): bump lemonade offline fallback to 11.5.1 The dash offline fallback version is documented to track the latest release ("Bump deliberately"), but it still named 10.6.0 while the engine pin moved to 11.5.1. A host whose GitHub releases API call fails would silently install a runtime several minor versions behind the pin. Also correct the host-triple mapping doc, which claimed the release ships only the three archives we select; it also publishes ubuntu-arm64. Signed-off-by: Roman Inflianskas --- crates/rocm-dash-tui/src/skills.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 55968c7d..2d94a120 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -257,17 +257,17 @@ pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option` token + archive extension. `None` for an -/// unsupported triple (the release ships only these three). +/// unsupported triple (only these three are supported here; the release also +/// ships `ubuntu-arm64`, which we do not select). fn embeddable_os_arch(os: &str, arch: &str) -> Option<(&'static str, &'static str)> { match (os, arch) { ("linux", "x86_64") => Some(("ubuntu-x64", "tar.gz")), @@ -294,7 +295,7 @@ fn server_bin_for(os: &str) -> &'static str { } } -/// Strip a leading `v` from a release tag (`v10.6.0` → `10.6.0`). +/// Strip a leading `v` from a release tag (`v11.5.1` → `11.5.1`). fn strip_v(tag: &str) -> &str { tag.strip_prefix('v').unwrap_or(tag) } From 841827d17241204ff4803b565fb3e49cf731caa7 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 12:13:53 +0000 Subject: [PATCH 3/5] refactor: derive lemonade artifact names from a single pinned version The Lemonade embeddable version was written out seven times across five constants in the engine adapter, and again as an independent constant in the dashboard TUI. Nothing tied those together, so they drifted: the two constants sat five minor versions apart, the runtime-id label in the CLI was four behind both, and a doc comment had been stale since 10.10.0. Move the pin into `runtime-deps.toml` at the repository root and add a small `rocm-deps` crate whose build script turns each `[runtime.]` field into a constant. Archive names, download URLs, the dashboard's offline fallback, and the managed runtime-id label are all derived from that one value, so a bump is a one-line edit and the tree can no longer hold two different Lemonade versions. Generating at build time rather than committing generated source means there is nothing to drift and no `--check` gate to keep honest. The schema is a table per runtime with one or more string fields, so a runtime needing more than a version (a version plus an ABI tag, say) is additive rather than a reshape. No behaviour change beyond the runtime-id label, which now matches the `env_id` the adapter actually reports instead of naming an older release. Signed-off-by: Roman Inflianskas Signed-off-by: Roman Inflianskas --- .github/workflows/ci.yml | 4 ++ Cargo.lock | 10 ++++ Cargo.toml | 2 + apps/rocm/Cargo.toml | 1 + apps/rocm/src/main.rs | 17 ++++-- crates/rocm-dash-tui/Cargo.toml | 1 + crates/rocm-dash-tui/src/skills.rs | 45 +++++++++++---- crates/rocm-deps/Cargo.toml | 17 ++++++ crates/rocm-deps/build.rs | 77 ++++++++++++++++++++++++++ crates/rocm-deps/src/lib.rs | 89 ++++++++++++++++++++++++++++++ docs/engine-plugins.md | 9 +++ engines/lemonade/Cargo.toml | 1 + engines/lemonade/src/lib.rs | 63 +++++++++++---------- runtime-deps.toml | 18 ++++++ 14 files changed, 308 insertions(+), 46 deletions(-) create mode 100644 crates/rocm-deps/Cargo.toml create mode 100644 crates/rocm-deps/build.rs create mode 100644 crates/rocm-deps/src/lib.rs create mode 100644 runtime-deps.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c55847d..9460afe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,9 @@ jobs: - 'rust-toolchain*' # clippy runs `cargo xtask manifest --check` against this file. - 'MANIFEST.md' + # rocm-deps compiles the pins into constants, so a pin change is a + # source change even though no *.rs file moved. + - 'runtime-deps.toml' - '.github/workflows/**' # build-and-test runs cargo AND the python/shell smoke steps plus the # install-lifecycle E2E (`cargo xtask package` + the real installer), @@ -111,6 +114,7 @@ jobs: - '**/Cargo.toml' - 'Cargo.lock' - 'rust-toolchain*' + - 'runtime-deps.toml' - 'scripts/**' - 'xtask/**' - 'engines/**' diff --git a/Cargo.lock b/Cargo.lock index b1ee9fa7..b117535e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3594,6 +3594,7 @@ dependencies = [ "rocm-core", "rocm-dash-daemon", "rocm-dash-tui", + "rocm-deps", "rocm-engine-lemonade", "rocm-engine-protocol", "rocm-engine-vllm", @@ -3699,6 +3700,7 @@ dependencies = [ "reqwest 0.13.4", "rig-core", "rocm-dash-core", + "rocm-deps", "serde", "serde_json", "thiserror 2.0.18", @@ -3709,6 +3711,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rocm-deps" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "rocm-engine-lemonade" version = "0.1.0" @@ -3716,6 +3725,7 @@ dependencies = [ "anyhow", "clap", "rocm-core", + "rocm-deps", "rocm-engine-protocol", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9d560de4..ff515120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "apps/rocm", "apps/rocmd", "crates/rocm-core", + # Pinned third-party runtime versions, generated from `runtime-deps.toml`. + "crates/rocm-deps", "crates/rocm-engine-protocol", # rocm-dash telemetry/dashboard libraries (rocm-dash merge). "crates/rocm-dash-core", diff --git a/apps/rocm/Cargo.toml b/apps/rocm/Cargo.toml index 3984ca5a..00e7efca 100644 --- a/apps/rocm/Cargo.toml +++ b/apps/rocm/Cargo.toml @@ -18,6 +18,7 @@ crossterm.workspace = true flate2 = "1.1" keyring-core.workspace = true rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } # rocm-dash unified dashboard launch. The # telemetry daemon + ratatui-0.30 TUI are launched from the `dash` verb; tokio # drives the async daemon/TUI from the otherwise-sync `rocm` binary. diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index b0a3fd13..ddf92746 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3408,7 +3408,7 @@ fn resolve_engine_install_runtime_id( runtime_id: Option, ) -> Result { if engine_manages_own_runtime(engine) { - return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine).to_owned())); + return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine))); } let Some(selector) = runtime_id .or_else(|| config.active_runtime_key.clone()) @@ -3512,10 +3512,15 @@ fn env_root_for_service( } } -fn managed_engine_runtime_id(engine: &str) -> &'static str { +/// Label recorded for the runtime a self-managing engine installs for itself. +/// +/// For `lemonade` this must be the `env_id` its adapter reports, which is +/// derived from the single Lemonade pin — it was previously a hand-written +/// literal and had drifted several minor versions behind what is installed. +fn managed_engine_runtime_id(engine: &str) -> String { match engine { - "lemonade" => "lemonade-embeddable-10.6.0", - _ => "managed-engine-runtime", + "lemonade" => format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), + _ => "managed-engine-runtime".to_owned(), } } @@ -3527,7 +3532,7 @@ fn ensure_self_managed_engine_ready( if !engine_manages_own_runtime(engine) { return Ok(()); } - let runtime_id = managed_engine_runtime_id(engine).to_owned(); + let runtime_id = managed_engine_runtime_id(engine); let env_root = env_root_for_self_managed_engine(paths, config)?; let detect = engine_request::<_, DetectResponse>( Some(paths), @@ -21765,7 +21770,7 @@ ID_LIKE="suse opensuse" assert!(error.contains("no active ROCm runtime is configured")); assert_eq!( resolve_engine_install_runtime_id(&paths, &RocmCliConfig::default(), "lemonade", None)?, - "lemonade-embeddable-10.6.0" + format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), ); write_test_pip_runtime( &paths, diff --git a/crates/rocm-dash-tui/Cargo.toml b/crates/rocm-dash-tui/Cargo.toml index d180bc4a..772e8dbb 100644 --- a/crates/rocm-dash-tui/Cargo.toml +++ b/crates/rocm-dash-tui/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] rocm-dash-core = { path = "../rocm-dash-core" } +rocm-deps = { path = "../rocm-deps" } # Unified dashboard TUI base: ratatui 0.30 + crossterm 0.28. This is the only # ratatui in the workspace — the `rocm` binary no longer carries a (dead) 0.29 # dependency, so no second major is pulled in. Exact wrapped-row counts must diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 2d94a120..6b3b657b 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -254,10 +254,18 @@ pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option String { + rocm_deps::LEMONADE_VERSION.to_owned() +} /// A selected embeddable archive for a host triple — enough to download, extract, /// and locate the server binary. Pure data; no I/O. @@ -297,7 +305,7 @@ fn server_bin_for(os: &str) -> &'static str { /// Strip a leading `v` from a release tag (`v11.5.1` → `11.5.1`). fn strip_v(tag: &str) -> &str { - tag.strip_prefix('v').unwrap_or(tag) + rocm_deps::strip_v(tag) } /// PURE: build the canonical embeddable artifact for `(os, arch, version)` with no @@ -305,10 +313,8 @@ fn strip_v(tag: &str) -> &str { pub fn embeddable_artifact(os: &str, arch: &str, version: &str) -> Option { let (os_arch, ext) = embeddable_os_arch(os, arch)?; let ver = strip_v(version); - let archive_name = format!("lemonade-embeddable-{ver}-{os_arch}.{ext}"); - let url = format!( - "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{ver}/{archive_name}" - ); + let archive_name = rocm_deps::lemonade_archive_name(ver, os_arch, ext); + let url = rocm_deps::lemonade_download_url(ver, &archive_name); Some(EmbeddableArtifact { version: ver.to_string(), url, @@ -507,9 +513,26 @@ mod tests { assert_eq!(b.server_bin, "lemond.exe"); // Unsupported triple → None. assert!(embeddable_artifact("linux", "aarch64", "10.6.0").is_none()); - // The fallback const resolves for the common host. + } + + /// The offline fallback and the version the `lemonade` engine adapter + /// installs must never disagree. They cannot: both come from the single + /// `runtime-deps.toml` pin, and this pins that down against a regression + /// that reintroduces a second constant. + #[test] + fn fallback_version_is_the_single_pinned_version() { + let fallback = lemonade_embeddable_fallback_version(); + assert_eq!(fallback, rocm_deps::LEMONADE_VERSION); + let artifact = embeddable_artifact("linux", "x86_64", &fallback).expect("linux"); + assert_eq!(artifact.version, fallback); + assert_eq!( + artifact.archive_name, + rocm_deps::lemonade_archive_name(&fallback, "ubuntu-x64", "tar.gz") + ); assert!( - embeddable_artifact("linux", "x86_64", LEMONADE_EMBEDDABLE_FALLBACK_VERSION).is_some() + artifact.url.contains(&format!("/download/v{fallback}/")), + "url {} does not carry the pinned version", + artifact.url ); } diff --git a/crates/rocm-deps/Cargo.toml b/crates/rocm-deps/Cargo.toml new file mode 100644 index 00000000..16aa13bb --- /dev/null +++ b/crates/rocm-deps/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rocm-deps" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true +# Turns the workspace-root `runtime-deps.toml` pins into constants. Declared +# explicitly so the file is not mistaken for a stray script. +build = "build.rs" + +[lints] +workspace = true + +[build-dependencies] +toml = "0.8" diff --git a/crates/rocm-deps/build.rs b/crates/rocm-deps/build.rs new file mode 100644 index 00000000..1c061d1a --- /dev/null +++ b/crates/rocm-deps/build.rs @@ -0,0 +1,77 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Turn the workspace-root `runtime-deps.toml` pins into Rust constants. +//! +//! Every `[runtime.]` field becomes a `pub const _: &str` +//! written to `$OUT_DIR/pins.rs`, which `src/lib.rs` includes. Generating at +//! build time (rather than committing a generated source file) means the pin +//! exists exactly once in the tree, so there is nothing to drift and no +//! `--check` gate to keep honest; a missing or malformed pin is a build error. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +/// Pin file, relative to this crate's manifest directory. +const PINS_FILE: &str = "../../runtime-deps.toml"; + +fn main() { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"), + ); + let pins_path = manifest_dir.join(PINS_FILE); + println!("cargo:rerun-if-changed={}", pins_path.display()); + + let text = fs::read_to_string(&pins_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", pins_path.display())); + let generated = render(&text, &pins_path); + + let out_path = + PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo")).join("pins.rs"); + fs::write(&out_path, generated) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", out_path.display())); +} + +/// Render the constants for one pin file. Panics with a pointed message on any +/// schema violation so a bad pin fails the build instead of the runtime. +fn render(text: &str, path: &Path) -> String { + let doc: toml::Value = toml::from_str(text) + .unwrap_or_else(|err| panic!("{} is not valid TOML: {err}", path.display())); + let runtimes = doc + .get("runtime") + .and_then(toml::Value::as_table) + .unwrap_or_else(|| panic!("{} must define a [runtime] table", path.display())); + + let mut out = String::from( + "// @generated by build.rs from runtime-deps.toml -- do not edit; edit the pin file.\n", + ); + for (name, entry) in runtimes { + let fields = entry + .as_table() + .unwrap_or_else(|| panic!("[runtime.{name}] must be a table of string fields")); + for (field, value) in fields { + let literal = value + .as_str() + .unwrap_or_else(|| panic!("runtime.{name}.{field} must be a string")); + assert!( + !literal.contains(['"', '\\']), + "runtime.{name}.{field} must not contain quotes or backslashes" + ); + let ident = format!("{}_{}", const_ident(name), const_ident(field)); + writeln!( + out, + "/// `runtime.{name}.{field}` from `runtime-deps.toml`.\n\ + pub const {ident}: &str = \"{literal}\";" + ) + .expect("writing to a String cannot fail"); + } + } + out +} + +/// `rocm-abi` -> `ROCM_ABI`: upper-case, with separators folded to `_`. +fn const_ident(raw: &str) -> String { + raw.to_uppercase().replace(['-', '.'], "_") +} diff --git a/crates/rocm-deps/src/lib.rs b/crates/rocm-deps/src/lib.rs new file mode 100644 index 00000000..e8044d76 --- /dev/null +++ b/crates/rocm-deps/src/lib.rs @@ -0,0 +1,89 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Pinned versions of the third-party runtimes rocm-cli manages, plus the +//! helpers that derive artifact names and URLs from them. +//! +//! The pins live in the workspace-root `runtime-deps.toml` and are turned into +//! the constants below by `build.rs`. Every consumer — the engine adapters and +//! the dashboard TUI alike — derives what it needs from those constants, so a +//! runtime version is spelled exactly once in the repository and two crates +//! cannot disagree about it. + +// `pub const LEMONADE_VERSION: &str = "..."`, one per `runtime-deps.toml` field. +include!(concat!(env!("OUT_DIR"), "/pins.rs")); + +/// GitHub repository publishing the Lemonade embeddable release archives. +pub const LEMONADE_GITHUB_REPO: &str = "lemonade-sdk/lemonade"; + +/// Strip a leading `v` from a release tag (`v11.5.1` -> `11.5.1`). +#[must_use] +pub fn strip_v(tag: &str) -> &str { + tag.strip_prefix('v').unwrap_or(tag) +} + +/// Name of the Lemonade embeddable archive for a host token and extension, +/// e.g. `lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz`. +#[must_use] +pub fn lemonade_archive_name(version: &str, os_arch: &str, extension: &str) -> String { + format!( + "lemonade-embeddable-{}-{os_arch}.{extension}", + strip_v(version) + ) +} + +/// Download URL of a Lemonade embeddable archive published under `v`. +#[must_use] +pub fn lemonade_download_url(version: &str, archive_name: &str) -> String { + format!( + "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{}/{archive_name}", + strip_v(version) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_version_is_a_bare_release_number() { + assert!( + !LEMONADE_VERSION.is_empty() && !LEMONADE_VERSION.starts_with('v'), + "pin must be a bare version, got {LEMONADE_VERSION:?}" + ); + } + + #[test] + fn archive_names_and_urls_match_the_published_shape() { + let version = LEMONADE_VERSION; + let linux = lemonade_archive_name(version, "ubuntu-x64", "tar.gz"); + let windows = lemonade_archive_name(version, "windows-x64", "zip"); + assert_eq!( + linux, + format!("lemonade-embeddable-{version}-ubuntu-x64.tar.gz") + ); + assert_eq!( + windows, + format!("lemonade-embeddable-{version}-windows-x64.zip") + ); + assert_eq!( + lemonade_download_url(version, &linux), + format!( + "https://github.com/lemonade-sdk/lemonade/releases/download/v{version}/{linux}" + ) + ); + } + + #[test] + fn a_tagged_version_is_accepted_anywhere_a_version_is() { + assert_eq!( + lemonade_archive_name("v11.6.0", "ubuntu-x64", "tar.gz"), + "lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz" + ); + assert_eq!( + lemonade_download_url("v11.6.0", "archive.tar.gz"), + "https://github.com/lemonade-sdk/lemonade/releases/download/v11.6.0/archive.tar.gz" + ); + } +} diff --git a/docs/engine-plugins.md b/docs/engine-plugins.md index fa707862..b2909eaa 100644 --- a/docs/engine-plugins.md +++ b/docs/engine-plugins.md @@ -32,6 +32,15 @@ The `lemonade` adapter uses Lemonade embeddable and prefers Lemonade's `llamacpp:rocm` backend, falling back to `llamacpp:vulkan` when ROCm is unsupported. rocm-cli does not use a CPU fallback for this path. +## Pinned runtime versions + +The versions of the third-party runtimes rocm-cli downloads are pinned in +`runtime-deps.toml` at the repository root — one `[runtime.]` table per +runtime. That file is the only place a runtime version is written down: +archive names, download URLs, and the dashboard's offline fallback are all +derived from it, so a bump is a one-line edit and the tree cannot end up +holding two different versions of the same runtime. + `rocm engines list` shows the exact plugin directories for the current host. The same output is available in the TUI with `/engine`. diff --git a/engines/lemonade/Cargo.toml b/engines/lemonade/Cargo.toml index e840bb0e..9dafc9a0 100644 --- a/engines/lemonade/Cargo.toml +++ b/engines/lemonade/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } rocm-engine-protocol = { path = "../../crates/rocm-engine-protocol" } serde.workspace = true serde_json.workspace = true diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 81aeab59..5d4294bd 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -31,7 +31,6 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const ENGINE_NAME: &str = "lemonade"; -const LEMONADE_VERSION: &str = "11.5.1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; const DEFAULT_MODEL_REPO_DIR: &str = "models--unsloth--Qwen3-4B-Instruct-2507-GGUF"; @@ -56,10 +55,16 @@ const STARTUP_FAILURE_LOG_TAIL_LINES: usize = 80; /// Prevents reading entire gigabyte-sized logs on startup timeout. const MAX_TAIL_READ: u64 = 4 * 1024 * 1024; // 4MB -const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-windows-x64.zip"; -const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; -const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-windows-x64.zip"; -const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; +/// Embeddable asset token and archive extension for the host this adapter runs +/// on. Only `windows-x64` and `ubuntu-x64` are wired up here; the release also +/// publishes `macos-arm64` and `ubuntu-arm64`, which this adapter never selects. +const fn embeddable_os_arch() -> (&'static str, &'static str) { + if runtime_is_windows() { + ("windows-x64", "zip") + } else { + ("ubuntu-x64", "tar.gz") + } +} #[derive(Parser)] #[command(name = "rocm-engine-lemonade")] @@ -391,7 +396,10 @@ fn detect_response() -> DetectResponse { fn install_response(request: InstallRequest) -> Result { let paths = AppPaths::discover()?; paths.ensure()?; - eprintln!("Preparing Lemonade embeddable {LEMONADE_VERSION}..."); + eprintln!( + "Preparing Lemonade embeddable {}...", + rocm_deps::LEMONADE_VERSION + ); let env_root = request .env_root .as_deref() @@ -971,16 +979,17 @@ fn prepare_embeddable( reinstall: bool, ) -> Result { let root = lemonade_root(paths, env_root); - let archive_name = embeddable_archive_name(); - let archive_url = embeddable_url(); + let version = rocm_deps::LEMONADE_VERSION.to_owned(); + let archive_name = embeddable_archive_name(&version); + let archive_url = embeddable_url(&version); let downloads = root.join("downloads"); - let archive = downloads.join(archive_name); + let archive = downloads.join(&archive_name); fs::create_dir_all(&downloads)?; if archive.is_file() { eprintln!("Using cached {archive_name}."); } else { eprintln!("Downloading {archive_name}..."); - download_file(archive_url, &archive)?; + download_file(&archive_url, &archive)?; } let runtime_dir = runtime_dir_in(&root); if reinstall || !lemond_path_in(&runtime_dir).is_file() { @@ -1006,8 +1015,8 @@ fn prepare_embeddable( ); } Ok(LemonadeInstallManifest { - env_id: format!("lemonade-embeddable-{LEMONADE_VERSION}"), - version: LEMONADE_VERSION.to_owned(), + env_id: format!("lemonade-embeddable-{version}"), + version, runtime_dir, lemond, lemonade, @@ -1225,20 +1234,13 @@ fn platform_binary_name(name: &str) -> String { } } -const fn embeddable_archive_name() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_ARCHIVE_NAME - } else { - EMBEDDABLE_LINUX_ARCHIVE_NAME - } +fn embeddable_archive_name(version: &str) -> String { + let (os_arch, extension) = embeddable_os_arch(); + rocm_deps::lemonade_archive_name(version, os_arch, extension) } -const fn embeddable_url() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_URL - } else { - EMBEDDABLE_LINUX_URL - } +fn embeddable_url(version: &str) -> String { + rocm_deps::lemonade_download_url(version, &embeddable_archive_name(version)) } fn download_file(url: &str, destination: &Path) -> Result<()> { @@ -3079,7 +3081,7 @@ mod tests { fn test_manifest(runtime_dir: PathBuf) -> LemonadeInstallManifest { LemonadeInstallManifest { env_id: "test".to_owned(), - version: LEMONADE_VERSION.to_owned(), + version: rocm_deps::LEMONADE_VERSION.to_owned(), runtime_dir, lemond: PathBuf::from("lemond"), lemonade: PathBuf::from("lemonade"), @@ -3106,13 +3108,16 @@ mod tests { #[test] fn embeddable_package_matches_runtime_os() { + let version = rocm_deps::LEMONADE_VERSION; + let (os_arch, extension) = embeddable_os_arch(); + let suffix = format!("{os_arch}.{extension}"); + assert!(embeddable_archive_name(version).ends_with(&suffix)); + assert!(embeddable_url(version).ends_with(&suffix)); if runtime_is_windows() { - assert!(embeddable_archive_name().ends_with("windows-x64.zip")); - assert!(embeddable_url().ends_with("windows-x64.zip")); + assert_eq!(suffix, "windows-x64.zip"); assert_eq!(platform_binary_name("lemond"), "lemond.exe"); } else { - assert!(embeddable_archive_name().ends_with("ubuntu-x64.tar.gz")); - assert!(embeddable_url().ends_with("ubuntu-x64.tar.gz")); + assert_eq!(suffix, "ubuntu-x64.tar.gz"); assert_eq!(platform_binary_name("lemond"), "lemond"); } } diff --git a/runtime-deps.toml b/runtime-deps.toml new file mode 100644 index 00000000..44885660 --- /dev/null +++ b/runtime-deps.toml @@ -0,0 +1,18 @@ +# Pinned versions of the third-party runtimes rocm-cli downloads and manages. +# +# This file is the SINGLE SOURCE OF TRUTH for those pins. Nothing else in the +# tree may spell a runtime version literal: archive names, download URLs, and +# offline fallbacks are all derived from the values below by the `rocm-deps` +# crate, whose build script turns each entry into a Rust constant. Editing a +# version here is the whole bump. +# +# Schema: one `[runtime.]` table per managed runtime, with one or more +# string fields. Every field becomes a `pub const _: &str` in +# `rocm-deps` (uppercased, `-` and `.` folded to `_`), so a runtime that needs +# more than a version — say a version plus an ABI tag — just adds a field. + +[runtime.lemonade] +# Lemonade embeddable release, published as +# `lemonade-embeddable--.{tar.gz|zip}` under the +# `v` tag of the lemonade-sdk/lemonade GitHub releases. +version = "11.5.1" From 3b5a31ef8ad9e1f7083d1b5f99f7d7abb29e5f76 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 12:15:19 +0000 Subject: [PATCH 4/5] feat: allow overriding the pinned lemonade version from the environment Trying a Lemonade release other than the pinned one meant editing source and rebuilding. Add `ROCM_CLI_LEMONADE_VERSION`, following the existing `ROCM_CLI_*` convention, which replaces the `runtime-deps.toml` default for the invocation. Because every archive name and download URL is derived from the resolved version, an override reaches all of them; it cannot leave a URL pointing at the pin. A leading `v` is optional and a blank value falls back to the pin, so a mistyped or empty override degrades to the tested default instead of producing a broken URL. Only the pin is tested, so an override prints a warning naming both versions before the install proceeds. Resolution is a pure function taking the override as a parameter, so the tests cover it without touching process environment. Signed-off-by: Roman Inflianskas Signed-off-by: Roman Inflianskas --- apps/rocm/src/main.rs | 4 +- crates/rocm-dash-tui/src/skills.rs | 9 +-- crates/rocm-deps/src/lib.rs | 94 ++++++++++++++++++++++++++---- docs/engine-plugins.md | 11 ++++ engines/lemonade/src/lib.rs | 35 ++++++++--- runtime-deps.toml | 4 ++ 6 files changed, 132 insertions(+), 25 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index ddf92746..159f011b 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3519,7 +3519,7 @@ fn env_root_for_service( /// literal and had drifted several minor versions behind what is installed. fn managed_engine_runtime_id(engine: &str) -> String { match engine { - "lemonade" => format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), + "lemonade" => format!("lemonade-embeddable-{}", rocm_deps::lemonade_version()), _ => "managed-engine-runtime".to_owned(), } } @@ -21770,7 +21770,7 @@ ID_LIKE="suse opensuse" assert!(error.contains("no active ROCm runtime is configured")); assert_eq!( resolve_engine_install_runtime_id(&paths, &RocmCliConfig::default(), "lemonade", None)?, - format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), + format!("lemonade-embeddable-{}", rocm_deps::lemonade_version()), ); write_test_pip_runtime( &paths, diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 6b3b657b..2915ad98 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -260,11 +260,12 @@ pub use rocm_deps::LEMONADE_GITHUB_REPO; /// is unreachable. /// /// This is the same pin the `lemonade` engine adapter installs, resolved from -/// `runtime-deps.toml` rather than restated here — the two used to be separate -/// constants and silently drifted five minor versions apart. +/// `runtime-deps.toml` (and the `ROCM_CLI_LEMONADE_VERSION` override) rather +/// than restated here — the two used to be separate constants and silently +/// drifted five minor versions apart. #[must_use] pub fn lemonade_embeddable_fallback_version() -> String { - rocm_deps::LEMONADE_VERSION.to_owned() + rocm_deps::lemonade_version() } /// A selected embeddable archive for a host triple — enough to download, extract, @@ -522,7 +523,7 @@ mod tests { #[test] fn fallback_version_is_the_single_pinned_version() { let fallback = lemonade_embeddable_fallback_version(); - assert_eq!(fallback, rocm_deps::LEMONADE_VERSION); + assert_eq!(fallback, rocm_deps::lemonade_version()); let artifact = embeddable_artifact("linux", "x86_64", &fallback).expect("linux"); assert_eq!(artifact.version, fallback); assert_eq!( diff --git a/crates/rocm-deps/src/lib.rs b/crates/rocm-deps/src/lib.rs index e8044d76..b074472d 100644 --- a/crates/rocm-deps/src/lib.rs +++ b/crates/rocm-deps/src/lib.rs @@ -10,6 +10,11 @@ //! the dashboard TUI alike — derives what it needs from those constants, so a //! runtime version is spelled exactly once in the repository and two crates //! cannot disagree about it. +//! +//! Each pin can be overridden per invocation by an environment variable +//! following the existing `ROCM_CLI_*` convention. Overrides are for trying an +//! untested release; the pin is the only combination the project tests, so +//! callers should surface [`lemonade_version_override_notice`] to the user. // `pub const LEMONADE_VERSION: &str = "..."`, one per `runtime-deps.toml` field. include!(concat!(env!("OUT_DIR"), "/pins.rs")); @@ -17,6 +22,47 @@ include!(concat!(env!("OUT_DIR"), "/pins.rs")); /// GitHub repository publishing the Lemonade embeddable release archives. pub const LEMONADE_GITHUB_REPO: &str = "lemonade-sdk/lemonade"; +/// Environment variable overriding [`LEMONADE_VERSION`]. +pub const LEMONADE_VERSION_ENV: &str = "ROCM_CLI_LEMONADE_VERSION"; + +/// The Lemonade embeddable version to use, honoring [`LEMONADE_VERSION_ENV`]. +/// +/// Reads the process environment; the decision itself lives in the pure +/// [`resolve_lemonade_version`] so it can be tested without touching env. +#[must_use] +pub fn lemonade_version() -> String { + resolve_lemonade_version(std::env::var(LEMONADE_VERSION_ENV).ok().as_deref()) +} + +/// Pure resolution of the Lemonade version from an optional override. +/// +/// Falls back to [`LEMONADE_VERSION`] when the override is absent, blank, or +/// nothing but a `v`. A usable override is trimmed and has any leading `v` +/// stripped, so `v11.6.0` and `11.6.0` behave identically. +#[must_use] +pub fn resolve_lemonade_version(override_value: Option<&str>) -> String { + override_value + .map(str::trim) + .map(strip_v) + .filter(|value| !value.is_empty()) + .unwrap_or(LEMONADE_VERSION) + .to_owned() +} + +/// A warning to show the user when `version` is not the tested pin. +/// +/// `None` for the pinned version — the normal case — so callers can +/// unconditionally print whatever comes back. +#[must_use] +pub fn lemonade_version_override_notice(version: &str) -> Option { + (version != LEMONADE_VERSION).then(|| { + format!( + "Warning: using Lemonade {version} from {LEMONADE_VERSION_ENV}; \ + the tested version is {LEMONADE_VERSION}." + ) + }) +} + /// Strip a leading `v` from a release tag (`v11.5.1` -> `11.5.1`). #[must_use] pub fn strip_v(tag: &str) -> &str { @@ -54,11 +100,28 @@ mod tests { ); } + #[test] + fn absent_or_blank_override_keeps_the_pin() { + for override_value in [None, Some(""), Some(" "), Some("v"), Some(" v ")] { + assert_eq!( + resolve_lemonade_version(override_value), + LEMONADE_VERSION, + "override {override_value:?} should have kept the pin" + ); + } + } + + #[test] + fn override_is_trimmed_and_v_stripped() { + assert_eq!(resolve_lemonade_version(Some(" v11.6.0 ")), "11.6.0"); + assert_eq!(resolve_lemonade_version(Some("11.6.0")), "11.6.0"); + } + #[test] fn archive_names_and_urls_match_the_published_shape() { - let version = LEMONADE_VERSION; - let linux = lemonade_archive_name(version, "ubuntu-x64", "tar.gz"); - let windows = lemonade_archive_name(version, "windows-x64", "zip"); + let version = resolve_lemonade_version(None); + let linux = lemonade_archive_name(&version, "ubuntu-x64", "tar.gz"); + let windows = lemonade_archive_name(&version, "windows-x64", "zip"); assert_eq!( linux, format!("lemonade-embeddable-{version}-ubuntu-x64.tar.gz") @@ -68,7 +131,7 @@ mod tests { format!("lemonade-embeddable-{version}-windows-x64.zip") ); assert_eq!( - lemonade_download_url(version, &linux), + lemonade_download_url(&version, &linux), format!( "https://github.com/lemonade-sdk/lemonade/releases/download/v{version}/{linux}" ) @@ -76,14 +139,25 @@ mod tests { } #[test] - fn a_tagged_version_is_accepted_anywhere_a_version_is() { + fn override_flows_through_to_names_and_urls() { + let version = resolve_lemonade_version(Some("v11.6.0")); + let archive = lemonade_archive_name(&version, "ubuntu-x64", "tar.gz"); + assert_eq!(archive, "lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz"); + let url = lemonade_download_url(&version, &archive); assert_eq!( - lemonade_archive_name("v11.6.0", "ubuntu-x64", "tar.gz"), - "lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz" + url, + "https://github.com/lemonade-sdk/lemonade/releases/download/v11.6.0/lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz" ); - assert_eq!( - lemonade_download_url("v11.6.0", "archive.tar.gz"), - "https://github.com/lemonade-sdk/lemonade/releases/download/v11.6.0/archive.tar.gz" + assert!( + !url.contains(LEMONADE_VERSION), + "overridden URL still references the pin: {url}" ); } + + #[test] + fn only_an_override_produces_a_notice() { + assert_eq!(lemonade_version_override_notice(LEMONADE_VERSION), None); + let notice = lemonade_version_override_notice("11.6.0").expect("override notice"); + assert!(notice.contains("11.6.0") && notice.contains(LEMONADE_VERSION_ENV)); + } } diff --git a/docs/engine-plugins.md b/docs/engine-plugins.md index b2909eaa..c39697b6 100644 --- a/docs/engine-plugins.md +++ b/docs/engine-plugins.md @@ -41,6 +41,17 @@ archive names, download URLs, and the dashboard's offline fallback are all derived from it, so a bump is a one-line edit and the tree cannot end up holding two different versions of the same runtime. +To try a release other than the pinned one, set the runtime's environment +variable; the derived archive names and URLs follow it automatically. A +leading `v` is optional, and an empty value falls back to the pin. + +```bash +ROCM_CLI_LEMONADE_VERSION=11.6.0 rocm engines install lemonade +``` + +Only the pinned version is tested. An override prints a warning naming the +version in use and the version the project tests. + `rocm engines list` shows the exact plugin directories for the current host. The same output is available in the TUI with `/engine`. diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 5d4294bd..22d7be8e 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -396,10 +396,11 @@ fn detect_response() -> DetectResponse { fn install_response(request: InstallRequest) -> Result { let paths = AppPaths::discover()?; paths.ensure()?; - eprintln!( - "Preparing Lemonade embeddable {}...", - rocm_deps::LEMONADE_VERSION - ); + let version = rocm_deps::lemonade_version(); + if let Some(notice) = rocm_deps::lemonade_version_override_notice(&version) { + eprintln!("{notice}"); + } + eprintln!("Preparing Lemonade embeddable {version}..."); let env_root = request .env_root .as_deref() @@ -979,7 +980,7 @@ fn prepare_embeddable( reinstall: bool, ) -> Result { let root = lemonade_root(paths, env_root); - let version = rocm_deps::LEMONADE_VERSION.to_owned(); + let version = rocm_deps::lemonade_version(); let archive_name = embeddable_archive_name(&version); let archive_url = embeddable_url(&version); let downloads = root.join("downloads"); @@ -3081,7 +3082,7 @@ mod tests { fn test_manifest(runtime_dir: PathBuf) -> LemonadeInstallManifest { LemonadeInstallManifest { env_id: "test".to_owned(), - version: rocm_deps::LEMONADE_VERSION.to_owned(), + version: rocm_deps::lemonade_version(), runtime_dir, lemond: PathBuf::from("lemond"), lemonade: PathBuf::from("lemonade"), @@ -3108,11 +3109,11 @@ mod tests { #[test] fn embeddable_package_matches_runtime_os() { - let version = rocm_deps::LEMONADE_VERSION; + let version = rocm_deps::lemonade_version(); let (os_arch, extension) = embeddable_os_arch(); let suffix = format!("{os_arch}.{extension}"); - assert!(embeddable_archive_name(version).ends_with(&suffix)); - assert!(embeddable_url(version).ends_with(&suffix)); + assert!(embeddable_archive_name(&version).ends_with(&suffix)); + assert!(embeddable_url(&version).ends_with(&suffix)); if runtime_is_windows() { assert_eq!(suffix, "windows-x64.zip"); assert_eq!(platform_binary_name("lemond"), "lemond.exe"); @@ -3122,6 +3123,22 @@ mod tests { } } + #[test] + fn overridden_version_reaches_both_the_archive_name_and_the_url() { + let version = rocm_deps::resolve_lemonade_version(Some("v99.0.0")); + let (os_arch, extension) = embeddable_os_arch(); + assert_eq!( + embeddable_archive_name(&version), + format!("lemonade-embeddable-99.0.0-{os_arch}.{extension}") + ); + let url = embeddable_url(&version); + assert!(url.contains("/download/v99.0.0/"), "url: {url}"); + assert!( + !url.contains(rocm_deps::LEMONADE_VERSION), + "override left the pinned version in the url: {url}" + ); + } + #[test] fn lemonade_root_uses_requested_engine_root() { let paths = AppPaths { diff --git a/runtime-deps.toml b/runtime-deps.toml index 44885660..85162274 100644 --- a/runtime-deps.toml +++ b/runtime-deps.toml @@ -10,6 +10,10 @@ # string fields. Every field becomes a `pub const _: &str` in # `rocm-deps` (uppercased, `-` and `.` folded to `_`), so a runtime that needs # more than a version — say a version plus an ABI tag — just adds a field. +# +# A pin can be overridden at run time by the runtime's `ROCM_CLI_*` environment +# variable (see `rocm-deps` and `docs/engine-plugins.md`); the pin here is the +# default and the only combination the project tests. [runtime.lemonade] # Lemonade embeddable release, published as From 8236118df07ce5720c35887cd2ceab204b4ea2c6 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 12:38:24 +0000 Subject: [PATCH 5/5] fix: reinstall lemonade when the pinned version changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embeddable runtime directory is not version-scoped, so after a bump `prepare_embeddable` downloaded the new archive but skipped extraction because `lemond` was already there from the previous version. The old binaries stayed in place and were then reported as the new version. The CLI made the same mistake one layer up: it asked the adapter whether a runtime was installed and ignored which version came back, so a bump never even reached the install path. Compare versions in both places. The adapter re-extracts when the recorded version differs from the one being installed, and the CLI now requires the detected env id to be the one it asked for — which the previous commit made meaningful by deriving that id from the pin. The decision in the adapter is a pure function so the upgrade case is tested without a real archive. Also guard the pin generator against two entries whose names differ only by separator (`rocm-abi` and `rocm_abi` both fold to `ROCM_ABI`), and escape generated literals with Debug formatting instead of a hand-written rule about which characters are allowed. Signed-off-by: Roman Inflianskas Signed-off-by: Roman Inflianskas --- apps/rocm/src/main.rs | 8 +++++++- crates/rocm-deps/build.rs | 19 +++++++++++------ engines/lemonade/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 159f011b..ba3ee5a5 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3544,8 +3544,14 @@ fn ensure_self_managed_engine_ready( }, ) .ok(); + // For a self-managing engine the runtime id *is* the env id its adapter + // reports for the pinned version, so a version bump leaves an older + // install detected-but-not-current. Requiring the ids to match makes the + // bump trigger an install instead of silently keeping the old runtime. let installed = detect.as_ref().is_some_and(|detect| { - detect.installed && detect_runtime_matches_env_root(detect, env_root.as_deref()) + detect.installed + && detect.env_id.as_deref() == Some(runtime_id.as_str()) + && detect_runtime_matches_env_root(detect, env_root.as_deref()) }); let response = if installed { None diff --git a/crates/rocm-deps/build.rs b/crates/rocm-deps/build.rs index 1c061d1a..97254126 100644 --- a/crates/rocm-deps/build.rs +++ b/crates/rocm-deps/build.rs @@ -10,6 +10,7 @@ //! exists exactly once in the tree, so there is nothing to drift and no //! `--check` gate to keep honest; a missing or malformed pin is a build error. +use std::collections::BTreeMap; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::{env, fs}; @@ -47,6 +48,11 @@ fn render(text: &str, path: &Path) -> String { let mut out = String::from( "// @generated by build.rs from runtime-deps.toml -- do not edit; edit the pin file.\n", ); + // `a-b` and `a.b` both fold to `A_B`, so two differently spelled entries + // could generate the same constant. Reject that here rather than emitting + // a duplicate definition and a confusing compile error in the generated + // file. + let mut seen: BTreeMap = BTreeMap::new(); for (name, entry) in runtimes { let fields = entry .as_table() @@ -55,15 +61,16 @@ fn render(text: &str, path: &Path) -> String { let literal = value .as_str() .unwrap_or_else(|| panic!("runtime.{name}.{field} must be a string")); - assert!( - !literal.contains(['"', '\\']), - "runtime.{name}.{field} must not contain quotes or backslashes" - ); let ident = format!("{}_{}", const_ident(name), const_ident(field)); + let source = format!("runtime.{name}.{field}"); + if let Some(previous) = seen.insert(ident.clone(), source.clone()) { + panic!("{previous} and {source} both generate {ident}; rename one"); + } + // `{:?}` renders a correctly escaped Rust string literal, so any + // quote, backslash or control character in the pin stays valid. writeln!( out, - "/// `runtime.{name}.{field}` from `runtime-deps.toml`.\n\ - pub const {ident}: &str = \"{literal}\";" + "/// `{source}` from `runtime-deps.toml`.\npub const {ident}: &str = {literal:?};" ) .expect("writing to a String cannot fail"); } diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 22d7be8e..fb2ece70 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -974,6 +974,19 @@ fn stop_service(request: StopRequest) -> Result { Ok(StopResponse { stopped, graceful }) } +/// Whether the embeddable archive must be extracted over the runtime tree. +/// +/// Pure so the upgrade case is testable without a real archive. `installed` +/// is the version recorded for the tree already on disk, if any. +fn needs_extraction( + reinstall: bool, + installed: Option<&str>, + wanted: &str, + server_present: bool, +) -> bool { + reinstall || !server_present || installed != Some(wanted) +} + fn prepare_embeddable( paths: &AppPaths, env_root: Option<&Path>, @@ -993,7 +1006,20 @@ fn prepare_embeddable( download_file(&archive_url, &archive)?; } let runtime_dir = runtime_dir_in(&root); - if reinstall || !lemond_path_in(&runtime_dir).is_file() { + // The runtime directory is not version-scoped, so a bump downloads a new + // archive into a tree that already holds `lemond` from the previous + // version. Without comparing versions the extraction would be skipped and + // the old binaries reported as the new version. + let installed_version = read_manifest(paths) + .ok() + .filter(|manifest| manifest.runtime_dir == runtime_dir) + .map(|manifest| manifest.version); + if needs_extraction( + reinstall, + installed_version.as_deref(), + &version, + lemond_path_in(&runtime_dir).is_file(), + ) { if runtime_dir.exists() { fs::remove_dir_all(&runtime_dir) .with_context(|| format!("failed to clear {}", runtime_dir.display()))?; @@ -3123,6 +3149,19 @@ mod tests { } } + #[test] + fn a_version_change_forces_re_extraction_over_the_existing_runtime() { + let pin = rocm_deps::LEMONADE_VERSION; + // Same version already unpacked: nothing to do. + assert!(!needs_extraction(false, Some(pin), pin, true)); + // A bump must not be silently skipped just because `lemond` exists. + assert!(needs_extraction(false, Some("10.10.0"), pin, true)); + // Nothing recorded, nothing unpacked, or an explicit reinstall. + assert!(needs_extraction(false, None, pin, true)); + assert!(needs_extraction(false, Some(pin), pin, false)); + assert!(needs_extraction(true, Some(pin), pin, true)); + } + #[test] fn overridden_version_reaches_both_the_archive_name_and_the_url() { let version = rocm_deps::resolve_lemonade_version(Some("v99.0.0"));