diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/channels/ShellChannel.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/channels/ShellChannel.java index 70170b3..9b34167 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/channels/ShellChannel.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/channels/ShellChannel.java @@ -63,7 +63,8 @@ public void bind(KernelConnectionProperties connProps) { ShellReplyEnvironment env = connection.prepareReplyEnv(this, message); try { handler.handle(env, message); - } catch (Exception e) { + } catch (Throwable e) { + // last-resort guard: nothing a handler throws may kill the channel loop logger.warn("Unhandled exception handling {}. {} - {}", message.getHeader().getType().getName(), e.getClass().getSimpleName(), diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java index 141cdbe..aa514b9 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java @@ -425,7 +425,10 @@ protected synchronized void handleExecuteRequest(ShellReplyEnvironment env, Mess } env.defer().reply(new ExecuteReply(count, Collections.emptyMap())); - } catch (Exception e) { + } catch (Throwable e) { + // Throwable, not Exception: an escaping Error (OutOfMemoryError, LinkageError, + // AssertionError from non-JShell evaluators, ...) would otherwise kill the + // shell channel loop and permanently hang the kernel ErrorReply error = ErrorReply.of(e); error.setExecutionCount(count); env.publish(PublishError.of(e, this::formatError)); @@ -439,7 +442,7 @@ protected void handleInspectRequest(ShellReplyEnvironment env, Message 0); env.reply(new InspectReply(inspection != null, DisplayData.emptyIfNull(inspection))); - } catch (Exception e) { + } catch (Throwable e) { env.replyError(InspectReply.MESSAGE_TYPE.error(), ErrorReply.of(e)); } } @@ -453,7 +456,7 @@ protected void handleCompleteRequest(ShellReplyEnvironment env, Message format(Exception e); + List format(Throwable t); } diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/publish/PublishError.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/publish/PublishError.java index 9cef36d..5beb2b0 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/publish/PublishError.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/publish/PublishError.java @@ -13,7 +13,7 @@ public class PublishError implements ContentType { public static final MessageType MESSAGE_TYPE = MessageType.PUBLISH_ERROR; - public static PublishError of(Exception exception, ErrorFormatter formatter) { + public static PublishError of(Throwable exception, ErrorFormatter formatter) { String name = exception.getClass().getSimpleName(); String msg = exception.getLocalizedMessage(); List stacktrace = formatter.format(exception); diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/reply/ErrorReply.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/reply/ErrorReply.java index 68f4672..93cde38 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/reply/ErrorReply.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/messages/reply/ErrorReply.java @@ -14,7 +14,7 @@ public MessageType getRequestType() { return MessageType.UNKNOWN; } - public static ErrorReply of(Exception exception) { + public static ErrorReply of(Throwable exception) { String name = exception.getClass().getSimpleName(); String msg = exception.getLocalizedMessage(); List stacktrace = Arrays.stream(exception.getStackTrace()) diff --git a/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/BaseKernelErrorHandlingTest.java b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/BaseKernelErrorHandlingTest.java new file mode 100644 index 0000000..66f1b2a --- /dev/null +++ b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/BaseKernelErrorHandlingTest.java @@ -0,0 +1,154 @@ +package org.dflib.jjava.jupyter.kernel; + +import org.dflib.jjava.jupyter.channels.ShellReplyEnvironment; +import org.dflib.jjava.jupyter.kernel.comm.CommManager; +import org.dflib.jjava.jupyter.kernel.display.DisplayData; +import org.dflib.jjava.jupyter.kernel.display.Renderer; +import org.dflib.jjava.jupyter.kernel.magic.MagicTranspiler; +import org.dflib.jjava.jupyter.kernel.magic.MagicsRegistry; +import org.dflib.jjava.jupyter.kernel.magic.MagicsResolver; +import org.dflib.jjava.jupyter.kernel.util.StringStyler; +import org.dflib.jjava.jupyter.messages.Message; +import org.dflib.jjava.jupyter.messages.MessageType; +import org.dflib.jjava.jupyter.messages.publish.PublishError; +import org.dflib.jjava.jupyter.messages.reply.ErrorReply; +import org.dflib.jjava.jupyter.messages.request.ExecuteRequest; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The execute handler must convert anything thrown by evaluation — Errors + * included, not just Exceptions — into a published error and an error reply. + * An escaping Throwable would kill the shell channel loop and permanently + * hang the kernel (see gh-132). + */ +public class BaseKernelErrorHandlingTest { + + @Test + public void errorFromEvalProducesErrorReply() { + assertErrorHandled(new AssertionError("boom"), "AssertionError"); + } + + @Test + public void linkageErrorFromEvalProducesErrorReply() { + assertErrorHandled(new NoClassDefFoundError("com/example/Gone"), "NoClassDefFoundError"); + } + + @Test + public void exceptionFromEvalProducesErrorReply() { + assertErrorHandled(new RuntimeException("boom"), "RuntimeException"); + } + + private void assertErrorHandled(Throwable thrown, String expectedName) { + ThrowingKernel kernel = new ThrowingKernel(thrown); + CapturingEnv env = new CapturingEnv(); + Message message = new Message<>(null, MessageType.EXECUTE_REQUEST, + new ExecuteRequest("1 + 1", false, false, Collections.emptyMap(), false, false)); + + kernel.handleExecuteRequest(env, message); + env.resolveDeferrals(); + + List published = contentsOf(env.published); + List replied = contentsOf(env.replied); + + PublishError publishedError = (PublishError) published.stream() + .filter(c -> c instanceof PublishError) + .findFirst() + .orElseThrow(() -> new AssertionError("no error published, got: " + published)); + assertEquals(expectedName, publishedError.getErrorName()); + + ErrorReply reply = (ErrorReply) replied.stream() + .filter(c -> c instanceof ErrorReply) + .findFirst() + .orElseThrow(() -> new AssertionError("no error reply sent, got: " + replied)); + assertEquals(expectedName, reply.getErrorName()); + assertTrue(reply.getErrorMessage().contains("boom") || reply.getErrorMessage().contains("Gone")); + } + + private static List contentsOf(List> messages) { + List contents = new ArrayList<>(); + for (Message message : messages) { + contents.add(message.getContent()); + } + return contents; + } + + private static class ThrowingKernel extends BaseKernel { + + private final Throwable toThrow; + + ThrowingKernel(Throwable toThrow) { + super("test", "0", + new LanguageInfo.Builder("test").build(), + Collections.emptyList(), + null, + new JupyterIO(StandardCharsets.UTF_8), + new CommManager(), + new Renderer(), + new MagicsResolver("%", "%%", new MagicTranspiler()), + new MagicsRegistry(), + false, + new StringStyler.Builder().build()); + this.toThrow = toThrow; + } + + @Override + protected Object doEval(String source) { + if (toThrow instanceof RuntimeException) { + throw (RuntimeException) toThrow; + } + if (toThrow instanceof Error) { + throw (Error) toThrow; + } + throw new IllegalStateException(toThrow); + } + + @Override + public DisplayData inspect(String code, int at, boolean extraDetail) { + return null; + } + + @Override + public ReplacementOptions complete(String code, int at) { + return null; + } + + @Override + public String isComplete(String code) { + return IS_COMPLETE_MAYBE; + } + } + + private static class CapturingEnv extends ShellReplyEnvironment { + + final List> published = new ArrayList<>(); + final List> replied = new ArrayList<>(); + + CapturingEnv() { + super(null, null, null, null); + } + + @Override + public void publish(Message msg) { + published.add(msg); + } + + @Override + public void reply(Message msg) { + replied.add(msg); + } + + @Override + public ShellReplyEnvironment defer() { + // captures send immediately, so defer-then-send collapses to send + return this; + } + } +}