diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d864b..5dd7d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.3.8 (2026-09-15) + +### 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 diff --git a/gradle.properties b/gradle.properties index e54cce8..2dd3d03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,6 @@ loom_version=1.17.19 fabric_api_version=0.157.0+26.2 modmenu_version=20.0.1 -mod_version=0.3.7.3 +mod_version=0.3.8 maven_group=dev.ishaanko automatic_release_channel=alpha diff --git a/src/client/java/dev/ishaanko/worldarchive/runtime/BackgroundBackupWarnings.java b/src/client/java/dev/ishaanko/worldarchive/runtime/BackgroundBackupWarnings.java index 89dadee..db60167 100644 --- a/src/client/java/dev/ishaanko/worldarchive/runtime/BackgroundBackupWarnings.java +++ b/src/client/java/dev/ishaanko/worldarchive/runtime/BackgroundBackupWarnings.java @@ -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 { @@ -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 warning( diff --git a/src/client/java/dev/ishaanko/worldarchive/runtime/RuntimeLifecycle.java b/src/client/java/dev/ishaanko/worldarchive/runtime/RuntimeLifecycle.java index 29c657d..484e81f 100644 --- a/src/client/java/dev/ishaanko/worldarchive/runtime/RuntimeLifecycle.java +++ b/src/client/java/dev/ishaanko/worldarchive/runtime/RuntimeLifecycle.java @@ -212,7 +212,35 @@ CompletionStage 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 cancellableResult(PendingLiveBackup pending) { + CompletableFuture 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) { @@ -421,7 +449,7 @@ private void completeStoppedServer( runtime.beginBackupProgress( BackgroundBackupWarnings.worldExitStartedMessage(), exit.result(), - () -> cancelExitBackup(exit)); + () -> cancelLiveBackup(exit)); boolean quitting; synchronized (lock) { quitting = clientStopping; @@ -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 running = pending.requestCancel(); if (running != null) { runtime.submit(() -> running.cancel(true)); diff --git a/src/client/java/dev/ishaanko/worldarchive/ui/BackupClientFacade.java b/src/client/java/dev/ishaanko/worldarchive/ui/BackupClientFacade.java index 54808cc..a7f7d89 100644 --- a/src/client/java/dev/ishaanko/worldarchive/ui/BackupClientFacade.java +++ b/src/client/java/dev/ishaanko/worldarchive/ui/BackupClientFacade.java @@ -29,7 +29,10 @@ public interface BackupClientFacade { CompletionStage> 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 createManualBackup( BackupWorldContext world, Optional label, diff --git a/src/client/java/dev/ishaanko/worldarchive/ui/BackupOperationScreen.java b/src/client/java/dev/ishaanko/worldarchive/ui/BackupOperationScreen.java index ccc846b..e8adef7 100644 --- a/src/client/java/dev/ishaanko/worldarchive/ui/BackupOperationScreen.java +++ b/src/client/java/dev/ishaanko/worldarchive/ui/BackupOperationScreen.java @@ -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; @@ -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; @@ -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 extends Screen { private static final int BAR_BACKGROUND = 0xFF303030; @@ -44,8 +49,15 @@ final class BackupOperationScreen extends Screen { private final SuccessHandler successHandler; + private final boolean cancellable; + private final AtomicReference queuedProgress = new AtomicReference<>(); + // The running operation's future, kept so the Cancel button can stop it. + private CompletableFuture runningOperation; + + private boolean cancelling; + private ProgressState progress; private Presentation presentation = Presentation.running("Queued"); @@ -64,11 +76,13 @@ private BackupOperationScreen( Screen parent, String title, OperationStarter starter, - SuccessHandler successHandler) { + SuccessHandler 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( @@ -88,7 +102,8 @@ static BackupOperationScreen 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. */ @@ -104,7 +119,7 @@ static BackupOperationScreen> deleteBatch( case FAILED -> ChatFormatting.RED; }; return new Presentation(summary.headline(), summary.details(), color); - }); + }, false); } static BackupOperationScreen restore( @@ -119,7 +134,7 @@ static BackupOperationScreen restore( "Restore completed", List.of("Created " + result.restoredWorldDirectory().getFileName()), ChatFormatting.GREEN); - }); + }, false); } @Override @@ -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()) @@ -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 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 operation; try { @@ -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; @@ -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 details = retryAvailable diff --git a/src/main/java/dev/ishaanko/worldarchive/core/AsyncTasks.java b/src/main/java/dev/ishaanko/worldarchive/core/AsyncTasks.java index 43c8ebe..7b550d6 100644 --- a/src/main/java/dev/ishaanko/worldarchive/core/AsyncTasks.java +++ b/src/main/java/dev/ishaanko/worldarchive/core/AsyncTasks.java @@ -44,6 +44,12 @@ public static InterruptibleFuture 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 run( Executor executor, Runnable operation) { diff --git a/src/main/resources/assets/worldarchive/lang/en_us.json b/src/main/resources/assets/worldarchive/lang/en_us.json index 43a460e..7585476 100644 --- a/src/main/resources/assets/worldarchive/lang/en_us.json +++ b/src/main/resources/assets/worldarchive/lang/en_us.json @@ -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...", diff --git a/src/test/java/dev/ishaanko/worldarchive/core/AsyncTasksTest.java b/src/test/java/dev/ishaanko/worldarchive/core/AsyncTasksTest.java index a7dbd52..93d63b4 100644 --- a/src/test/java/dev/ishaanko/worldarchive/core/AsyncTasksTest.java +++ b/src/test/java/dev/ishaanko/worldarchive/core/AsyncTasksTest.java @@ -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( diff --git a/src/test/java/dev/ishaanko/worldarchive/storage/zip/ZipBackupCancellationTest.java b/src/test/java/dev/ishaanko/worldarchive/storage/zip/ZipBackupCancellationTest.java new file mode 100644 index 0000000..b0091b1 --- /dev/null +++ b/src/test/java/dev/ishaanko/worldarchive/storage/zip/ZipBackupCancellationTest.java @@ -0,0 +1,111 @@ +package dev.ishaanko.worldarchive.storage.zip; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.ishaanko.worldarchive.catalog.FileBackupCatalog; +import dev.ishaanko.worldarchive.core.BackupDestinationSelector; +import dev.ishaanko.worldarchive.core.BackupMaintenanceService; +import dev.ishaanko.worldarchive.core.CreateBackupRequest; +import dev.ishaanko.worldarchive.core.FileSystemBackupCaptureFactory; +import dev.ishaanko.worldarchive.core.FileWorldInventoryStore; +import dev.ishaanko.worldarchive.core.ProgressListener; +import dev.ishaanko.worldarchive.core.SerializedBackupCoordinator; +import dev.ishaanko.worldarchive.model.BackupResult; +import dev.ishaanko.worldarchive.model.BackupTrigger; +import dev.ishaanko.worldarchive.model.WorldId; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** A backup cancelled while the ZIP is written leaves no capture, archive, or catalog entry. */ +final class ZipBackupCancellationTest { + @TempDir + Path temporaryDirectory; + + @Test + void cancelDuringArchiveWriteLeavesNoFilesBehind() throws Exception { + Path world = Files.createDirectory(temporaryDirectory.resolve("world")); + Files.writeString(world.resolve("level.dat"), "level"); + Files.writeString( + Files.createDirectory(world.resolve("region")).resolve("r.0.0.mca"), "chunks"); + Path captures = temporaryDirectory.resolve("captures"); + Path archives = temporaryDirectory.resolve("archives"); + CountDownLatch writing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ZipStoreHooks pauseWrite = new ZipStoreHooks() { + @Override + public void archiveCompleted(Path partialArchive) throws IOException { + writing.countDown(); + try { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("ZIP write was interrupted"); + } + } + }; + FileBackupCatalog catalog = new FileBackupCatalog(temporaryDirectory.resolve("catalog.json")); + try (ExecutorService executor = Executors.newCachedThreadPool()) { + SerializedBackupCoordinator coordinator = new SerializedBackupCoordinator( + catalog, + new FileSystemBackupCaptureFactory(captures), + new FileWorldInventoryStore(temporaryDirectory.resolve("inventories")), + BackupDestinationSelector.fixed(List.of(new ZipBackupBackend( + new ZipBackupStore(archives, pauseWrite), executor))), + unusedMaintenance(), + executor, + Clock.systemUTC()); + WorldId worldId = WorldId.create(); + CompletionStage operation = coordinator.createBackup( + new CreateBackupRequest(worldId, world, "Cancelled", BackupTrigger.MANUAL), + ProgressListener.NO_OP); + assertTrue(writing.await(10, TimeUnit.SECONDS)); + + assertTrue(operation.toCompletableFuture().cancel(true)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (coordinator.isBusy(worldId)) { + assertTrue(System.nanoTime() < deadline, "Cancelled backup did not settle"); + Thread.sleep(10); + } + } + + assertEquals(List.of(), regularFiles(captures)); + assertEquals(List.of(), regularFiles(archives)); + assertEquals(List.of(), catalog.listAll()); + } + + /** Every file under the root except the store's permanent per-folder operation lock. */ + private static List regularFiles(Path root) throws IOException { + if (!Files.isDirectory(root)) { + return List.of(); + } + try (Stream files = Files.walk(root)) { + return files.filter(Files::isRegularFile) + .filter(file -> !file.getFileName().toString() + .equals(ZipBackupStore.OPERATION_LOCK_NAME)) + .toList(); + } + } + + private static BackupMaintenanceService unusedMaintenance() { + return (BackupMaintenanceService) Proxy.newProxyInstance( + BackupMaintenanceService.class.getClassLoader(), + new Class[] {BackupMaintenanceService.class}, + (proxy, method, args) -> CompletableFuture.failedFuture( + new UnsupportedOperationException(method.getName()))); + } +}