From c6ceee34a7ed5304cd82e1ea317cc22b382f60f8 Mon Sep 17 00:00:00 2001
From: dgelessus <dgelessus@users.noreply.github.com>
Date: Thu, 17 Sep 2020 19:05:30 +0200
Subject: [PATCH] Run commands in a separate executor instead of the main
 kernel thread

This fixes a bug where interrupting a command would terminate the main
event loop thread, causing the kernel to stop responding. This happened
because the interrupt handling sent a Java interrupt to the thread
executing the current command, which was always the main event loop
thread. Now commands are executed on a separate executor, which can
safely have Java interrupts sent to it without affecting any
jupyter-jvm-basekernel threads.
---
 CHANGELOG.md                                  |  1 +
 .../java/de/prob2/jupyter/ProBKernel.java     | 45 ++++++++++++++-----
 .../prob2/jupyter/commands/TimeCommand.java   |  2 +-
 3 files changed, 37 insertions(+), 11 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 525292e..d26e051 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 * Significantly refactored the logic for parsing commands and their arguments.
 	* This is an internal change and should not affect any user-visible behavior. That is, all inputs that were accepted by previous versions should still be accepted - if any previously valid inputs are no longer accepted, this is a bug.
 	* As a side effect, the inspection and code completion features now work better in a few edge cases.
+* Fixed a bug where interrupting a command could make the kernel completely stop responding, requiring a manual restart.
 
 ## [1.2.0](https://www3.hhu.de/stups/downloads/prob2-jupyter/prob2-jupyter-kernel-1.2.0-all.jar)
 
diff --git a/src/main/java/de/prob2/jupyter/ProBKernel.java b/src/main/java/de/prob2/jupyter/ProBKernel.java
index 3ccfae2..66ffbd6 100644
--- a/src/main/java/de/prob2/jupyter/ProBKernel.java
+++ b/src/main/java/de/prob2/jupyter/ProBKernel.java
@@ -16,6 +16,11 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Properties;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.regex.Matcher;
@@ -23,6 +28,7 @@ import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.util.concurrent.Futures;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
@@ -231,7 +237,8 @@ public final class ProBKernel extends BaseKernel {
 	private final @NotNull ReusableAnimator animator;
 	
 	private final @NotNull Map<@NotNull String, @NotNull Command> commands;
-	private final @NotNull AtomicReference<@Nullable Thread> currentEvalThread;
+	private final @NotNull ExecutorService commandExecutor;
+	private final @NotNull AtomicReference<@NotNull Future<@Nullable DisplayData>> currentCommandFuture;
 	private final @NotNull Map<@NotNull String, @NotNull String> variables;
 	
 	private @NotNull Path currentMachineDirectory;
@@ -251,7 +258,8 @@ public final class ProBKernel extends BaseKernel {
 			.map(injector::getInstance)
 			.collect(Collectors.toMap(Command::getName, cmd -> cmd));
 		
-		this.currentEvalThread = new AtomicReference<>(null);
+		this.commandExecutor = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "Command Executor"));
+		this.currentCommandFuture = new AtomicReference<>(Futures.immediateCancelledFuture());
 		this.variables = new HashMap<>();
 		
 		this.currentMachineDirectory = Paths.get("");
@@ -469,16 +477,35 @@ public final class ProBKernel extends BaseKernel {
 		return this.executeCommand(split.getName(), split.getArgString());
 	}
 	
+	public @Nullable DisplayData evalOnCurrentThread(final @NotNull String expr) {
+		return this.evalInternal(new PositionedString(expr, 0));
+	}
+	
 	@Override
 	public @Nullable DisplayData eval(final String expr) {
 		assert expr != null;
 		
-		this.currentEvalThread.set(Thread.currentThread());
+		final Future<DisplayData> oldFuture = this.currentCommandFuture.get();
+		assert oldFuture.isDone() || oldFuture.isCancelled();
 		
+		final Future<DisplayData> future = this.commandExecutor.submit(() -> this.evalOnCurrentThread(expr));
+		this.currentCommandFuture.set(future);
 		try {
-			return evalInternal(new PositionedString(expr, 0));
-		} finally {
-			this.currentEvalThread.set(null);
+			return future.get();
+		} catch (final InterruptedException e) {
+			LOGGER.error("Main kernel thread (event loop thread) interrupted while executing command", e);
+			Thread.currentThread().interrupt();
+			return null;
+		} catch (final CancellationException e) {
+			LOGGER.info("Command execution cancelled by user", e);
+			throw new UserErrorException("Interrupted by user");
+		} catch (final ExecutionException e) {
+			LOGGER.warn("Exception while executing command", e);
+			if (e.getCause() instanceof RuntimeException) {
+				throw (RuntimeException)e.getCause();
+			} else {
+				throw new IllegalStateException("Checked exception thrown by command, this should never happen", e);
+			}
 		}
 	}
 	
@@ -612,14 +639,12 @@ public final class ProBKernel extends BaseKernel {
 	@Override
 	public void onShutdown(final boolean isRestarting) {
 		this.animationSelector.getCurrentTrace().getStateSpace().kill();
+		this.commandExecutor.shutdownNow();
 	}
 	
 	@Override
 	public void interrupt() {
-		final Thread evalThread = this.currentEvalThread.get();
-		if (evalThread != null) {
-			evalThread.interrupt();
-		}
+		this.currentCommandFuture.get().cancel(true);
 		this.animationSelector.getCurrentTrace().getStateSpace().sendInterrupt();
 	}
 	
diff --git a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
index a7cf5c9..7fb5552 100644
--- a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
@@ -63,7 +63,7 @@ public final class TimeCommand implements Command {
 	public @Nullable DisplayData run(final @NotNull ParsedArguments args) {
 		final ProBKernel kernel = this.injector.getInstance(ProBKernel.class);
 		final Stopwatch stopwatch = Stopwatch.createStarted();
-		final DisplayData result = kernel.eval(args.get(COMMAND_AND_ARGS_PARAM));
+		final DisplayData result = kernel.evalOnCurrentThread(args.get(COMMAND_AND_ARGS_PARAM));
 		stopwatch.stop();
 		final Duration elapsed = stopwatch.elapsed();
 		final String text = String.format("Execution time: %d.%09d seconds", elapsed.get(ChronoUnit.SECONDS), elapsed.get(ChronoUnit.NANOS));
-- 
GitLab