补齐异步取消终态语义

This commit is contained in:
张家训
2026-08-01 17:29:03 +08:00
parent cd1218b575
commit 7867304e07
8 changed files with 224 additions and 10 deletions
@@ -6,6 +6,7 @@ import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
@@ -45,16 +46,27 @@ public final class AsyncOperationController<T> {
try {
T result = task.execute(context);
uiDispatcher.accept(() -> {
publish(stateMachine.succeed(
cancellationRequested.get() ? "已停止" : "操作完成"));
publish(cancellationRequested.get()
? stateMachine.cancel()
: stateMachine.succeed("操作完成"));
running.set(false);
onSuccess.accept(result);
});
} catch (Throwable error) {
if (error instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
uiDispatcher.accept(() -> {
publish(stateMachine.fail(readableMessage(error)));
boolean cancelled = cancellationRequested.get()
&& (error instanceof CancellationException
|| error instanceof InterruptedException);
publish(cancelled
? stateMachine.cancel()
: stateMachine.fail(readableMessage(error)));
running.set(false);
onFailure.accept(error);
if (!cancelled) {
onFailure.accept(error);
}
});
}
});
@@ -80,7 +92,8 @@ public final class AsyncOperationController<T> {
private void prepareStart(String message) {
OperationState current = stateMachine.snapshot().state();
if (current == OperationState.SUCCEEDED || current == OperationState.FAILED) {
if (current == OperationState.SUCCEEDED || current == OperationState.FAILED
|| current == OperationState.CANCELLED) {
stateMachine.reset();
}
cancellationRequested.set(false);
@@ -4,6 +4,7 @@ public enum OperationState {
IDLE,
RUNNING,
CANCELLING,
CANCELLED,
SUCCEEDED,
FAILED
}
@@ -30,13 +30,19 @@ public final class OperationStateMachine {
return update(OperationState.SUCCEEDED, message, 1);
}
public synchronized OperationSnapshot cancel() {
requireState(OperationState.CANCELLING);
return update(OperationState.CANCELLED, "已取消", snapshot.progress());
}
public synchronized OperationSnapshot fail(String message) {
requireState(OperationState.RUNNING, OperationState.CANCELLING);
return update(OperationState.FAILED, message, snapshot.progress());
}
public synchronized OperationSnapshot reset() {
requireState(OperationState.SUCCEEDED, OperationState.FAILED);
requireState(OperationState.SUCCEEDED, OperationState.FAILED,
OperationState.CANCELLED);
snapshot = OperationSnapshot.idle();
return snapshot;
}
@@ -122,6 +122,7 @@ public final class OperationStatusPane extends HBox {
case IDLE -> "就绪";
case RUNNING -> "处理中…";
case CANCELLING -> "正在停止…";
case CANCELLED -> "已取消";
case SUCCEEDED -> "操作完成";
case FAILED -> "操作失败";
};
@@ -127,6 +127,33 @@ class SharedPaneAccessibilityTest {
});
}
@Test
void cancelledStatusShowsTerminalMessageWithoutSuccessStyleOrCancelButton()
throws Exception {
runFx(() -> {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AsyncOperationController<Void> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
OperationStatusPane pane = new OperationStatusPane(controller, true);
controller.start("导出中", context -> null, ignored -> { });
Button cancelButton = pane.getChildren().stream()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.findFirst()
.orElseThrow();
cancelButton.fire();
pendingOperation.get().run();
assertEquals(OperationState.CANCELLED, controller.snapshot().state());
assertTrue(pane.getAccessibleText().contains("已取消"));
assertFalse(cancelButton.isVisible());
assertFalse(cancelButton.isManaged());
assertFalse(pane.getStyleClass().contains("operation-status-success"));
assertFalse(pane.getStyleClass().contains("operation-status-error"));
});
}
@Test
void enterOpensSelectedHistoryResult() throws Exception {
runFx(() -> {
@@ -4,12 +4,16 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AsyncOperationControllerTest {
@@ -63,27 +67,140 @@ class AsyncOperationControllerTest {
}
@Test
void cancellationIsVisibleToRunningTask() throws Exception {
void cooperativeCancellationPublishesCancelledAndPreservesReturnedResult() throws Exception {
AsyncOperationController<String> controller =
new AsyncOperationController<>(executor, Runnable::run);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch observedCancellation = new CountDownLatch(1);
CountDownLatch finished = new CountDownLatch(1);
AtomicReference<String> result = new AtomicReference<>();
controller.start("运行中", context -> {
started.countDown();
while (!context.isCancellationRequested()) {
Thread.onSpinWait();
}
observedCancellation.countDown();
return "cancelled";
}, ignored -> finished.countDown());
return "valid-result";
}, value -> {
result.set(value);
finished.countDown();
});
assertTrue(started.await(2, TimeUnit.SECONDS));
controller.cancel();
assertTrue(observedCancellation.await(2, TimeUnit.SECONDS));
assertTrue(finished.await(2, TimeUnit.SECONDS));
assertEquals(OperationState.SUCCEEDED, controller.snapshot().state());
assertEquals("valid-result", result.get());
assertEquals(OperationState.CANCELLED, controller.snapshot().state());
assertEquals("已取消", controller.snapshot().message());
assertFalse(controller.runningProperty().get());
}
@Test
void cancellationExceptionAfterRequestPublishesCancelledWithoutFailureCallback() {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
AsyncOperationController<String> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
controller.start("运行中", context -> {
if (context.isCancellationRequested()) {
throw new CancellationException("stopped");
}
return "unexpected";
}, ignored -> { }, failure::set);
controller.cancel();
pendingOperation.get().run();
assertEquals(OperationState.CANCELLED, controller.snapshot().state());
assertEquals("已取消", controller.snapshot().message());
assertNull(failure.get());
assertFalse(controller.runningProperty().get());
}
@Test
void unexpectedErrorAfterCancellationRemainsFailed() {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
AsyncOperationController<String> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
controller.start("运行中", context -> {
throw new IllegalStateException("broken after cancel");
}, ignored -> { }, failure::set);
controller.cancel();
pendingOperation.get().run();
assertEquals(OperationState.FAILED, controller.snapshot().state());
assertEquals("broken after cancel", controller.snapshot().message());
assertEquals("broken after cancel", failure.get().getMessage());
assertFalse(controller.runningProperty().get());
}
@Test
void cancellationExceptionWithoutRequestRemainsFailed() {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
AsyncOperationController<String> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
controller.start("运行中", context -> {
throw new CancellationException("unexpected cancellation");
}, ignored -> { }, failure::set);
pendingOperation.get().run();
assertEquals(OperationState.FAILED, controller.snapshot().state());
assertEquals("unexpected cancellation", controller.snapshot().message());
assertTrue(failure.get() instanceof CancellationException);
}
@Test
void interruptedCancellationPreservesInterruptFlagAndPublishesCancelled() {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AtomicBoolean failureCalled = new AtomicBoolean();
AsyncOperationController<String> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
controller.start("运行中", context -> {
throw new InterruptedException("interrupted");
}, ignored -> { }, ignored -> failureCalled.set(true));
controller.cancel();
try {
pendingOperation.get().run();
assertTrue(Thread.currentThread().isInterrupted());
assertEquals(OperationState.CANCELLED, controller.snapshot().state());
assertFalse(failureCalled.get());
} finally {
Thread.interrupted();
}
}
@Test
void unexpectedInterruptionPreservesInterruptFlagAndRemainsFailed() {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
AsyncOperationController<String> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
controller.start("运行中", context -> {
throw new InterruptedException("unexpected interrupt");
}, ignored -> { }, failure::set);
try {
pendingOperation.get().run();
assertTrue(Thread.currentThread().isInterrupted());
assertEquals(OperationState.FAILED, controller.snapshot().state());
assertTrue(failure.get() instanceof InterruptedException);
} finally {
Thread.interrupted();
}
}
}
@@ -165,6 +165,31 @@ class LoadingButtonTextTest {
});
}
@Test
void cancelledOperationRestoresIdleTextGraphicAndAccessibility() throws Exception {
runFx(() -> {
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
AsyncOperationController<Void> controller =
new AsyncOperationController<>(pendingOperation::set, Runnable::run);
Rectangle originalGraphic = new Rectangle(12, 12);
Button button = new Button("开始扫描", originalGraphic);
button.setAccessibleText("扫描文件");
LoadingButtonBinder.bind(button, controller, "开始扫描");
controller.start("扫描中", context -> null, ignored -> { });
controller.cancel();
pendingOperation.get().run();
assertEquals(OperationState.CANCELLED, controller.snapshot().state());
assertEquals("开始扫描", button.getText());
assertSame(originalGraphic, button.getGraphic());
assertEquals("扫描文件", button.getAccessibleText());
assertFalse(button.isDisable());
assertFalse(button.getPseudoClassStates().contains(
PseudoClass.getPseudoClass("running")));
});
}
private static void runFx(ThrowingRunnable action) throws Exception {
FutureTask<Void> task = new FutureTask<>(() -> {
action.run();
@@ -52,4 +52,28 @@ class OperationStateMachineTest {
machine.start("再次运行");
assertEquals(OperationState.RUNNING, machine.snapshot().state());
}
@Test
void cancellationRequestMustReachCancelledBeforeReset() {
OperationStateMachine machine = new OperationStateMachine();
machine.start("运行");
machine.requestCancel();
machine.cancel();
assertEquals(OperationState.CANCELLED, machine.snapshot().state());
assertEquals("已取消", machine.snapshot().message());
machine.reset();
machine.start("再次运行");
assertEquals(OperationState.RUNNING, machine.snapshot().state());
}
@Test
void runningOperationCannotSkipCancellingState() {
OperationStateMachine machine = new OperationStateMachine();
machine.start("运行");
assertThrows(IllegalStateException.class, machine::cancel);
}
}