补齐异步取消终态语义
This commit is contained in:
@@ -6,6 +6,7 @@ import javafx.beans.property.ReadOnlyObjectProperty;
|
|||||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -45,16 +46,27 @@ public final class AsyncOperationController<T> {
|
|||||||
try {
|
try {
|
||||||
T result = task.execute(context);
|
T result = task.execute(context);
|
||||||
uiDispatcher.accept(() -> {
|
uiDispatcher.accept(() -> {
|
||||||
publish(stateMachine.succeed(
|
publish(cancellationRequested.get()
|
||||||
cancellationRequested.get() ? "已停止" : "操作完成"));
|
? stateMachine.cancel()
|
||||||
|
: stateMachine.succeed("操作完成"));
|
||||||
running.set(false);
|
running.set(false);
|
||||||
onSuccess.accept(result);
|
onSuccess.accept(result);
|
||||||
});
|
});
|
||||||
} catch (Throwable error) {
|
} catch (Throwable error) {
|
||||||
|
if (error instanceof InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
uiDispatcher.accept(() -> {
|
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);
|
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) {
|
private void prepareStart(String message) {
|
||||||
OperationState current = stateMachine.snapshot().state();
|
OperationState current = stateMachine.snapshot().state();
|
||||||
if (current == OperationState.SUCCEEDED || current == OperationState.FAILED) {
|
if (current == OperationState.SUCCEEDED || current == OperationState.FAILED
|
||||||
|
|| current == OperationState.CANCELLED) {
|
||||||
stateMachine.reset();
|
stateMachine.reset();
|
||||||
}
|
}
|
||||||
cancellationRequested.set(false);
|
cancellationRequested.set(false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public enum OperationState {
|
|||||||
IDLE,
|
IDLE,
|
||||||
RUNNING,
|
RUNNING,
|
||||||
CANCELLING,
|
CANCELLING,
|
||||||
|
CANCELLED,
|
||||||
SUCCEEDED,
|
SUCCEEDED,
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,19 @@ public final class OperationStateMachine {
|
|||||||
return update(OperationState.SUCCEEDED, message, 1);
|
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) {
|
public synchronized OperationSnapshot fail(String message) {
|
||||||
requireState(OperationState.RUNNING, OperationState.CANCELLING);
|
requireState(OperationState.RUNNING, OperationState.CANCELLING);
|
||||||
return update(OperationState.FAILED, message, snapshot.progress());
|
return update(OperationState.FAILED, message, snapshot.progress());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized OperationSnapshot reset() {
|
public synchronized OperationSnapshot reset() {
|
||||||
requireState(OperationState.SUCCEEDED, OperationState.FAILED);
|
requireState(OperationState.SUCCEEDED, OperationState.FAILED,
|
||||||
|
OperationState.CANCELLED);
|
||||||
snapshot = OperationSnapshot.idle();
|
snapshot = OperationSnapshot.idle();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ public final class OperationStatusPane extends HBox {
|
|||||||
case IDLE -> "就绪";
|
case IDLE -> "就绪";
|
||||||
case RUNNING -> "处理中…";
|
case RUNNING -> "处理中…";
|
||||||
case CANCELLING -> "正在停止…";
|
case CANCELLING -> "正在停止…";
|
||||||
|
case CANCELLED -> "已取消";
|
||||||
case SUCCEEDED -> "操作完成";
|
case SUCCEEDED -> "操作完成";
|
||||||
case FAILED -> "操作失败";
|
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
|
@Test
|
||||||
void enterOpensSelectedHistoryResult() throws Exception {
|
void enterOpensSelectedHistoryResult() throws Exception {
|
||||||
runFx(() -> {
|
runFx(() -> {
|
||||||
|
|||||||
+121
-4
@@ -4,12 +4,16 @@ import org.junit.jupiter.api.AfterEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class AsyncOperationControllerTest {
|
class AsyncOperationControllerTest {
|
||||||
@@ -63,27 +67,140 @@ class AsyncOperationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cancellationIsVisibleToRunningTask() throws Exception {
|
void cooperativeCancellationPublishesCancelledAndPreservesReturnedResult() throws Exception {
|
||||||
AsyncOperationController<String> controller =
|
AsyncOperationController<String> controller =
|
||||||
new AsyncOperationController<>(executor, Runnable::run);
|
new AsyncOperationController<>(executor, Runnable::run);
|
||||||
CountDownLatch started = new CountDownLatch(1);
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
CountDownLatch observedCancellation = new CountDownLatch(1);
|
CountDownLatch observedCancellation = new CountDownLatch(1);
|
||||||
CountDownLatch finished = new CountDownLatch(1);
|
CountDownLatch finished = new CountDownLatch(1);
|
||||||
|
|
||||||
|
AtomicReference<String> result = new AtomicReference<>();
|
||||||
controller.start("运行中", context -> {
|
controller.start("运行中", context -> {
|
||||||
started.countDown();
|
started.countDown();
|
||||||
while (!context.isCancellationRequested()) {
|
while (!context.isCancellationRequested()) {
|
||||||
Thread.onSpinWait();
|
Thread.onSpinWait();
|
||||||
}
|
}
|
||||||
observedCancellation.countDown();
|
observedCancellation.countDown();
|
||||||
return "cancelled";
|
return "valid-result";
|
||||||
}, ignored -> finished.countDown());
|
}, value -> {
|
||||||
|
result.set(value);
|
||||||
|
finished.countDown();
|
||||||
|
});
|
||||||
|
|
||||||
assertTrue(started.await(2, TimeUnit.SECONDS));
|
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||||
controller.cancel();
|
controller.cancel();
|
||||||
|
|
||||||
assertTrue(observedCancellation.await(2, TimeUnit.SECONDS));
|
assertTrue(observedCancellation.await(2, TimeUnit.SECONDS));
|
||||||
assertTrue(finished.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 {
|
private static void runFx(ThrowingRunnable action) throws Exception {
|
||||||
FutureTask<Void> task = new FutureTask<>(() -> {
|
FutureTask<Void> task = new FutureTask<>(() -> {
|
||||||
action.run();
|
action.run();
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ class OperationStateMachineTest {
|
|||||||
machine.start("再次运行");
|
machine.start("再次运行");
|
||||||
assertEquals(OperationState.RUNNING, machine.snapshot().state());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user