Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Added

- The Create Backup screen has a Cancel button while a manual backup runs. Cancel
stops the world copy or the destination writes, removes the partial files, and
reports "Backup cancelled". A destination that had already finished is kept.

## 0.3.7.3 (2026-09-14)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package dev.ishaanko.worldarchive.runtime;

import dev.ishaanko.worldarchive.core.AsyncTasks;
import dev.ishaanko.worldarchive.model.BackupResult;
import dev.ishaanko.worldarchive.model.BackupStatus;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CancellationException;

/** Credential-safe notices for unattended backup outcomes. */
final class BackgroundBackupWarnings {
Expand Down Expand Up @@ -52,8 +52,7 @@ static String worldExitStartedMessage() {

/** A cancellation is the user's own choice, so it never becomes a warning. */
static boolean isCancellation(Throwable failure) {
return failure instanceof CancellationException
|| failure != null && failure.getCause() instanceof CancellationException;
return AsyncTasks.isCancellation(failure);
}

private static Optional<String> warning(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,35 @@ CompletionStage<BackupResult> queueRequestedSave(
pending,
"The integrated server rejected the backup save");
}
return pending.result().minimalCompletionStage();
return cancellableResult(pending);
}

/**
* A view of a pending backup's result whose cancel asks the backup to stop the same way
* the world-exit toast's Cancel button does. The view does not mark itself cancelled: it
* completes with the backup's real outcome, which is a cancellation unless the
* coordinator had already begun recording the result and refused the request. Callers
* cannot complete the backup through it.
*/
private CompletableFuture<BackupResult> cancellableResult(PendingLiveBackup pending) {
CompletableFuture<BackupResult> view = new CompletableFuture<>() {
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
if (isDone()) {
return false;
}
cancelLiveBackup(pending);
return true;
}
};
pending.result().whenComplete((result, throwable) -> {
if (throwable == null) {
view.complete(result);
} else {
view.completeExceptionally(throwable);
}
});
return view;
}

private void serverStarted(MinecraftServer server) {
Expand Down Expand Up @@ -421,7 +449,7 @@ private void completeStoppedServer(
runtime.beginBackupProgress(
BackgroundBackupWarnings.worldExitStartedMessage(),
exit.result(),
() -> cancelExitBackup(exit));
() -> cancelLiveBackup(exit));
boolean quitting;
synchronized (lock) {
quitting = clientStopping;
Expand Down Expand Up @@ -663,10 +691,11 @@ private void captureAndDispatch(PendingLiveBackup pending) {
}

/**
* Cancel button handler. Safe to call from the render thread: the coordinator
* cancel runs on a worker because it may release captured files.
* Cancel handler for the world-exit toast and the manual backup screen. Safe to call
* from the render thread: the coordinator cancel runs on a worker because it may
* release captured files.
*/
private void cancelExitBackup(PendingLiveBackup pending) {
private void cancelLiveBackup(PendingLiveBackup pending) {
CompletableFuture<BackupResult> running = pending.requestCancel();
if (running != null) {
runtime.submit(() -> running.cancel(true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public interface BackupClientFacade {

CompletionStage<Optional<BackupWorldContext>> resolveWorld(BackupWorldSelection selection);

/** Requests a save-gated manual capture; screens must not capture a live world directly. */
/**
* Requests a save-gated manual capture; screens must not capture a live world directly.
* Cancelling the returned stage stops the backup and removes its partial files.
*/
CompletionStage<BackupResult> createManualBackup(
BackupWorldContext world,
Optional<String> label,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.ishaanko.worldarchive.ui;

import dev.ishaanko.worldarchive.core.AsyncTasks;
import dev.ishaanko.worldarchive.core.BackupOperation;
import dev.ishaanko.worldarchive.core.ProgressListener;
import dev.ishaanko.worldarchive.core.RestoreBackupResult;
Expand All @@ -13,6 +14,7 @@
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
Expand All @@ -24,7 +26,10 @@
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;

/** Native asynchronous operation screen with credential-safe progress and partial outcomes. */
/**
* Native asynchronous operation screen with credential-safe progress and partial outcomes.
* A backup creation can be cancelled while it runs; other operations show a wait notice.
*/
final class BackupOperationScreen<T> extends Screen {
private static final int BAR_BACKGROUND = 0xFF303030;

Expand All @@ -44,8 +49,15 @@ final class BackupOperationScreen<T> extends Screen {

private final SuccessHandler<T> successHandler;

private final boolean cancellable;

private final AtomicReference<ProgressState> queuedProgress = new AtomicReference<>();

// The running operation's future, kept so the Cancel button can stop it.
private CompletableFuture<T> runningOperation;

private boolean cancelling;

private ProgressState progress;

private Presentation presentation = Presentation.running("Queued");
Expand All @@ -64,11 +76,13 @@ private BackupOperationScreen(
Screen parent,
String title,
OperationStarter<T> starter,
SuccessHandler<T> successHandler) {
SuccessHandler<T> successHandler,
boolean cancellable) {
super(Component.literal(title));
this.parent = Objects.requireNonNull(parent, "parent");
this.starter = Objects.requireNonNull(starter, "starter");
this.successHandler = Objects.requireNonNull(successHandler, "successHandler");
this.cancellable = cancellable;
}

static BackupOperationScreen<BackupResult> backupResult(
Expand All @@ -88,7 +102,8 @@ static BackupOperationScreen<BackupResult> backupResult(
parent,
title,
starter,
result -> backupPresentation(operation, result));
result -> backupPresentation(operation, result),
operation == BackupOperation.CREATE);
}

/** Shows one combined outcome for a multi-backup delete instead of one screen per backup. */
Expand All @@ -104,7 +119,7 @@ static BackupOperationScreen<List<BackupResult>> deleteBatch(
case FAILED -> ChatFormatting.RED;
};
return new Presentation(summary.headline(), summary.details(), color);
});
}, false);
}

static BackupOperationScreen<RestoreBackupResult> restore(
Expand All @@ -119,7 +134,7 @@ static BackupOperationScreen<RestoreBackupResult> restore(
"Restore completed",
List.of("Created " + result.restoredWorldDirectory().getFileName()),
ChatFormatting.GREEN);
});
}, false);
}

@Override
Expand Down Expand Up @@ -178,11 +193,11 @@ protected void init() {
int buttonY = Math.min(height - 28, height / 2 + 72);
int buttonWidth = retryAvailable ? 120 : 150;
int closeX = retryAvailable ? width / 2 + 3 : width / 2 - buttonWidth / 2;
Component closeLabel = running ? Component.literal("Please wait…") : Component.literal("Done");
Button closeButton = Button.builder(closeLabel, ignored -> onClose())
.bounds(closeX, buttonY, buttonWidth, 20)
.build();
closeButton.active = !running;
Button closeButton = running
? runningButton(closeX, buttonY, buttonWidth)
: Button.builder(Component.literal("Done"), ignored -> onClose())
.bounds(closeX, buttonY, buttonWidth, 20)
.build();
addRenderableWidget(closeButton);
if (retryAvailable) {
addRenderableWidget(Button.builder(Component.literal("Retry"), ignored -> retry())
Expand Down Expand Up @@ -213,6 +228,41 @@ public void extractRenderState(
graphics.fill(x + 1, y + 1, x + 1 + filled, y + 9, BAR_PROGRESS);
}

/** While the operation runs: Cancel when it can be stopped, otherwise a wait notice. */
private Button runningButton(int x, int y, int width) {
if (!cancellable) {
Button wait = Button.builder(Component.literal("Please wait…"), ignored -> { })
.bounds(x, y, width, 20)
.build();
wait.active = false;
return wait;
}
String key = cancelling
? "screen.worldarchive.operation.cancelling"
: "screen.worldarchive.operation.cancel";
Button cancel = Button.builder(Component.translatable(key), ignored -> cancelOperation())
.bounds(x, y, width, 20)
.build();
cancel.active = !cancelling;
return cancel;
}

/**
* Stops the running backup. The coordinator interrupts the capture or the destination
* writes and removes their partial files; the outcome arrives as a cancellation.
*/
private void cancelOperation() {
CompletableFuture<T> current = runningOperation;
if (!running || cancelling || current == null) {
return;
}
cancelling = true;
presentation = Presentation.running(
Component.translatable("screen.worldarchive.operation.cancelling").getString());
rebuildIfInitialized();
current.cancel(true);
}

private void startOperation(long token) {
CompletionStage<T> operation;
try {
Expand All @@ -223,6 +273,7 @@ private void startOperation(long token) {
finishFailure(token, exception);
return;
}
runningOperation = operation.toCompletableFuture();
operation.whenComplete((result, throwable) -> minecraft.execute(() -> {
if (!accepts(token)) {
return;
Expand Down Expand Up @@ -286,6 +337,17 @@ private void finishFailureOnClient(long token, Throwable throwable) {
return;
}
running = false;
cancelling = false;
if (AsyncTasks.isCancellation(throwable)) {
retryAvailable = false;
presentation = new Presentation(
Component.translatable("screen.worldarchive.operation.cancelled").getString(),
List.of(Component.translatable(
"screen.worldarchive.operation.cancelled_detail").getString()),
ChatFormatting.YELLOW);
rebuildIfInitialized();
return;
}
String failure = safeFailure(throwable);
retryAvailable = captureChanged(failure);
List<String> details = retryAvailable
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/dev/ishaanko/worldarchive/core/AsyncTasks.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ public static <T> InterruptibleFuture<T> supplyInterruptible(
return result;
}

/** True when a failure is a cancellation, directly or wrapped by a completion stage. */
public static boolean isCancellation(Throwable failure) {
return failure instanceof CancellationException
|| failure != null && failure.getCause() instanceof CancellationException;
}

public static CompletionStage<Void> run(
Executor executor,
Runnable operation) {
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/assets/worldarchive/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"modmenu.descriptionTranslation.worldarchive": "Creates dependable local backups of your single-player worlds.",
"screen.worldarchive.backups_button": "World Backups",
"screen.worldarchive.backup_toast.cancel": "Cancel",
"screen.worldarchive.operation.cancel": "Cancel",
"screen.worldarchive.operation.cancelled": "Backup cancelled",
"screen.worldarchive.operation.cancelled_detail": "Partial files are being removed. A destination that had already finished is kept.",
"screen.worldarchive.operation.cancelling": "Cancelling backup...",
"screen.worldarchive.settings.archive_folder": "Archive folder",
"screen.worldarchive.settings.back": "Back",
"screen.worldarchive.settings.browse": "Browse...",
Expand Down
10 changes: 10 additions & 0 deletions src/test/java/dev/ishaanko/worldarchive/core/AsyncTasksTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
import org.junit.jupiter.api.Test;

final class AsyncTasksTest {
@Test
void cancellationIsRecognizedDirectlyAndWhenWrapped() {
CancellationException cancelled = new CancellationException("cancelled");

assertTrue(AsyncTasks.isCancellation(cancelled));
assertTrue(AsyncTasks.isCancellation(new CompletionException(cancelled)));
assertFalse(AsyncTasks.isCancellation(new IllegalStateException("failed")));
assertFalse(AsyncTasks.isCancellation(null));
}

@Test
void supplyExecutorRejectionCompletesTheReturnedStage() {
CompletionException failure = assertThrows(
Expand Down
Loading
Loading