unsloth/studio/src-tauri/src/diagnostics/mod.rs
Wasim Yousef Said 0a54d001ec
Harden Tauri release flow (#5341)
* Harden Tauri backend preflight and startup

Require managed Studio root IDs to match before attaching to existing backends, close the concurrent backend-start window, and tighten frontend Tauri detection to Tauri-specific signals.

* Add Tauri backend manageability guards

Gate desktop backend compatibility on explicit manageability fields, add external-conflict handling for unsafe backend states, and protect update/repair paths from mutating active non-owned Studio backends. Track Tauri-owned backends with local owner metadata for verified orphan cleanup only.

* Split Tauri preflight probes into modules

Move preflight types, version checks, managed install probing, and backend probing into focused submodules while preserving behavior and keeping implementation files under the release-readiness size target.

* Use desktop-specific Tauri updater channel

Point the desktop updater at a same-repo desktop-latest manifest and publish that channel from non-draft desktop releases after validating the Tauri-generated latest.json.

* Add Linux desktop update policy

* Add owned backend lifecycle guards

* Adopt verified desktop-owned backends

* Validate desktop backend readiness

* Trim Tauri release hardening code

* Require desktop backend 2026.5.3

* Handle desktop backend edge cases

* Fail stalled desktop backend startup

* Fix desktop update edge cases

* Avoid secret-gating adopted watchdog

* Fix desktop update comparison guards

* Automate desktop release versioning

* Serialize desktop release workflow

* tests: follow preflight.rs split into preflight/{backend,managed,types,version}.rs

PR #5341 splits studio/src-tauri/src/preflight.rs into a directory of
submodules. The cmd.env_remove("UNSLOTH_STUDIO_HOME") + STUDIO_HOME
calls now live in preflight/managed.rs instead of preflight.rs, so
test_tauri_preflight_scrubs_studio_home_env counted zero matches in
the old single-file location and failed with "assert 0 >= 2".

Read whichever shape is on disk: preflight.rs at the old path plus
every *.rs under preflight/ (current PR has 2 occurrences in
preflight/managed.rs). The guard intent is unchanged: at least 2
env_remove calls covering run_cli_probe and probe_cli_capability,
plus the single commands.rs scrub in check_install_status. Verified
locally: pytest tests/test_studio_install_workspace_guard.py::test_tauri_preflight_scrubs_studio_home_env passes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Avoid browser Tauri hostname detection

* Restore shutdown flag after failed stop

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-12 20:30:20 -07:00

171 lines
5.4 KiB
Rust

mod phase_log;
mod redaction;
mod report;
mod state;
use crate::process::BackendState;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
pub use phase_log::append_phase_line;
#[cfg(target_os = "linux")]
pub use phase_log::PhaseLogHandle;
pub use state::{
begin_adopted_backend_session, begin_backend_session, begin_install_attempt,
begin_repair_child, begin_repair_group, begin_update_attempt, finish_attempt,
finish_repair_group, new_diagnostics_state, record_attached_external_backend,
record_auth_failure, record_backend_exit, record_backend_intentional_stop, record_backend_port,
record_backend_start_failure, record_backend_watchdog, record_diag_marker,
record_elevation_packages, record_preflight, record_progress, record_step, AttemptLog,
BackendLog, DiagnosticsState, FrontendSupportSnapshot,
};
pub const SCHEMA_VERSION: u32 = 1;
pub const PHASE_LOG_SEGMENT_MAX_BYTES: u64 = 5 * 1024 * 1024;
pub const PHASE_LOG_MAX_SEGMENTS_PER_GROUP: usize = 3;
#[allow(dead_code)]
pub const PHASE_LOG_KEEP_GROUPS_PER_KIND: usize = 5;
pub const TAIL_MAX_LINES: usize = 1000;
pub const TAIL_MAX_BYTES: usize = 200 * 1024;
pub const REPORT_MAX_BYTES: usize = 1024 * 1024;
pub(crate) const MAX_STATE_ITEMS: usize = 200;
pub(crate) const MAX_PHASE_LINE_BYTES: usize = 16 * 1024;
pub(crate) const FOOTER_BUDGET_BYTES: usize = 8 * 1024;
static ID_COUNTER: AtomicU64 = AtomicU64::new(1);
pub(crate) fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_millis() as u64
}
pub(crate) fn new_id(prefix: &str) -> String {
let n = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{}-{}", sanitize_component(prefix), now_ms(), n)
}
pub fn studio_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".unsloth")
.join("studio")
}
pub fn logs_dir() -> PathBuf {
studio_dir().join("logs")
}
pub(crate) fn cap_string(value: &str, max: usize) -> String {
if value.len() <= max {
return value.to_string();
}
let boundary = valid_utf8_boundary(value, max);
format!("{} [truncated]", &value[..boundary])
}
pub(crate) fn valid_utf8_boundary(s: &str, max: usize) -> usize {
if max >= s.len() {
return s.len();
}
let mut index = max;
while index > 0 && !s.is_char_boundary(index) {
index -= 1;
}
index
}
pub(crate) fn sanitize_component(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
ch
} else {
'_'
}
})
.collect()
}
#[tauri::command]
pub async fn collect_support_diagnostics(
_app: AppHandle,
diagnostics: tauri::State<'_, DiagnosticsState>,
backend: tauri::State<'_, BackendState>,
snapshot: FrontendSupportSnapshot,
) -> Result<String, String> {
let diagnostics_snapshot = state::clone_snapshot(diagnostics.inner());
let backend_port = backend.lock().ok().and_then(|proc| proc.port).or_else(|| {
diagnostics_snapshot
.backend
.as_ref()
.and_then(|backend| backend.reported_port.or(backend.requested_port))
});
let health = match backend_port {
Some(port) => Some(collect_backend_health(port).await),
None => None,
};
match tokio::task::spawn_blocking(move || {
report::render_report(diagnostics_snapshot, snapshot, health)
})
.await
{
Ok(report) => Ok(report),
Err(error) => Ok(format!(
"DEGRADED RUST DIAGNOSTICS\ncollection_warning=report task failed: {error}\n"
)),
}
}
async fn collect_backend_health(port: u16) -> report::BackendHealthSection {
let mut section = report::BackendHealthSection {
port,
fields: Vec::new(),
warning: None,
};
let client = match reqwest::Client::builder()
.timeout(Duration::from_millis(750))
.build()
{
Ok(client) => client,
Err(error) => {
section.warning = Some(format!("health client unavailable: {error}"));
return section;
}
};
let url = format!("http://127.0.0.1:{port}/api/health");
match client.get(url).send().await {
Ok(response) => match response.json::<serde_json::Value>().await {
Ok(json) => {
for key in [
"status",
"service",
"version",
"device_type",
"chat_only",
"desktop_protocol_version",
"desktop_manageability_version",
"supports_api_only",
"supports_desktop_auth",
"supports_desktop_backend_ownership",
] {
if let Some(value) = json.get(key) {
section
.fields
.push((key.to_string(), report::selected_json_value(value)));
}
}
}
Err(error) => section.warning = Some(format!("health json unavailable: {error}")),
},
Err(error) => section.warning = Some(format!("health request unavailable: {error}")),
}
section
}