Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
307 lines
16 KiB
C++
307 lines
16 KiB
C++
#include "model_installer.h"
|
|
#include "engine/framework/io/json.h"
|
|
#include "engine/framework/package_manager/manager.h"
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <future>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
void require(bool condition, const std::string & message) {
|
|
if (!condition) throw std::runtime_error(message);
|
|
}
|
|
|
|
std::filesystem::path make_root() {
|
|
const auto suffix = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
|
const auto root = std::filesystem::temp_directory_path() /
|
|
("audiocpp-native-model-manager-test-" + std::to_string(suffix));
|
|
std::filesystem::create_directories(root);
|
|
return root;
|
|
}
|
|
|
|
void write(const std::filesystem::path & path, const std::string & value) {
|
|
std::filesystem::create_directories(path.parent_path());
|
|
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
|
output << value;
|
|
}
|
|
|
|
void set_base_url(const std::string & value) {
|
|
#ifdef _WIN32
|
|
_putenv_s("AUDIOCPP_HF_BASE_URL", value.c_str());
|
|
#else
|
|
setenv("AUDIOCPP_HF_BASE_URL", value.c_str(), 1);
|
|
#endif
|
|
}
|
|
|
|
std::string wait_for(minitts::server::ModelInstaller & installer, const std::string & id,
|
|
const std::string & state, int attempts = 200) {
|
|
for (int attempt = 0; attempt < attempts; ++attempt) {
|
|
const auto status = installer.status(id);
|
|
if (status.find("\"state\":\"" + state + "\"") != std::string::npos) return status;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
throw std::runtime_error("timed out waiting for " + id + " to become " + state);
|
|
}
|
|
|
|
void test_native_package_info() {
|
|
namespace json = engine::io::json;
|
|
const auto root = make_root();
|
|
try {
|
|
write(root / "model_specs" / "demo.json", R"({"family":"demo",
|
|
"package_defaults":{"download":{"kind":"huggingface_snapshot","repo":"org/repo"}},
|
|
"packages":[
|
|
{"id":"components","display_name":"Components","format":"gguf","precision":"f32",
|
|
"target_directory":"Nested/Components","strip_prefix":"Remote/",
|
|
"files":["Remote/config.json","Remote/subdir/z.gguf","Remote/a.gguf"]},
|
|
{"id":"directory","display_name":"Directory","format":"directory","precision":"f32",
|
|
"target_directory":"Directory","strip_prefix":".","files":["./config.json"]},
|
|
{"id":"empty","display_name":"Empty","format":"gguf","precision":"f32",
|
|
"target_directory":"Empty","files":[]}
|
|
]})");
|
|
engine::package_manager::PackageManager manager(root, root / "models");
|
|
require(!manager.package_info("missing").has_value(), "unknown packages return no package info");
|
|
const auto info = manager.package_info("components");
|
|
require(info.has_value() && info->family == "demo" && !info->installed &&
|
|
info->package.root == root / "models" / "Nested" / "Components" &&
|
|
info->package.format == "gguf", "package info exposes raw identity, root, format, and local presence");
|
|
const std::vector<std::filesystem::path> expected = {"config.json", "subdir/z.gguf", "a.gguf"};
|
|
require(info->package.files == expected,
|
|
"package info strips the prefix, preserves file order and subdirectories, and retains every component");
|
|
require(!std::filesystem::exists(info->package.root), "package lookup does not install or create a package");
|
|
const auto directory = manager.package_info("directory");
|
|
require(directory.has_value() && directory->package.format == "directory" &&
|
|
directory->package.files == std::vector<std::filesystem::path>{"config.json"},
|
|
"non-GGUF packages use the same generic metadata and dot-prefix semantics");
|
|
require(!manager.package_info("empty")->installed && manager.package_info("empty")->package.files.empty(),
|
|
"packages without files retain the installer's not-installed state");
|
|
|
|
const auto check_inventory = [&](bool installed) {
|
|
const auto response = json::parse(manager.inventory(false, "components"));
|
|
const auto & row = response.as_array().at(0);
|
|
const auto & metadata = row.require("package");
|
|
require(row.require("installed").as_bool() == installed &&
|
|
manager.package_info("components")->installed == installed,
|
|
"lookup and local inventory agree on complete package presence");
|
|
require(metadata.require("family").as_string() == info->family &&
|
|
metadata.require("root").as_string() == info->package.root.generic_string() &&
|
|
metadata.require("format").as_string() == info->package.format,
|
|
"inventory exposes the same raw package identity, root, and format");
|
|
const auto & files = metadata.require("files").as_array();
|
|
require(files.size() == expected.size(), "inventory retains all package files");
|
|
for (size_t i = 0; i < expected.size(); ++i) {
|
|
require(files[i].as_string() == expected[i].generic_string(),
|
|
"inventory paths are installed-relative and retain source order");
|
|
}
|
|
require(metadata.as_object().size() == 4 && row.find("load_target") == nullptr &&
|
|
row.find("load_options") == nullptr && row.find("session_options") == nullptr,
|
|
"native inventory contains raw package data without loading policy or options");
|
|
};
|
|
check_inventory(false);
|
|
write(info->package.root / expected[0], "{}");
|
|
check_inventory(false);
|
|
write(info->package.root / expected[1], "payload");
|
|
write(info->package.root / expected[2], "payload");
|
|
check_inventory(true);
|
|
std::filesystem::remove(info->package.root / expected[0]);
|
|
check_inventory(false);
|
|
} catch (...) {
|
|
std::filesystem::remove_all(root);
|
|
throw;
|
|
}
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_native_package_lifecycle() {
|
|
const auto root = make_root();
|
|
try {
|
|
write(root / "model_specs" / "demo.json", R"JSON({
|
|
"family":"demo","display_name":"Demo","description":"","category":"tts",
|
|
"status":"supported","tasks":["tts"],"modes":["offline"],"languages":[],
|
|
"capabilities":{},"runtime":{},
|
|
"package_defaults":{"download":{"kind":"huggingface_snapshot","repo":"org/repo","revision":"main"}},
|
|
"packages":[
|
|
{"id":"demo_q8","display_name":"Demo Q8","default":true,"format":"gguf","precision":"q8_0",
|
|
"target_directory":"Demo","files":["Demo/model-q8.gguf","Demo/shared.json"],"strip_prefix":"Demo"},
|
|
{"id":"demo_f16","display_name":"Demo F16","format":"gguf","precision":"f16",
|
|
"target_directory":"Demo","files":["Demo/model-f16.gguf","Demo/shared.json"],"strip_prefix":"Demo"},
|
|
{"id":"demo_slow","display_name":"Demo Slow","format":"gguf","precision":"q8_0",
|
|
"target_directory":"Slow","files":["Slow/model.gguf"],"strip_prefix":"Slow"},
|
|
{"id":"demo_slow_sibling","display_name":"Demo Slow sibling","format":"gguf","precision":"f16",
|
|
"target_directory":"Slow","files":["Slow/sibling.gguf"],"strip_prefix":"Slow"}
|
|
],"sources":[]
|
|
})JSON");
|
|
set_base_url("http://127.0.0.1:18991");
|
|
auto fixture = std::async(std::launch::async, [] {
|
|
#ifdef _WIN32
|
|
return std::system("python \"" AUDIOCPP_NATIVE_MANAGER_FIXTURE "\" --requests 16");
|
|
#else
|
|
return std::system("python3 \"" AUDIOCPP_NATIVE_MANAGER_FIXTURE "\" --requests 16");
|
|
#endif
|
|
});
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
|
|
minitts::server::ModelInstaller installer(root, root / "models");
|
|
require(installer.status("missing").find("\"state\":\"idle\"") != std::string::npos,
|
|
"unknown jobs are idle");
|
|
bool rejected = false;
|
|
try { (void) installer.start("bad & package", "", "", "", "", false); }
|
|
catch (const std::runtime_error &) { rejected = true; }
|
|
require(rejected, "unsafe package ids are rejected");
|
|
|
|
auto started = installer.start("demo_q8", "", "", "", "", false);
|
|
require(started.find("\"state\":\"queued\"") != std::string::npos ||
|
|
started.find("\"state\":\"running\"") != std::string::npos,
|
|
"native install starts asynchronously");
|
|
const auto sibling_started = installer.start("demo_f16", "", "", "", "", false);
|
|
require(sibling_started.find("\"state\":\"queued\"") != std::string::npos ||
|
|
sibling_started.find("\"state\":\"running\"") != std::string::npos,
|
|
"a sibling package can be queued while the first package is installing");
|
|
auto complete = wait_for(installer, "demo_q8", "complete");
|
|
(void) wait_for(installer, "demo_f16", "complete");
|
|
require(complete.find("\"progress_percent\":100") != std::string::npos,
|
|
"completed install reports 100 percent");
|
|
require(std::filesystem::is_regular_file(root / "models" / "Demo" / "model-q8.gguf"),
|
|
"model payload is installed");
|
|
require(std::filesystem::is_regular_file(root / "models" / "Demo" / "shared.json"),
|
|
"shared sidecar is installed");
|
|
require(std::filesystem::is_regular_file(root / "models" / "Demo" / ".audiocpp-package-demo_q8.json"),
|
|
"native install writes a version manifest");
|
|
|
|
// Packages in the same model family share a target directory. The
|
|
// package manager serializes them so common sidecars are published once.
|
|
require(std::filesystem::is_regular_file(root / "models" / "Demo" / "model-f16.gguf"),
|
|
"sibling precision installs beside Q8");
|
|
auto removed = installer.remove("demo_q8");
|
|
require(removed.find("\"removed\":true") != std::string::npos, "Q8 removal succeeds");
|
|
require(!std::filesystem::exists(root / "models" / "Demo" / "model-q8.gguf"),
|
|
"Q8-owned file is removed");
|
|
require(std::filesystem::exists(root / "models" / "Demo" / "shared.json"),
|
|
"sidecar shared by installed F16 is retained");
|
|
|
|
(void) installer.start("demo_slow", "", "", "", "", false);
|
|
(void) wait_for(installer, "demo_slow", "running");
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(75));
|
|
const auto stopping = installer.stop("demo_slow");
|
|
require(stopping.find("\"state\":\"cancelling\"") != std::string::npos,
|
|
"native download accepts cancellation");
|
|
(void) wait_for(installer, "demo_slow", "cancelled");
|
|
require(!std::filesystem::exists(root / "models" / "Slow" / "model.gguf"),
|
|
"cancelled package is never published to its final path");
|
|
|
|
// A sibling queued behind an active install must be cancellable while
|
|
// it waits for the shared target-directory lock.
|
|
(void) installer.start("demo_slow", "", "", "", "", false);
|
|
(void) wait_for(installer, "demo_slow", "running");
|
|
(void) installer.start("demo_slow_sibling", "", "", "", "", false);
|
|
(void) wait_for(installer, "demo_slow_sibling", "queued");
|
|
const auto sibling_stopping = installer.stop("demo_slow_sibling");
|
|
require(sibling_stopping.find("\"state\":\"cancelling\"") != std::string::npos,
|
|
"queued sibling accepts cancellation while another package is active");
|
|
(void) wait_for(installer, "demo_slow_sibling", "cancelled");
|
|
require(installer.status("demo_slow").find("\"state\":\"running\"") != std::string::npos,
|
|
"cancelling a queued sibling does not stop the active package");
|
|
(void) installer.stop("demo_slow");
|
|
(void) wait_for(installer, "demo_slow", "cancelled");
|
|
require(!std::filesystem::exists(root / "models" / "Slow" / "sibling.gguf"),
|
|
"cancelled queued sibling is never published");
|
|
|
|
const auto partial = root / "models" / ".Demo.abandoned";
|
|
write(partial / "model.tmp", "partial");
|
|
const auto cleaned = installer.clean_partial("demo_q8");
|
|
require(cleaned.find("\"cleaned\":true") != std::string::npos, "partial cleanup succeeds");
|
|
require(!std::filesystem::exists(partial), "partial staging directory is removed");
|
|
|
|
const auto initial = installer.package_sizes();
|
|
require(initial.find("\"state\":\"running\"") != std::string::npos,
|
|
"remote inventory starts in the background");
|
|
bool inventory_complete = false;
|
|
for (int attempt = 0; attempt < 200; ++attempt) {
|
|
const auto sizes = installer.package_sizes();
|
|
if (sizes.find("\"state\":\"complete\"") != std::string::npos) {
|
|
require(sizes.find("\"id\":\"demo_f16\"") != std::string::npos,
|
|
"inventory includes package ids");
|
|
require(sizes.find("\"installed\":true") != std::string::npos,
|
|
"inventory reports installed packages");
|
|
require(sizes.find("\"version_state\":\"up_to_date\"") != std::string::npos,
|
|
"per-file ETag version check reports up to date");
|
|
inventory_complete = true;
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
require(inventory_complete, "remote package inventory completes");
|
|
|
|
bool legacy_missing = false;
|
|
try { (void) installer.start("demo_q8", "checkpoint.bin", "", "", "", false); }
|
|
catch (const std::runtime_error & error) {
|
|
legacy_missing = std::string(error.what()).find("model_manager_deprecated.py") != std::string::npos;
|
|
}
|
|
require(legacy_missing, "explicit converter inputs retain the legacy converter boundary");
|
|
require(fixture.get() == 0, "local HTTP fixture completed normally");
|
|
} catch (...) {
|
|
std::error_code error;
|
|
std::filesystem::remove_all(root, error);
|
|
throw;
|
|
}
|
|
std::error_code error;
|
|
std::filesystem::remove_all(root, error);
|
|
}
|
|
|
|
void test_large_inventory() {
|
|
const auto root = make_root();
|
|
std::string spec = R"({"family":"demo","packages":[)";
|
|
for (int i = 0; i < 500; ++i) {
|
|
if (i) spec += ',';
|
|
spec += "{\"id\":\"demo_" + std::to_string(i) + R"(","display_name":"Demo",
|
|
"format":"gguf","precision":"f32","target_directory":"Demo","files":[],
|
|
"download":{"kind":"huggingface_snapshot","repo":"org/repo"}})";
|
|
}
|
|
spec += "]}";
|
|
write(root / "model_specs" / "demo.json", spec);
|
|
try {
|
|
minitts::server::ModelInstaller installer(root, root / "models");
|
|
bool complete = false;
|
|
for (int i = 0; i < 200; ++i) {
|
|
const auto text = installer.package_sizes();
|
|
const auto response = engine::io::json::parse(text);
|
|
require(response.require("data").as_array().size() == 500,
|
|
"inventory larger than 64 KiB must retain every package");
|
|
require(text.size() > 65536, "fixture must exceed the old log-tail limit");
|
|
if (response.require("state").as_string() == "complete") {
|
|
complete = true;
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
require(complete, "large inventory completes");
|
|
} catch (...) {
|
|
std::filesystem::remove_all(root);
|
|
throw;
|
|
}
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
try {
|
|
test_native_package_info();
|
|
test_native_package_lifecycle();
|
|
test_large_inventory();
|
|
}
|
|
catch (const std::exception & error) {
|
|
std::cerr << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
std::cout << "server_model_installer_test passed\n";
|
|
return 0;
|
|
}
|