fix: try fix install jobs being bricked/improve tracing (#6890)

* fix: try fix install jobs being bricked/improve tracing

* fix: rev cmts

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-27 14:47:46 +00:00
committed by GitHub
co-authored by Prospector
parent 372e2bf47b
commit 4c2e64e31f
13 changed files with 583 additions and 290 deletions
@@ -1,6 +1,14 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -11,9 +19,23 @@ import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
alwaysShowCopyDetailsTitle: {
id: 'app.resource-management-settings.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.resource-management-settings.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
})
watch(
settings,
@@ -154,6 +176,28 @@ async function findLauncherDir() {
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
@@ -23,6 +23,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -221,13 +222,6 @@ const failureSummaryMessages = defineMessages({
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -241,6 +235,7 @@ export async function useInstallJobNotifications(opts: {
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
@@ -250,7 +245,6 @@ export async function useInstallJobNotifications(opts: {
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -421,116 +415,14 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
@@ -607,6 +499,16 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@@ -615,7 +517,6 @@ export async function useInstallJobNotifications(opts: {
}
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
@@ -642,12 +543,8 @@ export async function useInstallJobNotifications(opts: {
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
}
}),
)
@@ -730,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}
await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
await refresh(false)
return {
active: computed(() => jobs.value.length > 0),
@@ -743,9 +640,6 @@ export async function useInstallJobNotifications(opts: {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
@@ -704,6 +704,12 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.resource-management-settings.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
},
"app.resource-management-settings.always-show-copy-details.title": {
"message": "Always show copy details"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+1
View File
@@ -17,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS = {
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
always_show_copy_details: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+9
View File
@@ -202,11 +202,20 @@ pub async fn emit_install_job(
{
use tauri::Emitter;
let result: crate::Result<()> = (|| {
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(
"Failed to emit install job {} update: {error}",
snapshot.job_id
);
}
}
Ok(())
+51 -13
View File
@@ -262,7 +262,22 @@ async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
for mut job in jobs {
for job in jobs {
let job_id = job.id;
if let Err(error) = recover_interrupted_job(job, state).await {
tracing::error!(
"Error recovering interrupted install job {job_id}: {error}"
);
}
}
Ok(())
}
async fn recover_interrupted_job(
mut job: store::InstallJobRecord,
state: &State,
) -> crate::Result<()> {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
@@ -284,7 +299,14 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
if let Err(error) = apply_cleanup(&job.state, state).await {
let cleanup_succeeded = match apply_cleanup(&job.state, state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
tracing::error!(
"Error cleaning up interrupted install job {}: {error}",
job.id
@@ -298,20 +320,19 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
} else {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
false
}
clear_deleted_new_instance_id(&mut job.state);
};
let record = store::update_status(
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?;
if job.state.rollback_error.is_none() {
.await?
{
if cleanup_succeeded {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -383,10 +404,20 @@ pub async fn apply_cleanup(
match &job_state.cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
if let Some(instance_id) = instance_id {
let _ = crate::state::remove_instance(instance_id, state).await;
let _ =
if crate::state::get_instance(instance_id, &state.pool)
.await?
.is_some()
{
crate::state::remove_instance(instance_id, state).await?;
}
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Removed)
.await;
.await
{
tracing::warn!(
"Failed to emit removed instance {instance_id}: {error}"
);
}
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
@@ -410,7 +441,14 @@ pub async fn apply_cleanup(
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit restored instance {instance_id}: {error}"
);
}
}
}
}
+211 -71
View File
@@ -163,19 +163,57 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
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?;
if let Err(error) = prepare_initial_instance(&mut job.state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up install job {job_id} retry preparation: {cleanup_error}"
);
}
return Err(error);
}
job.state.record_event(InstallJobEventKind::JobQueued {
kind: job.state.request.kind(),
});
let record = store::update_status(
let record = match store::update_status(
job_id,
InstallJobStatus::Queued,
&job.state,
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up unqueued install job {job_id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job.state, &state).await
{
let error_view = install_error_view(
job.state.progress.phase,
&error,
job.state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(job_id, job.state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize retried install job {job_id}: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -206,10 +244,29 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
let status_updated = store::update_status_if(
job_id,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job.state,
&state,
)
.await?
.is_some();
if !status_updated {
return Err(crate::ErrorKind::InputError(
"Install job is no longer queued".to_string(),
)
.into());
}
let cleanup_succeeded =
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => job
.state
.record_event(InstallJobEventKind::RollbackCompleted),
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
@@ -220,17 +277,17 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
false
}
}
clear_deleted_new_instance_id(&mut job.state);
let record = store::update_status(
job_id,
InstallJobStatus::Canceled,
&job.state,
&state,
)
.await?;
if job.state.rollback_error.is_none() {
};
let status = if cleanup_succeeded {
InstallJobStatus::Canceled
} else {
InstallJobStatus::Failed
};
let record =
store::update_status(job_id, status, &job.state, &state).await?;
if cleanup_succeeded {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -247,10 +304,53 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let id = Uuid::new_v4();
let mut job_state = InstallJobState::new(request);
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job_state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up install job preparation: {cleanup_error}"
);
}
return Err(error);
}
let record = match store::insert(
id,
&job_state,
InstallJobStatus::Queued,
&state,
)
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up untracked install job {id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job_state, &state).await
{
let error_view = install_error_view(
job_state.progress.phase,
&error,
job_state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(id, job_state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize install job {id} after setup error: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -370,9 +470,9 @@ async fn prepare_initial_instance(
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
set_instance_id(job_state, instance_id.clone());
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
@@ -433,10 +533,15 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = Box::pin(run_job(job_id)).await {
let failure = error.to_string();
tracing::error!("Install job {job_id} terminated: {failure}");
if let Err(error) = terminalize_stranded_job(job_id, failure).await
{
tracing::error!(
"Install job {job_id} failed to update state: {error}"
"Failed to terminalize stranded install job {job_id}: {error}"
);
}
}
});
}
@@ -457,13 +562,17 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
let mut job_state = job.state.clone();
job_state.record_event(InstallJobEventKind::JobStarted);
let record = store::update_status(
let Some(record) = store::update_status_if(
job_id,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job_state,
&state,
)
.await?;
.await?
else {
return Ok(());
};
emit_install_job(&record.snapshot()).await?;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
@@ -472,19 +581,21 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
}
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
Ok(Some(instance_id)) => {
set_instance_id(&mut job_state, instance_id.clone());
Ok(instance_id)
}
Ok(None) => Err(crate::ErrorKind::InputError(
"Install job completed without an instance id".to_string(),
)
.into()),
Err(error) => Err(error),
};
match result {
Ok(()) => {
Ok(instance_id) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
instance_id: Some(instance_id.clone()),
});
job_state.progress.phase = InstallPhaseId::Finalizing;
job_state.progress.progress = None;
@@ -492,38 +603,87 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
job_state.error = None;
job_state.rollback_error = None;
job_state.context = None;
let record = store::update_status(
job_id,
InstallJobStatus::Succeeded,
&job_state,
&state,
)
.await?;
if let Some(record) =
store::complete_success(job_id, &job_state, &state).await?
{
recovery::clear_staging_dir(&job_state).await;
if let Err(error) =
emit_instance(&instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit completed instance {instance_id}: {error}"
);
}
emit_install_job(&record.snapshot()).await?;
}
}
Err(error) => {
let failed_phase = job_state.progress.phase;
tracing::error!("Install job {job_id} failed: {error}");
let error_view = install_error_view(
failed_phase,
job_state.progress.phase,
&error,
job_state.context.clone(),
);
terminalize_failed_job(job_id, job_state, error_view, &state)
.await?;
}
}
Ok(())
}
async fn terminalize_stranded_job(
job_id: Uuid,
message: String,
) -> crate::Result<()> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
if !matches!(
job.status,
InstallJobStatus::Queued | InstallJobStatus::Running
) {
return Ok(());
}
let error_view = InstallErrorView::from_message(
"install_worker_terminated",
job.state.progress.phase,
message,
);
terminalize_failed_job(job_id, job.state, error_view, &state).await
}
async fn terminalize_failed_job(
job_id: Uuid,
mut job_state: InstallJobState,
error_view: InstallErrorView,
state: &State,
) -> crate::Result<()> {
let failed_phase = job_state.progress.phase;
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.rollback_error = None;
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
if let Err(rollback_error) =
recovery::apply_cleanup(&job_state, &state).await
let cleanup_succeeded = match recovery::apply_cleanup(&job_state, state)
.await
{
Ok(()) => {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job_state);
true
}
Err(rollback_error) => {
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
@@ -535,23 +695,22 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
} else {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
false
}
clear_deleted_new_instance_id(&mut job_state);
let record = store::update_status(
};
if let Some(record) = store::finish_active(
job_id,
InstallJobStatus::Failed,
&job_state,
&state,
state,
)
.await?;
if job_state.rollback_error.is_none() {
.await?
{
if cleanup_succeeded {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
}
}
Ok(())
@@ -1176,25 +1335,6 @@ async fn lock_existing_instance(
Ok(())
}
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
+156 -2
View File
@@ -171,7 +171,7 @@ pub async fn list(
.await?
};
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn list_interrupted_candidates(
@@ -198,7 +198,7 @@ pub async fn list_interrupted_candidates(
.fetch_all(&app_state.pool)
.await?;
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn update_state(
@@ -262,6 +262,143 @@ pub async fn update_status(
get_required(id, app_state).await
}
pub async fn update_status_if(
id: Uuid,
expected_status: InstallJobStatus,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = status.is_finished().then_some(now.timestamp());
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let expected_status_value = expected_status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = now.timestamp();
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status = ?
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.bind(expected_status_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn finish_active(
id: Uuid,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = now.timestamp();
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = finished;
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status IN ('queued', 'running')
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn complete_success(
id: Uuid,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let Some(instance_id) = instance_id(state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
let now = Utc::now().timestamp();
let json = serde_json::to_string(state)?;
let id_value = id.to_string();
let mut transaction = app_state.pool.begin().await?;
let job_result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = 'succeeded', state = ?, modified = ?, finished = ?
WHERE id = ? AND status = 'running'
",
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
if job_result.rows_affected() == 0 {
transaction.rollback().await?;
return Ok(None);
}
let instance_result = sqlx::query(
"
UPDATE instances
SET install_stage = 'installed', modified = ?
WHERE id = ?
",
)
.bind(now)
.bind(&instance_id)
.execute(&mut *transaction)
.await?;
if instance_result.rows_affected() == 0 {
transaction.rollback().await?;
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
.into());
}
transaction.commit().await?;
get_required(id, app_state).await.map(Some)
}
pub async fn dismiss(id: Uuid, app_state: &State) -> crate::Result<()> {
let id = id.to_string();
let modified = Utc::now().timestamp();
@@ -308,6 +445,23 @@ fn row_to_record(row: InstallJobRow) -> crate::Result<InstallJobRecord> {
})
}
fn deserialize_rows(rows: Vec<InstallJobRow>) -> Vec<InstallJobRecord> {
rows.into_iter()
.filter_map(|row| {
let id = row.id.clone();
match row_to_record(row) {
Ok(record) => Some(record),
Err(error) => {
tracing::error!(
"Failed to deserialize install job {id}: {error}"
);
None
}
}
})
.collect()
}
fn instance_id(state: &InstallJobState) -> Option<String> {
match &state.target {
super::model::InstallTarget::NewInstance { instance_id } => {
+8 -6
View File
@@ -603,6 +603,13 @@ pub async fn install_minecraft_with_reporter(
let protocol_version = read_protocol_version_from_jar(client_path).await?;
crate::state::instances::commands::set_applied_content_set_protocol_version(
&instance.id,
protocol_version,
&state.pool,
)
.await?;
if reporter.is_none() {
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
@@ -610,12 +617,7 @@ pub async fn install_minecraft_with_reporter(
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
crate::state::instances::commands::set_applied_content_set_protocol_version(
&instance.id,
protocol_version,
&state.pool,
)
.await?;
}
if let Some(loading_bar) = &loading_bar {
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
+1
View File
@@ -65,6 +65,7 @@ pub enum FeatureFlag {
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
}
impl Settings {
@@ -52,6 +52,7 @@
:progress-current="progressItem.progressCurrent"
:progress-total="progressItem.progressTotal"
:actions="progressItem.buttons"
:dismissible="progressItem.dismissible"
@dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)"
/>
@@ -49,7 +49,7 @@
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -96,7 +96,7 @@
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -213,6 +213,7 @@ const props = withDefaults(
progressCurrent?: number
progressTotal?: number
actions?: PopupNotificationButton[]
dismissible?: boolean
}>(),
{
actionLoading: null,
@@ -224,6 +225,7 @@ const props = withDefaults(
showProgress: true,
wrapText: false,
progressType: 'percentage',
dismissible: true,
},
)
@@ -24,6 +24,7 @@ export interface PopupNotificationProgressItem {
progressType?: PopupNotificationProgressType
progressCurrent?: number
progressTotal?: number
dismissible?: boolean
onDismiss?: () => void | Promise<void>
buttons?: PopupNotificationButton[]
}