unsloth/studio/src-tauri/src/diagnostics/report.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

609 lines
22 KiB
Rust
Raw Blame History

use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::time::UNIX_EPOCH;
use super::phase_log::{collect_phase_groups, path_has_symlink};
use super::redaction::{redact_text, RedactionReport};
use super::state::{AttemptSummary, CappedStrings, DiagnosticsSnapshot, FrontendSupportSnapshot};
use super::{
cap_string, logs_dir, now_ms, studio_dir, valid_utf8_boundary, FOOTER_BUDGET_BYTES,
MAX_PHASE_LINE_BYTES, REPORT_MAX_BYTES, SCHEMA_VERSION, TAIL_MAX_BYTES, TAIL_MAX_LINES,
};
#[derive(Clone, Debug)]
pub(crate) struct BackendHealthSection {
pub(crate) port: u16,
pub(crate) fields: Vec<(String, String)>,
pub(crate) warning: Option<String>,
}
pub(crate) fn render_report(
snapshot: DiagnosticsSnapshot,
frontend: FrontendSupportSnapshot,
health: Option<BackendHealthSection>,
) -> String {
let mut warnings = Vec::new();
let mut raw = String::new();
raw.push_str("Unsloth Studio Support Diagnostics\n");
raw.push_str(&format!("diag_report_schema={SCHEMA_VERSION}\n"));
raw.push_str(&format!("created_at_ms={}\n", now_ms()));
raw.push_str(&format!("app_version={}\n", env!("CARGO_PKG_VERSION")));
raw.push_str(&format!("os={}\n", std::env::consts::OS));
raw.push_str(&format!("arch={}\n", std::env::consts::ARCH));
raw.push_str(&format!("debug_build={}\n", cfg!(debug_assertions)));
raw.push_str(&format!(
"appimage={}\n",
std::env::var_os("APPIMAGE").is_some()
));
raw.push_str(&format!(
"cpu_logical_count={}\n",
std::thread::available_parallelism()
.map(|n| n.get().to_string())
.unwrap_or_else(|_| "unavailable".to_string())
));
raw.push_str(&format!("studio_dir={}\n", studio_dir().display()));
raw.push_str(&format!("logs_dir={}\n", logs_dir().display()));
raw.push_str(&format!("app_session_id={}\n", snapshot.app_session_id));
raw.push_str(&format!(
"app_started_at_ms={}\n",
snapshot.app_started_at_ms
));
raw.push_str(
"disk_budget=5MiB x 3 segments x 5 groups x 4 kinds ~= 300MiB plus tauri.log/.1\n",
);
append_frontend_section(&mut raw, &frontend);
append_preflight_section(&mut raw, &snapshot);
append_flow_section(&mut raw, &snapshot);
append_backend_section(&mut raw, &snapshot, health.as_ref());
append_device_signals_section(&mut raw, &snapshot, health.as_ref());
append_log_sections(&mut raw, &snapshot, &mut warnings);
if let Some(health) = &health {
if let Some(warning) = &health.warning {
warnings.push(format!("backend /api/health unavailable: {warning}"));
}
}
let mut redaction = RedactionReport::default();
let redacted_body = redact_text(&raw, &mut redaction);
// Redact collection warnings/footer too. Some warnings include raw local paths
// from failed log reads, so the footer must not be appended after redaction.
let footer_for_count = render_footer(&warnings, &redaction);
let _ = redact_text(&footer_for_count, &mut redaction);
let footer = redact_text(
&render_footer(&warnings, &redaction),
&mut RedactionReport::default(),
);
enforce_report_limit(redacted_body, footer)
}
fn append_frontend_section(out: &mut String, frontend: &FrontendSupportSnapshot) {
out.push_str("\n== Trigger/UI state ==\n");
append_opt(out, "status", frontend.status.as_deref());
append_opt(out, "error", frontend.error.as_deref());
append_opt(
out,
"current_step_index",
frontend
.current_step_index
.map(|v| v.to_string())
.as_deref(),
);
append_opt(out, "progress_detail", frontend.progress_detail.as_deref());
append_opt(out, "flow", frontend.flow.as_deref());
append_opt(out, "update_phase", frontend.update_phase.as_deref());
let update_progress = frontend.update_progress.as_ref().map(selected_json_value);
append_opt(out, "update_progress", update_progress.as_deref());
if let Some(packages) = &frontend.elevation_packages {
out.push_str(&format!("elevation_packages={}\n", packages.join(", ")));
}
out.push_str("last_ui_log_tail:\n");
match &frontend.last_ui_log_lines {
Some(lines) if !lines.is_empty() => {
for line in tail_lines(lines, TAIL_MAX_LINES) {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
_ => out.push_str(" unavailable\n"),
}
}
fn append_preflight_section(out: &mut String, snapshot: &DiagnosticsSnapshot) {
out.push_str("\n== Preflight/install state ==\n");
match &snapshot.latest_preflight {
Some(preflight) => {
out.push_str("source=live-state\n");
out.push_str(&format!("recorded_at_ms={}\n", preflight.recorded_at_ms));
out.push_str(&format!("disposition={}\n", preflight.disposition));
append_opt(out, "reason", preflight.reason.as_deref());
append_opt(
out,
"port",
preflight.port.map(|p| p.to_string()).as_deref(),
);
out.push_str(&format!("can_auto_repair={}\n", preflight.can_auto_repair));
if let Some(path) = &preflight.managed_bin {
out.push_str(&format!("managed_bin={}\n", path.display()));
out.push_str(&format!("managed_bin_exists={}\n", path.exists()));
} else {
out.push_str("managed_bin=unavailable\n");
}
}
None => out.push_str("source=live-state unavailable\n"),
}
}
fn append_flow_section(out: &mut String, snapshot: &DiagnosticsSnapshot) {
out.push_str("\n== Flow summaries ==\n");
append_attempt_summary(out, "install", snapshot.install.as_ref(), "live-state");
if !snapshot.install_history.is_empty() {
out.push_str("install_history:\n");
for install in snapshot.install_history.iter().rev().take(4).rev() {
append_attempt_summary(
out,
" previous_install",
Some(install),
"live-state-history",
);
}
}
append_attempt_summary(out, "update", snapshot.update.as_ref(), "live-state");
out.push_str("repair_groups:\n");
if snapshot.repair_groups.is_empty() {
out.push_str(" unavailable\n");
} else {
for group in snapshot.repair_groups.iter().rev().take(5).rev() {
out.push_str(&format!(
" repair_group_id={} started_at_ms={} ended_at_ms={} final_status={} last_error={}\n",
group.repair_group_id,
group.started_at_ms,
opt_u64(group.ended_at_ms),
group.final_status.as_deref().unwrap_or("unavailable"),
group.last_error.as_deref().unwrap_or("unavailable")
));
for child in &group.children {
append_attempt_summary(out, " child", Some(child), "live-state");
}
}
}
}
fn append_attempt_summary(
out: &mut String,
label: &str,
attempt: Option<&AttemptSummary>,
source: &str,
) {
match attempt {
Some(attempt) => {
out.push_str(&format!(
"{label}: source={source} flow={} attempt_id={} group_id={} child_type={} started_at_ms={} ended_at_ms={} exit_status={} intentional_stop={} last_error={} log_segments={}\n",
attempt.flow,
attempt.attempt_id,
attempt.repair_group_id.as_deref().unwrap_or("none"),
attempt.child_type.as_deref().unwrap_or("none"),
attempt.started_at_ms,
opt_u64(attempt.ended_at_ms),
attempt.exit_status.as_deref().unwrap_or("unavailable"),
attempt.intentional_stop,
attempt.last_error.as_deref().unwrap_or("unavailable"),
attempt
.log_segments
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(",")
));
if !attempt.elevation_packages.is_empty() {
out.push_str(&format!(
"{label}.elevation_packages={}\n",
attempt.elevation_packages.join(", ")
));
}
append_capped(out, &format!("{label}.steps"), &attempt.steps);
append_capped(
out,
&format!("{label}.progress_details"),
&attempt.progress_details,
);
append_capped(out, &format!("{label}.diag_markers"), &attempt.diag_markers);
}
None => out.push_str(&format!("{label}: unavailable\n")),
}
}
fn append_backend_section(
out: &mut String,
snapshot: &DiagnosticsSnapshot,
health: Option<&BackendHealthSection>,
) {
out.push_str("\n== Backend/auth/watchdog ==\n");
match &snapshot.backend {
Some(backend) => {
out.push_str("source=live-state\n");
out.push_str(&format!("backend_kind={}\n", backend.backend_kind));
append_opt(out, "session_id", backend.session_id.as_deref());
append_opt(
out,
"requested_port",
backend.requested_port.map(|p| p.to_string()).as_deref(),
);
append_opt(
out,
"reported_port",
backend.reported_port.map(|p| p.to_string()).as_deref(),
);
append_opt(
out,
"generation",
backend.generation.map(|g| g.to_string()).as_deref(),
);
append_opt(
out,
"started_at_ms",
backend.started_at_ms.map(|t| t.to_string()).as_deref(),
);
append_opt(
out,
"ended_at_ms",
backend.ended_at_ms.map(|t| t.to_string()).as_deref(),
);
append_opt(out, "exit_status", backend.exit_status.as_deref());
out.push_str(&format!("intentional_stop={}\n", backend.intentional_stop));
append_opt(out, "terminal_reason", backend.terminal_reason.as_deref());
append_opt(out, "last_error", backend.last_error.as_deref());
if backend.backend_kind == "attached_external" {
out.push_str("managed_process_logs=not_owned_by_tauri\n");
}
}
None => out.push_str("backend=unavailable backend_kind=unknown\n"),
}
match &snapshot.auth {
Some(auth) => {
out.push_str(&format!(
"auth_failure: failed_at_ms={} stage={} port={} message={}\n",
auth.failed_at_ms,
auth.stage,
auth.port
.map(|p| p.to_string())
.unwrap_or_else(|| "unavailable".to_string()),
auth.message
));
}
None => out.push_str("auth_failure=unavailable\n"),
}
out.push_str("runtime_health:\n");
match health {
Some(health) => {
out.push_str(&format!(" port={}\n", health.port));
if health.fields.is_empty() {
out.push_str(" selected_fields=unavailable\n");
} else {
for (key, value) in &health.fields {
out.push_str(&format!(" {key}={value}\n"));
}
}
if let Some(warning) = &health.warning {
out.push_str(&format!(" warning={warning}\n"));
}
}
None => out.push_str(" unavailable: no known backend port\n"),
}
}
fn append_device_signals_section(
out: &mut String,
snapshot: &DiagnosticsSnapshot,
health: Option<&BackendHealthSection>,
) {
out.push_str("\n== Lightweight device signals ==\n");
out.push_str("scope=setup/runtime signals, not full hardware inventory\n");
out.push_str(&format!(
"os={} arch={}\n",
std::env::consts::OS,
std::env::consts::ARCH
));
out.push_str("installer_diag_markers:\n");
let mut any = false;
if let Some(install) = &snapshot.install {
append_marker_tail(out, "install", install);
any |= !install.diag_markers.items.is_empty();
}
if let Some(update) = &snapshot.update {
append_marker_tail(out, "update", update);
any |= !update.diag_markers.items.is_empty();
}
for group in &snapshot.repair_groups {
for child in &group.children {
append_marker_tail(out, "repair_child", child);
any |= !child.diag_markers.items.is_empty();
}
}
if !any {
out.push_str(" unavailable\n");
}
if let Some(health) = health {
out.push_str("runtime_health_selected_fields:\n");
for (key, value) in &health.fields {
if matches!(
key.as_str(),
"version"
| "device_type"
| "chat_only"
| "desktop_protocol_version"
| "desktop_manageability_version"
| "supports_api_only"
| "supports_desktop_auth"
| "supports_desktop_backend_ownership"
) {
out.push_str(&format!(" {key}={value}\n"));
}
}
}
}
fn append_log_sections(
out: &mut String,
snapshot: &DiagnosticsSnapshot,
warnings: &mut Vec<String>,
) {
out.push_str("\n== Log tails ==\n");
for name in ["tauri.log", "tauri.log.1"] {
let path = studio_dir().join(name);
append_tail_section(out, &path, name, "local-app-log", warnings);
}
let phase_groups = collect_phase_groups(snapshot, warnings);
if phase_groups.is_empty() {
out.push_str("phase_logs=unavailable\n");
}
for group in phase_groups {
out.push_str(&format!(
"\n-- phase_log_group label={} source={} --\n",
group.label, group.source
));
if let Some(note) = &group.note {
out.push_str(&format!("note={}\n", note));
}
for path in group.paths {
append_tail_section(out, &path, &group.label, &group.source, warnings);
}
}
}
fn append_tail_section(
out: &mut String,
path: &Path,
label: &str,
source: &str,
warnings: &mut Vec<String>,
) {
match read_tail(path, TAIL_MAX_LINES, TAIL_MAX_BYTES) {
Ok(tail) => {
out.push_str(&format!(
"file={} label={} source={} size_bytes={} mtime_ms={} included_bytes={} included_lines={} truncated={}\n",
path.display(),
label,
source,
tail.size_bytes,
opt_u64(tail.mtime_ms),
tail.included_bytes,
tail.included_lines,
tail.truncated
));
out.push_str("```text\n");
out.push_str(&tail.text);
if !tail.text.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n");
}
Err(error) => {
out.push_str(&format!(
"file={} label={} source={} unavailable={}\n",
path.display(),
label,
source,
error
));
warnings.push(format!("{} unavailable: {}", path.display(), error));
}
}
}
#[derive(Debug)]
struct TailRead {
text: String,
size_bytes: u64,
mtime_ms: Option<u64>,
included_bytes: usize,
included_lines: usize,
truncated: bool,
}
fn read_tail(path: &Path, max_lines: usize, max_bytes: usize) -> Result<TailRead, String> {
if path_has_symlink(path) {
return Err("refusing to read symlink".to_string());
}
let metadata = fs::metadata(path).map_err(|e| e.to_string())?;
let size = metadata.len();
let mut file = File::open(path).map_err(|e| e.to_string())?;
let start = size.saturating_sub(max_bytes as u64);
file.seek(SeekFrom::Start(start))
.map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
file.take(max_bytes as u64)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
let mut text = String::from_utf8_lossy(&bytes).into_owned();
let mut truncated = start > 0;
if truncated {
text = format!("[tail truncated to last {max_bytes} bytes]\n{text}");
}
let mut lines: Vec<String> = text
.lines()
.map(|line| {
if line.len() > MAX_PHASE_LINE_BYTES {
let boundary = valid_utf8_boundary(line, MAX_PHASE_LINE_BYTES);
format!("{} [line truncated]", &line[..boundary])
} else {
line.to_string()
}
})
.collect();
if lines.len() > max_lines {
truncated = true;
let omitted = lines.len() - max_lines;
lines = lines.split_off(omitted);
lines.insert(0, format!("[tail truncated to last {max_lines} lines]"));
}
let included_lines = lines.len();
let mut text = lines.join("\n");
if !text.is_empty() {
text.push('\n');
}
Ok(TailRead {
text,
size_bytes: size,
mtime_ms: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64),
included_bytes: bytes.len(),
included_lines,
truncated,
})
}
fn render_footer(warnings: &[String], redaction: &RedactionReport) -> String {
let mut footer = String::new();
footer.push_str("\n== Collection warnings ==\n");
if warnings.is_empty() {
footer.push_str("none\n");
} else {
for warning in warnings.iter().take(100) {
footer.push_str("- ");
footer.push_str(warning);
footer.push('\n');
}
if warnings.len() > 100 {
footer.push_str(&format!(
"- omitted {} additional warnings\n",
warnings.len() - 100
));
}
}
footer.push_str("\n== Redaction/omission summary ==\n");
footer.push_str(&format!(
"redaction_replacements={}\n",
redaction.replacements
));
footer.push_str("redaction_scope=ANSI, private keys, URL credentials, auth headers, cookies, token patterns, assignment-style secrets, studio/home paths, emails\n");
footer.push_str(
"report_bounds=log sections <=1000 lines/200KiB each; total clipboard text <=1MiB\n",
);
footer.push_str("known_v1_gap=elevated apt helper may buffer subprocess output before diagnostics caps it\n");
footer.push_str("known_v1_gap=normal install elevation resume is linked in same-run state/report history; disk fallback conservatively includes recent install attempts but has no explicit install_group_id in V1\n");
footer
}
fn enforce_report_limit(body: String, footer: String) -> String {
if body.len() + footer.len() <= REPORT_MAX_BYTES {
return format!("{body}{footer}");
}
let notice = "\n[report truncated to fit clipboard budget; footer preserved]\n";
let footer_budget = footer.len().min(FOOTER_BUDGET_BYTES).max(footer.len());
let budget = REPORT_MAX_BYTES
.saturating_sub(footer_budget)
.saturating_sub(notice.len());
let boundary = valid_utf8_boundary(&body, budget);
format!("{}{}{}", &body[..boundary], notice, footer)
}
fn append_opt(out: &mut String, key: &str, value: Option<&str>) {
out.push_str(key);
out.push('=');
out.push_str(value.unwrap_or("unavailable"));
out.push('\n');
}
fn append_capped(out: &mut String, label: &str, values: &CappedStrings) {
out.push_str(&format!("{label}: omitted={}\n", values.omitted));
if values.items.is_empty() {
out.push_str(" unavailable\n");
} else {
for value in values.items.iter().rev().take(20).rev() {
out.push_str(" ");
out.push_str(value);
out.push('\n');
}
}
}
fn append_marker_tail(out: &mut String, label: &str, attempt: &AttemptSummary) {
for marker in attempt.diag_markers.items.iter().rev().take(10).rev() {
out.push_str(&format!(" {label}: {marker}\n"));
}
}
fn tail_lines(lines: &[String], max_lines: usize) -> &[String] {
if lines.len() > max_lines {
&lines[lines.len() - max_lines..]
} else {
lines
}
}
fn opt_u64(value: Option<u64>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "unavailable".to_string())
}
pub(crate) fn selected_json_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => cap_string(s, 256),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "null".to_string(),
_ => "<non-scalar>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_redacts_footer_collection_warning_paths() {
let snapshot = DiagnosticsSnapshot::default();
let report = render_report(snapshot, FrontendSupportSnapshot::default(), None);
let studio = studio_dir().display().to_string();
assert!(!report.contains(&studio));
assert!(report.contains("<studio_home>"));
}
#[test]
fn tail_handles_missing_and_non_utf8() {
let missing =
std::env::temp_dir().join(format!("unsloth-missing-tail-{}", std::process::id()));
assert!(read_tail(&missing, 10, 100).is_err());
let path =
std::env::temp_dir().join(format!("unsloth-nonutf8-tail-{}", std::process::id()));
fs::write(&path, [0xff, b'a', b'\n', b'b']).unwrap();
let tail = read_tail(&path, 10, 100).unwrap();
assert!(tail.text.contains('<27>'));
let _ = fs::remove_file(path);
}
}