feat: install flow improvements (#6669)

* feat: better error handling + copy details btn for info

* refactor: clean up error handling into diagnostics

* feat: extra info for failure states + queuing properly

* fix: cleanup

* fix: lint

* fix: fmt

* fix: cleanup

* fix: cleanup

* fix: lint

* feat: use bon builder
This commit is contained in:
Calum H.
2026-07-10 11:54:56 +00:00
committed by GitHub
parent 57a977f7f3
commit c07727aa49
44 changed files with 2572 additions and 480 deletions
+667
View File
@@ -0,0 +1,667 @@
use super::model::{
InstallCleanup, InstallInterruptReason, InstallJobEvent,
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallProgress,
};
use super::store;
use crate::state::{ModrinthCredentials, State};
use regex::{Captures, Regex};
use sqlx::Row;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const INSTALL_SUPPORT_LOG_TAIL_BYTES: u64 = 128 * 1024;
pub async fn build_job_support_details(
job: &store::InstallJobRecord,
state: &State,
) -> crate::Result<String> {
let snapshot = job.snapshot();
let mut details = String::new();
let title = snapshot
.display
.as_ref()
.map(|display| display.title.as_str())
.unwrap_or("Unknown");
let _ = writeln!(details, "Install report: {title}");
let _ =
writeln!(details, "Result: {}", result_summary(&snapshot, &job.state));
let _ = writeln!(details, "Job ID: {}", snapshot.job_id);
let _ = writeln!(details, "Request: {}", json_string(&snapshot.kind));
let _ = writeln!(details, "Status: {}", json_string(&snapshot.status));
let _ = writeln!(details, "Current phase: {}", phase_label(snapshot.phase));
if let Some(progress) = &snapshot.progress {
let _ = writeln!(
details,
"Current progress: {}",
progress_summary(progress)
);
}
write_environment_details(&mut details);
write_timeline(&mut details, &job.state.events);
write_content_summary(&mut details, &job.state.events);
write_errors(&mut details, &snapshot);
write_raw_snapshot(&mut details, &snapshot);
write_latest_log(&mut details, state).await;
censor_support_text(details, state).await
}
fn result_summary(
snapshot: &InstallJobSnapshot,
state: &InstallJobState,
) -> String {
match snapshot.status {
InstallJobStatus::Queued => "queued".to_string(),
InstallJobStatus::Running => {
format!("running while {}", phase_label(snapshot.phase))
}
InstallJobStatus::Succeeded => "succeeded".to_string(),
InstallJobStatus::Canceled => snapshot
.error
.as_ref()
.and_then(|error| error.phase)
.map(|phase| format!("canceled while {}", phase_label(phase)))
.unwrap_or_else(|| "canceled".to_string()),
InstallJobStatus::Failed => snapshot
.error
.as_ref()
.and_then(|error| {
error.phase.map(|phase| {
format!(
"failed while {} ({})",
phase_label(phase),
error.code
)
})
})
.unwrap_or_else(|| "failed".to_string()),
InstallJobStatus::Interrupted => latest_interruption(&state.events)
.map(|(reason, phase)| match reason {
InstallInterruptReason::AppClosed => format!(
"interrupted because the app closed while {}",
phase_label(phase)
),
InstallInterruptReason::Unknown => {
format!("interrupted while {}", phase_label(phase))
}
})
.unwrap_or_else(|| "interrupted".to_string()),
}
}
fn write_environment_details(details: &mut String) {
let _ = writeln!(details);
let _ = writeln!(details, "Environment");
let _ = writeln!(details, "App version: {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(
details,
"OS: {}",
sysinfo::System::long_os_version()
.or_else(sysinfo::System::name)
.unwrap_or_else(|| std::env::consts::OS.to_string())
);
let _ = writeln!(details, "OS kind: {}", std::env::consts::OS);
let _ = writeln!(details, "OS family: {}", std::env::consts::FAMILY);
let _ = writeln!(details, "Architecture: {}", std::env::consts::ARCH);
if let Some(kernel_version) = sysinfo::System::kernel_version() {
let _ = writeln!(details, "Kernel: {kernel_version}");
}
}
fn latest_interruption(
events: &[InstallJobEvent],
) -> Option<(InstallInterruptReason, InstallPhaseId)> {
events.iter().rev().find_map(|event| match &event.kind {
InstallJobEventKind::Interrupted { reason, phase } => {
Some((*reason, *phase))
}
_ => None,
})
}
fn write_timeline(details: &mut String, events: &[InstallJobEvent]) {
let _ = writeln!(details);
let _ = writeln!(details, "Timeline");
let mut index = 1;
for event in events {
let Some(description) = timeline_event_description(event) else {
continue;
};
let _ = writeln!(
details,
"{index}. {} {description}",
event.at.to_rfc3339()
);
index += 1;
}
if index == 1 {
let _ = writeln!(details, "No install events were recorded.");
}
}
fn timeline_event_description(event: &InstallJobEvent) -> Option<String> {
match &event.kind {
InstallJobEventKind::JobQueued { kind } => {
Some(format!("Queued {} install", json_string(kind)))
}
InstallJobEventKind::JobStarted => {
Some("Started install job".to_string())
}
InstallJobEventKind::JobSucceeded { instance_id } => {
Some(match instance_id {
Some(instance_id) => {
format!("Finished install for instance {instance_id}")
}
None => "Finished install".to_string(),
})
}
InstallJobEventKind::JobCanceled { phase } => {
Some(format!("Canceled while {}", phase_label(*phase)))
}
InstallJobEventKind::PhaseStarted { phase, details } => Some(format!(
"Started {}{}",
phase_label(*phase),
phase_details_suffix(details)
)),
InstallJobEventKind::Interrupted { reason, phase } => {
Some(match reason {
InstallInterruptReason::AppClosed => {
format!("App closed while {}", phase_label(*phase))
}
InstallInterruptReason::Unknown => {
format!("Interrupted while {}", phase_label(*phase))
}
})
}
InstallJobEventKind::Failed {
phase,
code,
message,
} => Some(format!(
"Failed while {} ({code}): {message}",
phase_label(*phase)
)),
InstallJobEventKind::RollbackStarted { cleanup } => {
Some(format!("Started rollback ({})", cleanup_summary(cleanup)))
}
InstallJobEventKind::RollbackCompleted => {
Some("Rollback completed".to_string())
}
InstallJobEventKind::RollbackFailed { message } => {
Some(format!("Rollback failed: {message}"))
}
InstallJobEventKind::ContentDownloadStarted { .. }
| InstallJobEventKind::ContentFileSkipped { .. }
| InstallJobEventKind::ContentFileCompleted { .. } => None,
}
}
fn write_content_summary(details: &mut String, events: &[InstallJobEvent]) {
let started = events.iter().rev().find_map(|event| match &event.kind {
InstallJobEventKind::ContentDownloadStarted { files, bytes } => {
Some((*files, *bytes))
}
_ => None,
});
let completed = events
.iter()
.filter_map(|event| match &event.kind {
InstallJobEventKind::ContentFileCompleted { path, bytes } => {
Some((path.as_str(), *bytes))
}
_ => None,
})
.collect::<Vec<_>>();
let skipped = events
.iter()
.filter_map(|event| match &event.kind {
InstallJobEventKind::ContentFileSkipped { path, reason } => {
Some((path.as_str(), reason.as_str()))
}
_ => None,
})
.collect::<Vec<_>>();
if started.is_none() && completed.is_empty() && skipped.is_empty() {
return;
}
let _ = writeln!(details);
let _ = writeln!(details, "Content activity");
if let Some((files, bytes)) = started {
let _ = writeln!(
details,
"Completed files: {} / {files}, skipped files: {}",
completed.len(),
skipped.len()
);
if let Some(bytes) = bytes {
let _ = writeln!(
details,
"Expected content size: {}",
format_bytes(bytes)
);
}
} else {
let _ = writeln!(
details,
"Completed files: {}, skipped files: {}",
completed.len(),
skipped.len()
);
}
if !completed.is_empty() {
let _ = writeln!(details);
let _ = writeln!(details, "Recently completed files");
for (path, bytes) in completed.iter().rev().take(20) {
let _ = writeln!(details, "- {path} ({})", format_bytes(*bytes));
}
if completed.len() > 20 {
let _ = writeln!(details, "- ... {} more", completed.len() - 20);
}
}
if !skipped.is_empty() {
let _ = writeln!(details);
let _ = writeln!(details, "Skipped files");
for (path, reason) in skipped.iter().rev().take(20) {
let _ = writeln!(details, "- {path} ({reason})");
}
if skipped.len() > 20 {
let _ = writeln!(details, "- ... {} more", skipped.len() - 20);
}
}
}
fn write_errors(details: &mut String, snapshot: &InstallJobSnapshot) {
if let Some(error) = &snapshot.error {
let _ = writeln!(details);
let _ = writeln!(details, "Failure");
let _ = writeln!(details, "Code: {}", error.code);
if let Some(phase) = error.phase {
let _ = writeln!(details, "Phase: {}", phase_label(phase));
}
let _ = writeln!(details, "Message: {}", error.message);
write_api_error_details(details, error);
write_error_context(details, error);
}
if let Some(error) = &snapshot.rollback_error {
let _ = writeln!(details);
let _ = writeln!(details, "Rollback error");
let _ = writeln!(details, "Code: {}", error.code);
if let Some(phase) = error.phase {
let _ = writeln!(details, "Phase: {}", phase_label(phase));
}
let _ = writeln!(details, "Message: {}", error.message);
write_api_error_details(details, error);
write_error_context(details, error);
}
}
fn write_api_error_details(
details: &mut String,
error: &super::model::InstallErrorView,
) {
let Some(api) = &error.api else {
return;
};
let _ = writeln!(details, "API error: {}", api.error);
if let Some(status) = api.status {
let _ = writeln!(details, "HTTP status: {status}");
}
if api.method.is_some() || api.url.is_some() {
let method = api.method.as_deref().unwrap_or("unknown method");
let url = api.url.as_deref().unwrap_or("unknown URL");
let _ = writeln!(details, "Request: {method} {url}");
}
if let Some(route) = &api.route {
let _ = writeln!(details, "Route: {route}");
}
}
fn write_error_context(
details: &mut String,
error: &super::model::InstallErrorView,
) {
let Some(context) = &error.context else {
return;
};
let _ = writeln!(details, "Operation: {}", context.operation);
if let Some(source_path) = &context.source_path {
let _ = writeln!(details, "Source path: {source_path}");
}
if let Some(target_path) = &context.target_path {
let _ = writeln!(details, "Target path: {target_path}");
}
if let Some(file_path) = &context.file_path {
let _ = writeln!(details, "File path: {file_path}");
}
if let Some(entry_path) = &context.entry_path {
let _ = writeln!(details, "Archive entry: {entry_path}");
}
if !context.urls.is_empty() {
let _ = writeln!(details, "URLs:");
for url in &context.urls {
let _ = writeln!(details, "- {url}");
}
}
if let Some(expected_hash) = &context.expected_hash {
let _ = writeln!(details, "Expected hash: {expected_hash}");
}
if let Some(expected_size) = context.expected_size {
let _ =
writeln!(details, "Expected size: {}", format_bytes(expected_size));
}
if let Some(project_id) = &context.project_id {
let _ = writeln!(details, "Project ID: {project_id}");
}
if let Some(version_id) = &context.version_id {
let _ = writeln!(details, "Version ID: {version_id}");
}
if let Some(minecraft_version) = &context.minecraft_version {
let _ = writeln!(details, "Minecraft version: {minecraft_version}");
}
if let Some(loader) = &context.loader {
let _ = writeln!(details, "Loader: {loader}");
}
if let Some(java_version) = context.java_version {
let _ = writeln!(details, "Java version: {java_version}");
}
if let Some(os) = &context.os {
let _ = writeln!(details, "OS: {os}");
}
if let Some(arch) = &context.arch {
let _ = writeln!(details, "Architecture: {arch}");
}
}
fn write_raw_snapshot(details: &mut String, snapshot: &InstallJobSnapshot) {
let _ = writeln!(details);
let _ = writeln!(details, "Raw snapshot");
match serde_json::to_string_pretty(snapshot) {
Ok(snapshot_json) => {
let _ = writeln!(details, "{snapshot_json}");
}
Err(error) => {
let _ = writeln!(details, "Unable to serialize snapshot: {error}");
}
}
}
async fn write_latest_log(details: &mut String, state: &State) {
let _ = writeln!(details);
let _ = writeln!(details, "Latest launcher log excerpt");
match latest_launcher_log_tail(state).await {
Ok(Some((path, output))) => {
let _ = writeln!(details, "File: {}", path.display());
details.push_str(&output);
}
Ok(None) => {
let _ = writeln!(details, "No launcher log found.");
}
Err(error) => {
let _ = writeln!(details, "Unable to read launcher log: {error}");
}
}
}
fn phase_label(phase: InstallPhaseId) -> &'static str {
match phase {
InstallPhaseId::PreparingInstance => "preparing instance",
InstallPhaseId::ResolvingPack => "resolving pack",
InstallPhaseId::DownloadingPackFile => "downloading pack file",
InstallPhaseId::ReadingPackManifest => "reading pack manifest",
InstallPhaseId::DownloadingContent => "downloading content",
InstallPhaseId::ExtractingOverrides => "extracting overrides",
InstallPhaseId::ResolvingMinecraft => "resolving Minecraft",
InstallPhaseId::ResolvingLoader => "resolving loader",
InstallPhaseId::PreparingJava => "preparing Java",
InstallPhaseId::DownloadingMinecraft => "downloading Minecraft",
InstallPhaseId::RunningLoaderProcessors => "running loader processors",
InstallPhaseId::Finalizing => "finalizing",
InstallPhaseId::RollingBack => "rolling back",
}
}
fn phase_details_suffix(details: &InstallPhaseDetails) -> String {
match details {
InstallPhaseDetails::Empty => String::new(),
InstallPhaseDetails::Instance { name } => format!(" for {name}"),
InstallPhaseDetails::Minecraft {
game_version,
loader,
} => format!(
" for Minecraft {game_version} with {}",
json_string(loader)
),
InstallPhaseDetails::Java {
major_version,
step,
} => format!(": {} Java {major_version}", json_string(step)),
InstallPhaseDetails::Modpack {
project_id,
version_id,
title,
} => {
let mut value = title
.as_ref()
.map(|title| format!(" for {title}"))
.unwrap_or_default();
if let Some(project_id) = project_id {
let _ = write!(value, " project={project_id}");
}
if let Some(version_id) = version_id {
let _ = write!(value, " version={version_id}");
}
value
}
InstallPhaseDetails::Import {
launcher_type,
instance_folder,
} => format!(" from {launcher_type} instance {instance_folder}"),
}
}
fn cleanup_summary(cleanup: &InstallCleanup) -> String {
match cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
match instance_id {
Some(instance_id) => {
format!("delete partially-created instance {instance_id}")
}
None => "delete partially-created instance".to_string(),
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
format!("restore existing instance {instance_id}")
}
}
}
fn progress_summary(progress: &InstallProgress) -> String {
let mut value = format!("{} / {}", progress.current, progress.total);
if let Some(secondary) = &progress.secondary {
let _ = write!(
value,
" ({} / {})",
format_bytes(secondary.current),
format_bytes(secondary.total)
);
}
value
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = UNITS[0];
for next_unit in UNITS.iter().skip(1) {
if value < 1024.0 {
break;
}
value /= 1024.0;
unit = next_unit;
}
if unit == "B" {
format!("{bytes} B")
} else {
format!("{value:.1} {unit}")
}
}
fn json_string<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value)
.map(|value| value.trim_matches('"').to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
async fn latest_launcher_log_tail(
state: &State,
) -> crate::Result<Option<(PathBuf, String)>> {
let Some(logs_dir) = state.directories.launcher_logs_dir() else {
return Ok(None);
};
let entries = match std::fs::read_dir(&logs_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(error) => return Err(error.into()),
};
let mut latest: Option<(PathBuf, SystemTime)> = None;
for entry in entries {
let entry = entry?;
let metadata = match entry.metadata() {
Ok(metadata) if metadata.is_file() => metadata,
_ => continue,
};
let modified = metadata
.modified()
.or_else(|_| metadata.created())
.unwrap_or(SystemTime::UNIX_EPOCH);
let path = entry.path();
match latest.as_ref() {
Some((_, latest_modified)) if modified <= *latest_modified => {}
_ => latest = Some((path, modified)),
}
}
let Some((path, _)) = latest else {
return Ok(None);
};
let output = read_file_tail(&path, INSTALL_SUPPORT_LOG_TAIL_BYTES)?;
Ok(Some((path, output)))
}
fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result<String> {
let mut file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let start = len.saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let mut buffer = Vec::with_capacity((len - start) as usize);
file.read_to_end(&mut buffer)?;
let mut output = String::from_utf8_lossy(&buffer).into_owned();
if start > 0 {
output = format!("[first {start} bytes omitted]\n{output}");
}
Ok(output)
}
async fn censor_support_text(
mut text: String,
state: &State,
) -> crate::Result<String> {
for credentials in ModrinthCredentials::get_all(&state.pool)
.await?
.into_iter()
.map(|credentials| credentials.1)
{
replace_nonempty(
&mut text,
&credentials.session,
"{MODRINTH_ACCESS_TOKEN}",
);
}
for token in minecraft_tokens(&state.pool).await? {
replace_nonempty(&mut text, &token, "{MINECRAFT_TOKEN}");
}
text = censor_ip_addresses(text);
Ok(text)
}
async fn minecraft_tokens(
pool: &sqlx::SqlitePool,
) -> crate::Result<Vec<String>> {
let rows =
sqlx::query("SELECT access_token, refresh_token FROM minecraft_users")
.fetch_all(pool)
.await?;
let mut tokens = Vec::with_capacity(rows.len() * 2);
for row in rows {
tokens.push(row.try_get("access_token")?);
tokens.push(row.try_get("refresh_token")?);
}
Ok(tokens)
}
fn replace_nonempty(text: &mut String, value: &str, replacement: &str) {
if !value.is_empty() {
*text = text.replace(value, replacement);
}
}
fn censor_ip_addresses(text: String) -> String {
let text = Regex::new(
r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b",
)
.expect("valid IPv4 regex")
.replace_all(&text, |captures: &Captures<'_>| {
let value = &captures[0];
match value.parse::<Ipv4Addr>() {
Ok(_) => "...".to_string(),
_ => value.to_string(),
}
})
.into_owned();
Regex::new(r"(?i)\b[0-9a-f:.%]{3,}\b")
.expect("valid IPv6 candidate regex")
.replace_all(&text, |captures: &Captures<'_>| {
let value = &captures[0];
if value.matches(':').count() < 2 {
return value.to_string();
}
let candidate = value.split('%').next().unwrap_or(value);
match candidate.parse::<Ipv6Addr>() {
Ok(_) => ":::::::".to_string(),
_ => value.to_string(),
}
})
.into_owned()
}
+164 -9
View File
@@ -1,23 +1,38 @@
use super::model::{
InstallJobSnapshot, InstallJobState, InstallPhaseDetails, InstallPhaseId,
InstallProgress,
InstallErrorContext, InstallJobEventKind, InstallJobSnapshot,
InstallJobState, InstallPhaseDetails, InstallPhaseId, InstallProgress,
};
use super::store;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use uuid::Uuid;
const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_millis(750);
const CONTENT_PROGRESS_PERSIST_STEPS: u64 = 25;
#[derive(Clone, Debug)]
pub struct InstallProgressReporter {
job_id: Uuid,
state: Arc<Mutex<InstallJobState>>,
state: Arc<Mutex<InstallProgressReporterState>>,
}
#[derive(Debug)]
struct InstallProgressReporterState {
job: InstallJobState,
last_persisted_at: Instant,
last_persisted_progress: Option<(InstallPhaseId, u64)>,
}
impl InstallProgressReporter {
pub fn new(job_id: Uuid, state: InstallJobState) -> Self {
Self {
job_id,
state: Arc::new(Mutex::new(state)),
state: Arc::new(Mutex::new(InstallProgressReporterState {
job: state,
last_persisted_at: Instant::now(),
last_persisted_progress: None,
})),
}
}
@@ -27,16 +42,156 @@ impl InstallProgressReporter {
progress: Option<InstallProgress>,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let app_state = crate::State::get().await?;
self.update_with_events(phase, progress, details, Vec::new())
.await
}
pub async fn set_context(
&self,
context: InstallErrorContext,
) -> crate::Result<()> {
self.update_context(Some(context), true).await
}
pub async fn set_transient_context(
&self,
context: InstallErrorContext,
) -> crate::Result<()> {
self.update_context(Some(context), false).await
}
pub async fn clear_context(&self) -> crate::Result<()> {
self.update_context(None, true).await
}
async fn update_context(
&self,
context: Option<InstallErrorContext>,
persist: bool,
) -> crate::Result<()> {
let app_state = if persist {
Some(crate::State::get().await?)
} else {
None
};
let mut state = self.state.lock().await;
state.progress.phase = phase;
state.progress.progress = progress;
state.progress.details = details;
state.job.set_context(context);
let Some(app_state) = app_state else {
return Ok(());
};
let record =
store::update_state(self.job_id, &state, &app_state).await?;
store::update_state(self.job_id, &state.job, &app_state).await?;
state.mark_persisted();
emit_install_job(&record.snapshot()).await
}
pub async fn persist(&self) -> crate::Result<InstallJobSnapshot> {
let app_state = crate::State::get().await?;
let mut state = self.state.lock().await;
let record =
store::update_state(self.job_id, &state.job, &app_state).await?;
state.mark_persisted();
let snapshot = record.snapshot();
emit_install_job(&snapshot).await?;
Ok(snapshot)
}
pub async fn persist_failure_context(&self, context: InstallErrorContext) {
if let Err(error) = self.update_context(Some(context), true).await {
tracing::warn!(
"Failed to persist install context for failed operation: {error}"
);
}
}
pub async fn preserve_failure_context<T>(
&self,
context: InstallErrorContext,
result: crate::Result<T>,
) -> crate::Result<T> {
match result {
Ok(value) => Ok(value),
Err(error) => {
self.persist_failure_context(context).await;
Err(error)
}
}
}
pub async fn update_with_events(
&self,
phase: InstallPhaseId,
progress: Option<InstallProgress>,
details: InstallPhaseDetails,
events: Vec<InstallJobEventKind>,
) -> crate::Result<()> {
let app_state = crate::State::get().await?;
let mut state = self.state.lock().await;
let phase_started = state.job.progress.phase != phase
|| matches!(
&state.job.progress.details,
InstallPhaseDetails::Empty
) && !matches!(&details, InstallPhaseDetails::Empty);
state.job.set_progress(phase, progress, details);
for event in events {
state.job.record_event(event);
}
if !state.should_persist(phase_started) {
return Ok(());
}
let record =
store::update_state(self.job_id, &state.job, &app_state).await?;
state.mark_persisted();
emit_install_job(&record.snapshot()).await
}
}
impl InstallProgressReporterState {
fn should_persist(&self, phase_started: bool) -> bool {
if phase_started {
return true;
}
let Some(progress) = &self.job.progress.progress else {
return true;
};
if progress.current >= progress.total {
return true;
}
let progressed_enough =
if self.job.progress.phase == InstallPhaseId::DownloadingContent {
self.last_persisted_progress
.map(|(phase, current)| {
phase != self.job.progress.phase
|| progress.current.saturating_sub(current)
>= CONTENT_PROGRESS_PERSIST_STEPS
})
.unwrap_or(true)
} else {
false
};
progressed_enough
|| self.last_persisted_at.elapsed() >= PROGRESS_PERSIST_INTERVAL
}
fn mark_persisted(&mut self) {
self.last_persisted_at = Instant::now();
self.last_persisted_progress = self
.job
.progress
.progress
.as_ref()
.map(|progress| (self.job.progress.phase, progress.current));
}
}
#[allow(unused_variables)]
+8 -5
View File
@@ -1,3 +1,4 @@
mod diagnostics;
pub mod events;
pub mod model;
pub mod recovery;
@@ -6,13 +7,15 @@ pub mod store;
pub use events::InstallProgressReporter;
pub use model::{
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
InstallProgressSecondary, InstallRequest,
InstallErrorContext, InstallErrorView, InstallJavaStep,
InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus,
InstallModpackPreview, InstallPhaseDetails, InstallPhaseId,
InstallPostInstallEdit, InstallProgress, InstallProgressSecondary,
InstallRequest,
};
pub use runner::{
cancel_job, create_instance, create_modpack_instance, dismiss_job,
duplicate_instance, get_job, import_instance, install_existing_instance,
install_pack_to_existing_instance, list_jobs, retry_job,
install_pack_to_existing_instance, job_support_details, list_jobs,
retry_job,
};
+204 -1
View File
@@ -18,16 +18,23 @@ pub struct InstallJobState {
pub cleanup: InstallCleanup,
pub progress: InstallProgressState,
pub paths: InstallJobPaths,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<InstallErrorContext>,
#[serde(default)]
pub events: Vec<InstallJobEvent>,
#[serde(default)]
pub display: Option<InstallJobDisplay>,
pub rollback: Option<InstallRollbackState>,
pub error: Option<InstallErrorView>,
#[serde(default)]
pub rollback_error: Option<InstallErrorView>,
}
impl InstallJobState {
pub fn new(request: InstallRequest) -> Self {
let target = request.target();
let cleanup = request.cleanup();
let kind = request.kind();
let phase = InstallPhaseId::PreparingInstance;
Self {
@@ -41,11 +48,109 @@ impl InstallJobState {
details: InstallPhaseDetails::Empty,
},
paths: InstallJobPaths::default(),
context: None,
events: vec![InstallJobEvent {
at: Utc::now(),
kind: InstallJobEventKind::JobQueued { kind },
}],
display: None,
rollback: None,
error: None,
rollback_error: None,
}
}
pub fn record_event(&mut self, kind: InstallJobEventKind) {
self.events.push(InstallJobEvent {
at: Utc::now(),
kind,
});
}
pub fn set_context(&mut self, context: Option<InstallErrorContext>) {
self.context = context;
}
pub fn set_progress(
&mut self,
phase: InstallPhaseId,
progress: Option<InstallProgress>,
details: InstallPhaseDetails,
) {
if self.progress.phase != phase
|| matches!(&self.progress.details, InstallPhaseDetails::Empty)
&& !matches!(&details, InstallPhaseDetails::Empty)
{
self.record_event(InstallJobEventKind::PhaseStarted {
phase,
details: details.clone(),
});
}
self.progress.phase = phase;
self.progress.progress = progress;
self.progress.details = details;
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct InstallJobEvent {
pub at: DateTime<Utc>,
pub kind: InstallJobEventKind,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InstallInterruptReason {
AppClosed,
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallJobEventKind {
JobQueued {
kind: InstallJobKind,
},
JobStarted,
JobSucceeded {
instance_id: Option<String>,
},
JobCanceled {
phase: InstallPhaseId,
},
PhaseStarted {
phase: InstallPhaseId,
details: InstallPhaseDetails,
},
ContentDownloadStarted {
files: u64,
bytes: Option<u64>,
},
ContentFileSkipped {
path: String,
reason: String,
},
ContentFileCompleted {
path: String,
bytes: u64,
},
Interrupted {
reason: InstallInterruptReason,
phase: InstallPhaseId,
},
Failed {
phase: InstallPhaseId,
code: String,
message: String,
},
RollbackStarted {
cleanup: InstallCleanup,
},
RollbackCompleted,
RollbackFailed {
message: String,
},
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -315,6 +420,51 @@ pub struct InstallJobPaths {
pub final_instance_path: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Clone, Debug, bon::Builder)]
#[builder(start_fn = new)]
pub struct InstallErrorContext {
#[builder(start_fn, into)]
pub operation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub source_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub target_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub entry_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub urls: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub expected_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub minecraft_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub loader: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub arch: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct InstallJobDisplay {
pub title: String,
@@ -330,14 +480,66 @@ pub struct InstallRollbackState {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct InstallErrorView {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<InstallPhaseId>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<InstallApiErrorDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<InstallErrorContext>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct InstallApiErrorDetails {
pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
}
impl InstallErrorView {
pub fn from_error(code: &str, error: impl ToString) -> Self {
pub fn from_error(
code: &str,
phase: InstallPhaseId,
error: &crate::Error,
context: Option<InstallErrorContext>,
) -> Self {
Self {
code: code.to_string(),
phase: Some(phase),
message: error.to_string(),
api: match error.raw.as_ref() {
crate::ErrorKind::LabrinthError(error) => {
Some(InstallApiErrorDetails {
error: error.error.clone(),
status: error.status,
method: error.method.clone(),
url: error.url.clone(),
route: error.route.clone(),
})
}
_ => None,
},
context,
}
}
pub fn from_message(
code: &str,
phase: InstallPhaseId,
message: impl Into<String>,
) -> Self {
Self {
code: code.to_string(),
phase: Some(phase),
message: message.into(),
api: None,
context: None,
}
}
}
@@ -354,6 +556,7 @@ pub struct InstallJobSnapshot {
pub details: InstallPhaseDetails,
pub display: Option<InstallJobDisplay>,
pub error: Option<InstallErrorView>,
pub rollback_error: Option<InstallErrorView>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub finished: Option<DateTime<Utc>>,
+63 -39
View File
@@ -1,8 +1,8 @@
use super::events::emit_install_job;
use super::model::{
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobState,
InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallRequest,
InstallTarget,
InstallCleanup, InstallErrorView, InstallInterruptReason,
InstallJobDisplay, InstallJobEventKind, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallRequest, InstallTarget,
};
use super::store;
use crate::event::InstancePayloadType;
@@ -16,19 +16,41 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView {
code: "interrupted".to_string(),
message: "interrupted".to_string(),
});
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
if let Err(error) = apply_cleanup(&job.state, state).await {
tracing::error!(
"Error cleaning up interrupted install job {}: {error}",
job.id
);
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
} else {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
}
clear_deleted_new_instance_id(&mut job.state);
@@ -55,38 +77,40 @@ fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
match &state.request {
InstallRequest::CreateInstance { name, icon_path, .. } => {
Some(InstallJobDisplay {
title: name.clone(),
icon: icon_path.clone(),
})
}
InstallRequest::CreateModpackInstance { location, .. } => match location {
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
title,
icon_url,
..
} => Some(InstallJobDisplay {
title: title.clone(),
icon: icon_url.clone(),
}),
crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None,
},
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
title: instance_folder.clone(),
icon: None,
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
})
}
}
InstallRequest::CreateInstance { name, icon_path, .. } => {
Some(InstallJobDisplay {
title: name.clone(),
icon: icon_path.clone(),
})
}
InstallRequest::CreateModpackInstance { location, .. } => match location {
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
title,
icon_url,
..
} => Some(InstallJobDisplay {
title: title.clone(),
icon: icon_url.clone(),
}),
crate::api::pack::install_from::CreatePackLocation::FromFile {
..
} => None,
},
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
title: instance_folder.clone(),
icon: None,
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
})
}
}
}
pub async fn apply_cleanup(
+186 -25
View File
@@ -1,11 +1,11 @@
use super::events::{InstallProgressReporter, emit_install_job};
use super::model::{
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobSnapshot,
InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId,
InstallPostInstallEdit, InstallRequest, InstallRollbackState,
InstallTarget,
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
InstallRequest, InstallRollbackState, InstallTarget,
};
use super::{recovery, store};
use super::{diagnostics, recovery, store};
use crate::ErrorKind;
use crate::api::pack::install_from::{
CreatePackLocation, generate_pack_from_file,
@@ -108,6 +108,12 @@ pub async fn get_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
Ok(store::get_required(job_id, &state).await?.snapshot())
}
pub async fn job_support_details(job_id: Uuid) -> crate::Result<String> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
diagnostics::build_job_support_details(&job, &state).await
}
pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let mut job = store::get_required(job_id, &state).await?;
@@ -127,10 +133,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
job.state.cleanup = job.state.request.cleanup();
job.state.rollback = None;
job.state.error = None;
job.state.rollback_error = None;
job.state.context = None;
job.state.progress.phase = InstallPhaseId::PreparingInstance;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
prepare_initial_instance(&mut job.state, &state).await?;
job.state.record_event(InstallJobEventKind::JobQueued {
kind: job.state.request.kind(),
});
let record = store::update_status(
job_id,
@@ -156,11 +167,35 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.into());
}
job.state.error = Some(InstallErrorView {
code: "canceled".to_string(),
message: "Install was canceled".to_string(),
let canceled_phase = job.state.progress.phase;
job.state.error = Some(InstallErrorView::from_message(
"canceled",
canceled_phase,
"Install was canceled",
));
job.state.record_event(InstallJobEventKind::JobCanceled {
phase: canceled_phase,
});
recovery::apply_cleanup(&job.state, &state).await?;
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => job
.state
.record_event(InstallJobEventKind::RollbackCompleted),
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
}
}
clear_deleted_new_instance_id(&mut job.state);
let record = store::update_status(
job_id,
@@ -328,13 +363,21 @@ fn spawn_job(job_id: Uuid) {
async fn run_job(job_id: Uuid) -> crate::Result<()> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
let mut job = store::get_required(job_id, &state).await?;
if job.status != InstallJobStatus::Queued {
return Ok(());
}
let _install_permit = state.install_job_semaphore.acquire().await?;
job = store::get_required(job_id, &state).await?;
if job.status != InstallJobStatus::Queued {
return Ok(());
}
let mut job_state = job.state.clone();
job_state.record_event(InstallJobEventKind::JobStarted);
let record = store::update_status(
job_id,
InstallJobStatus::Running,
@@ -345,16 +388,24 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
emit_install_job(&record.snapshot()).await?;
let result = run_request(job_id, &mut job_state, &state).await;
if let Ok(record) = store::get_required(job_id, &state).await {
job_state = record.state;
}
match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
});
job_state.progress.phase = InstallPhaseId::Finalizing;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.error = None;
job_state.rollback_error = None;
job_state.context = None;
let record = store::update_status(
job_id,
InstallJobStatus::Succeeded,
@@ -365,11 +416,41 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
emit_install_job(&record.snapshot()).await?;
}
Err(error) => {
let failed_phase = job_state.progress.phase;
let error_view = install_error_view(
failed_phase,
&error,
job_state.context.clone(),
);
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.error = Some(install_error_view(&error));
recovery::apply_cleanup(&job_state, &state).await?;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
if let Err(rollback_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
} else {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
}
clear_deleted_new_instance_id(&mut job_state);
let record = store::update_status(
job_id,
@@ -812,6 +893,14 @@ async fn install_pack(
title,
icon_url,
} => {
reporter
.set_context(
InstallErrorContext::new("download modpack file")
.project_id(project_id.clone())
.version_id(version_id.clone())
.build(),
)
.await?;
generate_pack_from_version_id_with_reporter(
project_id,
version_id,
@@ -824,6 +913,13 @@ async fn install_pack(
.await?
}
CreatePackLocation::FromFile { path } => {
reporter
.set_context(
InstallErrorContext::new("read local modpack file")
.source_path(path.display().to_string())
.build(),
)
.await?;
generate_pack_from_file(path, instance_id.clone()).await?
}
};
@@ -887,9 +983,7 @@ async fn update_progress(
phase: InstallPhaseId,
details: InstallPhaseDetails,
) -> crate::Result<()> {
job_state.progress.phase = phase;
job_state.progress.progress = None;
job_state.progress.details = details;
job_state.set_progress(phase, None, details);
let record = store::update_state(job_id, job_state, state).await?;
emit_install_job(&record.snapshot()).await?;
Ok(())
@@ -934,19 +1028,86 @@ fn set_display(
job_state.display = Some(InstallJobDisplay { title, icon });
}
fn install_error_view(error: &crate::Error) -> InstallErrorView {
fn install_error_view(
phase: InstallPhaseId,
error: &crate::Error,
context: Option<InstallErrorContext>,
) -> InstallErrorView {
InstallErrorView::from_error(
install_error_code(phase, error),
phase,
error,
context,
)
}
fn install_error_code(
phase: InstallPhaseId,
error: &crate::Error,
) -> &'static str {
use InstallPhaseId::*;
match error.raw.as_ref() {
ErrorKind::FetchError(_)
| ErrorKind::ApiIsDownError(_)
| ErrorKind::WSError(_)
| ErrorKind::WSClosedError(_) => InstallErrorView {
code: "network_error".to_string(),
message: "network_error".to_string(),
ErrorKind::InputError(_) => match phase {
PreparingInstance | Finalizing => "instance_error",
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
"pack_error"
}
DownloadingContent => "content_error",
ExtractingOverrides => "path_error",
PreparingJava => "java_error",
DownloadingMinecraft => "instance_error",
RollingBack => "rollback_error",
ResolvingMinecraft | ResolvingLoader | RunningLoaderProcessors => {
"launcher_error"
}
},
_ => InstallErrorView {
code: "unknown_error".to_string(),
message: "unknown_error".to_string(),
ErrorKind::LauncherError(_) => match phase {
RunningLoaderProcessors => "processor_error",
PreparingJava => "java_error",
ResolvingLoader => "loader_error",
_ => "launcher_error",
},
ErrorKind::JREError(_) => "java_error",
ErrorKind::NoValueFor(_) | ErrorKind::MetadataError(_) => match phase {
ResolvingLoader => "loader_error",
PreparingJava => "java_error",
_ => "metadata_error",
},
ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) => {
"network_error"
}
ErrorKind::Any(_)
if matches!(
phase,
DownloadingPackFile
| DownloadingContent
| ResolvingMinecraft
| ResolvingLoader
| PreparingJava
| DownloadingMinecraft
) =>
{
"network_error"
}
ErrorKind::LabrinthError(_) => "api_error",
ErrorKind::HashError(_, _) => "hash_error",
ErrorKind::ZipError(_) => "archive_error",
ErrorKind::DeserializationError(_) | ErrorKind::StripPrefixError(_) => {
"path_error"
}
ErrorKind::FSError(_)
| ErrorKind::IOError(_)
| ErrorKind::StdIOError(_)
| ErrorKind::UTFError(_) => "filesystem_error",
ErrorKind::INIError(_) | ErrorKind::JSONError(_) => "parse_error",
ErrorKind::Sqlx(_) | ErrorKind::SqlxMigrate(_) => "database_error",
ErrorKind::JoinError(_)
| ErrorKind::RecvError(_)
| ErrorKind::AcquireError(_)
| ErrorKind::EventError(_) => "internal_error",
ErrorKind::OtherError(_) | ErrorKind::Any(_) => "internal_error",
_ => "unknown_error",
}
}
+1
View File
@@ -44,6 +44,7 @@ impl InstallJobRecord {
details: self.state.progress.details.clone(),
display: self.state.display.clone(),
error: self.state.error.clone(),
rollback_error: self.state.rollback_error.clone(),
created: self.created,
modified: self.modified,
finished: self.finished,