Merge main and address Docker review findings
This commit is contained in:
@@ -41,6 +41,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
### Fixed
|
||||
|
||||
- First-run source builds no longer stop after uv was successfully downloaded just because its installer failed during a later shell-profile step; app-private uv installs no longer touch shell profiles at all. (#1438) — thanks @AdrianoCahete!
|
||||
- ROCm Docker now installs and starts the backend with the same Python whose AMD torch build was validated, instead of launching a second CUDA-only environment and silently running on CPU. (#1274) — thanks @simmessa and @spicchio72!
|
||||
- An error whose text merely contained the digits 401 — a file path, a byte count, a job id — no longer tells you to fix your Hugging Face token. (#1427)
|
||||
- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446)
|
||||
|
||||
@@ -110,7 +110,7 @@ Verify the container sees the GPU:
|
||||
|
||||
```bash
|
||||
docker exec <container> python3 -c \
|
||||
"import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
|
||||
"import torch; ok = torch.cuda.is_available(); print(ok, torch.cuda.get_device_name(0) if ok else 'unavailable')"
|
||||
```
|
||||
|
||||
Use `omnivoice` for the `docker run` examples above. Docker Compose names the
|
||||
|
||||
+336
-51
@@ -3,7 +3,7 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -410,7 +410,7 @@ pub fn resolve_uv<R: tauri::Runtime>(
|
||||
log::info!("Using bundled uv at {}", p.display());
|
||||
return Ok(p);
|
||||
}
|
||||
if no_window(Command::new("uv").arg("--version")).output().is_ok() {
|
||||
if uv_is_usable(Path::new("uv")) {
|
||||
log::info!("Using system uv from PATH");
|
||||
return Ok(PathBuf::from("uv"));
|
||||
}
|
||||
@@ -427,11 +427,11 @@ pub fn resolve_uv<R: tauri::Runtime>(
|
||||
/// Windows: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/{version}/install.ps1 | iex"`
|
||||
///
|
||||
/// The installer handles platform detection, checksums, and extraction
|
||||
/// automatically. We control the install directory via `UV_INSTALL_DIR`.
|
||||
/// Idempotent: if the binary is already present, returns its path immediately.
|
||||
/// automatically. `UV_UNMANAGED_INSTALL` keeps this app-private tool out of
|
||||
/// the user's PATH and shell profiles on every platform.
|
||||
fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result<PathBuf> {
|
||||
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
if uv_bin.is_file() {
|
||||
if uv_is_usable(&uv_bin) {
|
||||
return Ok(uv_bin);
|
||||
}
|
||||
fs::create_dir_all(dest)?;
|
||||
@@ -439,28 +439,24 @@ fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result<PathBuf> {
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let status = Command::new("sh")
|
||||
.args([
|
||||
let output = configure_uv_installer(
|
||||
Command::new("sh").args([
|
||||
"-c",
|
||||
&format!(
|
||||
"curl -LsSf https://astral.sh/uv/{}/install.sh | sh -s -- --no-modify-path",
|
||||
"curl -LsSf https://astral.sh/uv/{}/install.sh | sh",
|
||||
UV_VERSION
|
||||
),
|
||||
])
|
||||
.env("UV_INSTALL_DIR", dest)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.status()
|
||||
.map_err(|e| io::Error::new(
|
||||
]),
|
||||
dest,
|
||||
)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("uv installer launch failed (is curl installed?): {}", e),
|
||||
))?;
|
||||
if !status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("uv installer exited with code {:?}", status.code()),
|
||||
));
|
||||
}
|
||||
)
|
||||
})?;
|
||||
return finish_uv_install(dest, &uv_bin, output);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -472,39 +468,328 @@ fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result<PathBuf> {
|
||||
// Windows: `CREATE_NO_WINDOW` so the uv installer's PowerShell doesn't
|
||||
// flash a console window during first-run bootstrap. stdout/stderr are
|
||||
// piped, so nothing is lost.
|
||||
let status = no_window(
|
||||
Command::new("powershell")
|
||||
.args(["-ExecutionPolicy", "ByPass", "-c", &script])
|
||||
.env("UV_INSTALL_DIR", dest)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped()),
|
||||
)
|
||||
.status()
|
||||
.map_err(|e| io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("uv PowerShell installer failed: {}", e),
|
||||
))?;
|
||||
if !status.success() {
|
||||
return Err(io::Error::new(
|
||||
let mut command = Command::new("powershell");
|
||||
command.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"ByPass",
|
||||
"-c",
|
||||
&script,
|
||||
]);
|
||||
configure_uv_installer(&mut command, dest);
|
||||
let output = no_window(&mut command).output().map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("uv installer exited with code {:?}", status.code()),
|
||||
));
|
||||
}
|
||||
format!("uv PowerShell installer failed: {}", e),
|
||||
)
|
||||
})?;
|
||||
return finish_uv_install(dest, &uv_bin, output);
|
||||
}
|
||||
|
||||
if uv_bin.is_file() {
|
||||
log::info!("uv installed successfully at {}", uv_bin.display());
|
||||
Ok(uv_bin)
|
||||
} else {
|
||||
let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
if alt.is_file() {
|
||||
fs::rename(&alt, &uv_bin)?;
|
||||
log::info!("uv moved from bin/ to {}", uv_bin.display());
|
||||
return Ok(uv_bin);
|
||||
#[allow(unreachable_code)]
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"unsupported uv install platform",
|
||||
))
|
||||
}
|
||||
|
||||
fn configure_uv_installer<'a>(command: &'a mut Command, dest: &Path) -> &'a mut Command {
|
||||
// The official unmanaged mode is designed for app-private/CI installs: it
|
||||
// selects the destination and disables PATH, profile, and self-update
|
||||
// mutations. Explicitly remove the legacy variable so a parent shell
|
||||
// cannot leave the installer in two conflicting modes.
|
||||
command
|
||||
.env_remove("UV_INSTALL_DIR")
|
||||
.env("UV_UNMANAGED_INSTALL", dest)
|
||||
.env("UV_NO_MODIFY_PATH", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
}
|
||||
|
||||
fn uv_is_usable(path: &Path) -> bool {
|
||||
no_window(
|
||||
Command::new(path)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null()),
|
||||
)
|
||||
.output()
|
||||
.map(|output| output.status.success() && uv_version_matches(&output.stdout))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn uv_version_matches(output: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(output) else {
|
||||
return false;
|
||||
};
|
||||
let mut fields = text.split_whitespace();
|
||||
fields.next() == Some("uv") && fields.next() == Some(UV_VERSION)
|
||||
}
|
||||
|
||||
fn finish_uv_install(dest: &Path, uv_bin: &Path, output: Output) -> io::Result<PathBuf> {
|
||||
finish_uv_install_with_probe(dest, uv_bin, output, uv_is_usable)
|
||||
}
|
||||
|
||||
fn finish_uv_install_with_probe<F>(
|
||||
dest: &Path,
|
||||
uv_bin: &Path,
|
||||
output: Output,
|
||||
is_usable: F,
|
||||
) -> io::Result<PathBuf>
|
||||
where
|
||||
F: Fn(&Path) -> bool,
|
||||
{
|
||||
let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
if !is_usable(uv_bin) && is_usable(&alt) {
|
||||
fs::rename(&alt, uv_bin).or_else(|_| fs::copy(&alt, uv_bin).map(|_| ()))?;
|
||||
}
|
||||
|
||||
// Some installer failures happen after extraction (for example while
|
||||
// editing a Windows shell profile). The installed executable is the real
|
||||
// postcondition: accepting a verified binary makes first run self-heal in
|
||||
// this process instead of requiring a restart. Never accept a partial or
|
||||
// corrupt file merely because it exists.
|
||||
if is_usable(uv_bin) {
|
||||
if output.status.success() {
|
||||
log::info!("uv installed successfully at {}", uv_bin.display());
|
||||
} else {
|
||||
log::warn!(
|
||||
"uv installer exited with {:?}, but the installed binary passed validation at {}",
|
||||
output.status.code(),
|
||||
uv_bin.display()
|
||||
);
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("uv binary not found at {} after installer completed", uv_bin.display()),
|
||||
))
|
||||
return Ok(uv_bin.to_path_buf());
|
||||
}
|
||||
|
||||
let detail = installer_output_detail(&output);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
if output.status.success() {
|
||||
format!(
|
||||
"uv installer completed but no usable binary was found at {}{}",
|
||||
uv_bin.display(),
|
||||
detail
|
||||
)
|
||||
} else {
|
||||
format!("uv installer exited with code {:?}{}", output.status.code(), detail)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn installer_output_detail(output: &Output) -> String {
|
||||
let bytes = if output.stderr.is_empty() {
|
||||
&output.stdout
|
||||
} else {
|
||||
&output.stderr
|
||||
};
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Some(home) = std::env::var_os(key).and_then(|value| value.into_string().ok()) {
|
||||
text = redact_home_prefix(&text, &home);
|
||||
}
|
||||
}
|
||||
let start = text
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(1999)
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0);
|
||||
format!(": {}", &text[start..])
|
||||
}
|
||||
|
||||
fn redact_home_prefix(text: &str, home: &str) -> String {
|
||||
if home.len() < 3 {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut redacted = text.replace(home, "~");
|
||||
let forward = home.replace('\\', "/");
|
||||
let backward = home.replace('/', "\\");
|
||||
if forward != home {
|
||||
redacted = redacted.replace(&forward, "~");
|
||||
}
|
||||
if backward != home {
|
||||
redacted = redacted.replace(&backward, "~");
|
||||
}
|
||||
redacted
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod uv_tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn installer_uses_app_private_unmanaged_mode() {
|
||||
let mut command = Command::new("installer");
|
||||
configure_uv_installer(&mut command, Path::new("private-tools"));
|
||||
let envs: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||
|
||||
assert_eq!(envs.get(OsStr::new("UV_INSTALL_DIR")), Some(&None));
|
||||
assert_eq!(
|
||||
envs.get(OsStr::new("UV_UNMANAGED_INSTALL")).and_then(|value| *value),
|
||||
Some(OsStr::new("private-tools"))
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get(OsStr::new("UV_NO_MODIFY_PATH")).and_then(|value| *value),
|
||||
Some(OsStr::new("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_version_probe_requires_the_pinned_version() {
|
||||
assert!(uv_version_matches(
|
||||
format!("uv {} (build-id)\n", UV_VERSION).as_bytes()
|
||||
));
|
||||
assert!(!uv_version_matches(b"uv 0.10.0 (older)\n"));
|
||||
assert!(!uv_version_matches(b"not-uv 0.11.7\n"));
|
||||
assert!(!uv_version_matches(b"uv\n"));
|
||||
assert!(!uv_version_matches(&[0xff, 0xfe]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_error_includes_captured_stderr() {
|
||||
let output = Output {
|
||||
status: failure_status(),
|
||||
stdout: Vec::new(),
|
||||
stderr: b"profile update denied".to_vec(),
|
||||
};
|
||||
assert_eq!(installer_output_detail(&output), ": profile update denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_error_redacts_unix_and_windows_home_paths() {
|
||||
assert_eq!(
|
||||
redact_home_prefix(
|
||||
"installed into /Users/alice/.local/bin",
|
||||
"/Users/alice"
|
||||
),
|
||||
"installed into ~/.local/bin"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_home_prefix(
|
||||
r"installed into C:\Users\alice\.local\bin",
|
||||
r"C:\Users\alice"
|
||||
),
|
||||
r"installed into ~\.local\bin"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_home_prefix(
|
||||
"installed into C:/Users/alice/.local/bin",
|
||||
r"C:\Users\alice"
|
||||
),
|
||||
"installed into ~/.local/bin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_exit_one_is_accepted_when_downloaded_uv_is_usable() {
|
||||
let dest = Path::new("private-tools");
|
||||
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
let output = Output {
|
||||
status: failure_status(),
|
||||
stdout: Vec::new(),
|
||||
stderr: b"later installer step failed".to_vec(),
|
||||
};
|
||||
|
||||
let result = finish_uv_install_with_probe(dest, &uv_bin, output, |candidate| {
|
||||
candidate == uv_bin
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap(), uv_bin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_installer_without_usable_uv_is_rejected() {
|
||||
let dest = Path::new("private-tools");
|
||||
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
let output = Output {
|
||||
status: success_status(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
|
||||
let error = finish_uv_install_with_probe(dest, &uv_bin, output, |_| false)
|
||||
.expect_err("installer success is insufficient without a usable binary");
|
||||
|
||||
assert!(error.to_string().contains("no usable binary was found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_installer_with_unusable_uv_reports_captured_error() {
|
||||
let dest = Path::new("private-tools");
|
||||
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
let output = Output {
|
||||
status: failure_status(),
|
||||
stdout: Vec::new(),
|
||||
stderr: b"downloaded executable was corrupt".to_vec(),
|
||||
};
|
||||
|
||||
let error = finish_uv_install_with_probe(dest, &uv_bin, output, |_| false)
|
||||
.expect_err("an unusable download must not be accepted");
|
||||
|
||||
assert!(error.to_string().contains("downloaded executable was corrupt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_legacy_bin_location_is_relocated() {
|
||||
let unique = format!(
|
||||
"voicestudio-uv-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let dest = std::env::temp_dir().join(unique);
|
||||
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
let legacy = dest
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) { "uv.exe" } else { "uv" });
|
||||
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
fs::write(&legacy, b"verified test executable").unwrap();
|
||||
let output = Output {
|
||||
status: success_status(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
|
||||
let result = finish_uv_install_with_probe(&dest, &uv_bin, output, |candidate| {
|
||||
candidate.is_file()
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap(), uv_bin);
|
||||
assert!(uv_bin.is_file());
|
||||
assert!(!legacy.exists());
|
||||
fs::remove_dir_all(dest).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn success_status() -> std::process::ExitStatus {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
std::process::ExitStatus::from_raw(0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn success_status() -> std::process::ExitStatus {
|
||||
use std::os::windows::process::ExitStatusExt;
|
||||
std::process::ExitStatus::from_raw(0)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn failure_status() -> std::process::ExitStatus {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
std::process::ExitStatus::from_raw(1 << 8)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn failure_status() -> std::process::ExitStatus {
|
||||
use std::os::windows::process::ExitStatusExt;
|
||||
std::process::ExitStatus::from_raw(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +112,18 @@ def test_docker_install_and_runtime_use_the_guarded_python():
|
||||
"""#1274: ROCm's `python3` had HIP torch, while `--system` installed and
|
||||
bare `uvicorn` launched through `/usr/bin/python` with CUDA torch."""
|
||||
text = _DOCKERFILE.read_text(encoding="utf-8")
|
||||
assert '--python "$(command -v python3)"' in text
|
||||
assert "uv pip install --system" not in text
|
||||
install_start = text.index("RUN uv pip install")
|
||||
install_end = text.index("\n\n", install_start)
|
||||
install = text[install_start:install_end]
|
||||
assert '--python "$(command -v python3)"' in install
|
||||
assert "--system" not in install
|
||||
assert 'ENTRYPOINT ["python3", "-m", "uvicorn"' in text
|
||||
|
||||
|
||||
def test_docker_docs_do_not_assume_the_run_name_for_compose():
|
||||
docs = (_ROOT / "docs" / "install" / "docker.md").read_text(encoding="utf-8")
|
||||
assert "docker exec <container> python3" in docs
|
||||
assert "torch.cuda.get_device_name(0) if ok else 'unavailable'" in docs
|
||||
for compose_name in ("omnivoice-studio", "omnivoice-studio-gpu", "omnivoice-studio-rocm"):
|
||||
assert compose_name in docs
|
||||
|
||||
|
||||
Reference in New Issue
Block a user