From 06aebee107eec96154c8fc4a4683a93a46eda1cd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 12 May 2026 12:17:56 +0300 Subject: [PATCH] playground: surface lambda runtime errors instead of swallowing them silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda body whose identifier is unresolved (e.g. com.codename1.io.Util referenced without its import) parses fine during initial script eval — the failure only happens when the listener fires. The EDT then catches the resulting EvalError, no Display error handler is registered, and the UI silently stops responding while the preview still draws. Capture a LambdaErrorHandler on each LambdaValue at construction time so event firings minutes later can still report back. The runner pushes a bridge into PlaygroundContext.reportRuntimeError, and CN1Playground appends an inline editor message (deduped per text), refreshes the markers, and flips the top bar to its failed state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/bsh/cn1/CN1LambdaSupport.java | 51 +++++++++++++++- .../main/java/bsh/cn1/GeneratedCN1Access.java | 17 +++--- ...atedAccess_com_codenameone_playground.java | 34 ++++++++++- .../codenameone/playground/CN1Playground.java | 43 ++++++++++++- .../playground/PlaygroundContext.java | 21 +++++++ .../playground/PlaygroundRunner.java | 26 ++++++++ .../playground/PlaygroundSmokeHarness.java | 61 +++++++++++++++++++ 7 files changed, 240 insertions(+), 13 deletions(-) diff --git a/scripts/cn1playground/common/src/main/java/bsh/cn1/CN1LambdaSupport.java b/scripts/cn1playground/common/src/main/java/bsh/cn1/CN1LambdaSupport.java index 68b9bfc76b..9392cc9975 100644 --- a/scripts/cn1playground/common/src/main/java/bsh/cn1/CN1LambdaSupport.java +++ b/scripts/cn1playground/common/src/main/java/bsh/cn1/CN1LambdaSupport.java @@ -9,6 +9,15 @@ public final class CN1LambdaSupport { private static final ThreadLocal CURRENT_INTERPRETER = new ThreadLocal(); private static final ThreadLocal CURRENT_NAMESPACE = new ThreadLocal(); + private static final ThreadLocal CURRENT_ERROR_HANDLER = new ThreadLocal(); + + /** Callback invoked when a lambda body raises an exception during + * {@link LambdaValue#invoke(Object[])}. Lets a host (e.g. the + * playground UI) surface runtime failures that would otherwise be + * lost to the EDT's silent exception handling. */ + public interface LambdaErrorHandler { + void onLambdaError(Throwable error, String bodySource); + } private CN1LambdaSupport() { } @@ -33,6 +42,19 @@ public static void clearCurrentNameSpace() { CURRENT_NAMESPACE.remove(); } + /** Register a {@link LambdaErrorHandler} that will be captured by any + * {@link LambdaValue} created on this thread while the handler is + * active. The captured handler stays with the lambda for its + * lifetime so errors raised on later EDT firings can still surface + * back to the host. */ + public static void pushErrorHandler(LambdaErrorHandler handler) { + CURRENT_ERROR_HANDLER.set(handler); + } + + public static void clearErrorHandler() { + CURRENT_ERROR_HANDLER.remove(); + } + public static LambdaValue lambda(String[] parameterNames, String bodySource) { Interpreter interpreter = CURRENT_INTERPRETER.get(); if (interpreter == null) { @@ -40,7 +62,8 @@ public static LambdaValue lambda(String[] parameterNames, String bodySource) { } NameSpace activeNs = CURRENT_NAMESPACE.get(); NameSpace parentNs = activeNs != null ? activeNs : interpreter.getNameSpace(); - return new LambdaValue(interpreter, parentNs, sanitizeParams(parameterNames), bodySource == null ? "" : bodySource); + return new LambdaValue(interpreter, parentNs, sanitizeParams(parameterNames), + bodySource == null ? "" : bodySource, CURRENT_ERROR_HANDLER.get()); } private static String[] sanitizeParams(String[] parameterNames) { @@ -135,12 +158,15 @@ public static final class LambdaValue implements private final NameSpace parentNameSpace; private final String[] parameterNames; private final String bodySource; + private final LambdaErrorHandler errorHandler; - LambdaValue(Interpreter interpreter, NameSpace parentNameSpace, String[] parameterNames, String bodySource) { + LambdaValue(Interpreter interpreter, NameSpace parentNameSpace, String[] parameterNames, + String bodySource, LambdaErrorHandler errorHandler) { this.interpreter = interpreter; this.parentNameSpace = parentNameSpace; this.parameterNames = parameterNames; this.bodySource = bodySource; + this.errorHandler = errorHandler; } /** Runnable adapter — zero-arg lambda. */ @@ -206,7 +232,9 @@ public Object invoke(Object[] args) throws EvalError { lambdaNs.setVariable(parameterNames[i], safeArgs[i], false); } } catch (UtilEvalError ex) { - throw ex.toEvalError(null, null); + EvalError converted = ex.toEvalError(null, null); + reportError(converted); + throw converted; } Interpreter prevInterpreter = CURRENT_INTERPRETER.get(); NameSpace prevNamespace = CURRENT_NAMESPACE.get(); @@ -216,6 +244,12 @@ public Object invoke(Object[] args) throws EvalError { synchronized (interpreter) { return Primitive.unwrap(interpreter.eval(bodySource, lambdaNs)); } + } catch (EvalError ex) { + reportError(ex); + throw ex; + } catch (RuntimeException ex) { + reportError(ex); + throw ex; } finally { if (prevInterpreter != null) { CURRENT_INTERPRETER.set(prevInterpreter); @@ -229,6 +263,17 @@ public Object invoke(Object[] args) throws EvalError { } } } + + private void reportError(Throwable error) { + if (errorHandler == null) { + return; + } + try { + errorHandler.onLambdaError(error, bodySource); + } catch (Throwable ignored) { + // Reporter failures must not displace the real lambda error. + } + } } /** Adapt a {@link LambdaValue} to a {@link java.util.function.BinaryOperator} diff --git a/scripts/cn1playground/common/src/main/java/bsh/cn1/GeneratedCN1Access.java b/scripts/cn1playground/common/src/main/java/bsh/cn1/GeneratedCN1Access.java index d9cae002f4..670a383acc 100644 --- a/scripts/cn1playground/common/src/main/java/bsh/cn1/GeneratedCN1Access.java +++ b/scripts/cn1playground/common/src/main/java/bsh/cn1/GeneratedCN1Access.java @@ -726,6 +726,7 @@ public final class GeneratedCN1Access implements CN1Access { "com.codenameone.playground.CN1Playground", "com.codenameone.playground.PlaygroundContext", "com.codenameone.playground.PlaygroundContext.Logger", + "com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter", "com.codenameone.playground.PlaygroundLambdaBridge", "com.codenameone.playground.PlaygroundListenerBridge", "com.codenameone.playground.WebsiteThemeNative", @@ -1599,8 +1600,9 @@ private static void fillMethodIndex9(Map index) { index.put("com.codename1.xml.XMLParser", splitMembers("")); index.put("com.codename1.xml.XMLWriter", splitMembers("")); index.put("com.codenameone.playground.CN1Playground", splitMembers("destroy()getTheme()init(Object)runApp()start()stop()")); - index.put("com.codenameone.playground.PlaygroundContext", splitMembers("captureShownForm(Form)clearCreatedComponents()clearPreview()clearShownForm()getCreatedComponents()getFirstCreatedComponent()getFirstCreatedForm()getHostForm()getPreviewRoot()getShownForm()getTheme()log(String)recordCreatedComponent(Component)refreshPreview()setTitle(String)debug(String)getCurrent()interceptMethodInvocation(Object, String, Object[])notifyConstructed(Object)")); + index.put("com.codenameone.playground.PlaygroundContext", splitMembers("captureShownForm(Form)clearCreatedComponents()clearPreview()clearShownForm()getCreatedComponents()getFirstCreatedComponent()getFirstCreatedForm()getHostForm()getPreviewRoot()getShownForm()getTheme()log(String)recordCreatedComponent(Component)refreshPreview()reportRuntimeError(String, Throwable)setTitle(String)debug(String)getCurrent()interceptMethodInvocation(Object, String, Object[])notifyConstructed(Object)")); index.put("com.codenameone.playground.PlaygroundContext.Logger", splitMembers("log(String)")); + index.put("com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter", splitMembers("reportRuntimeError(String, Throwable)")); index.put("com.codenameone.playground.PlaygroundLambdaBridge", splitMembers("lambda(Object[], String)lambda(String[], String)")); index.put("com.codenameone.playground.PlaygroundListenerBridge", splitMembers("actionListener(Object)networkListener(Object)onComplete(Object)runnable(Object)")); index.put("com.codenameone.playground.WebsiteThemeNative", splitMembers("isDarkMode()isSupported()notifyUiReady()")); @@ -1608,10 +1610,10 @@ private static void fillMethodIndex9(Map index) { index.put("java.io.ByteArrayOutputStream", splitMembers("")); index.put("java.io.DataInput", splitMembers("")); index.put("java.io.DataInputStream", splitMembers("")); - index.put("java.io.DataOutput", splitMembers("")); } private static void fillMethodIndex10(Map index) { + index.put("java.io.DataOutput", splitMembers("")); index.put("java.io.DataOutputStream", splitMembers("")); index.put("java.io.EOFException", splitMembers("")); index.put("java.io.Flushable", splitMembers("")); @@ -1675,10 +1677,10 @@ private static void fillMethodIndex10(Map index) { index.put("java.lang.OutOfMemoryError", splitMembers("")); index.put("java.lang.Override", splitMembers("")); index.put("java.lang.Runnable", splitMembers("")); - index.put("java.lang.Runtime", splitMembers("")); } private static void fillMethodIndex11(Map index) { + index.put("java.lang.Runtime", splitMembers("")); index.put("java.lang.RuntimeException", splitMembers("")); index.put("java.lang.SafeVarargs", splitMembers("")); index.put("java.lang.SecurityException", splitMembers("")); @@ -1742,10 +1744,10 @@ private static void fillMethodIndex11(Map index) { index.put("java.util.Comparator", splitMembers("")); index.put("java.util.ConcurrentModificationException", splitMembers("")); index.put("java.util.Date", splitMembers("")); - index.put("java.util.Deque", splitMembers("")); } private static void fillMethodIndex12(Map index) { + index.put("java.util.Deque", splitMembers("")); index.put("java.util.Dictionary", splitMembers("")); index.put("java.util.EmptyStackException", splitMembers("")); index.put("java.util.Enumeration", splitMembers("")); @@ -2476,6 +2478,7 @@ private static void fillFieldIndex9(Map index) { index.put("com.codenameone.playground.CN1Playground", splitMembers("")); index.put("com.codenameone.playground.PlaygroundContext", splitMembers("")); index.put("com.codenameone.playground.PlaygroundContext.Logger", splitMembers("")); + index.put("com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter", splitMembers("")); index.put("com.codenameone.playground.PlaygroundLambdaBridge", splitMembers("")); index.put("com.codenameone.playground.PlaygroundListenerBridge", splitMembers("")); index.put("com.codenameone.playground.WebsiteThemeNative", splitMembers("")); @@ -2483,10 +2486,10 @@ private static void fillFieldIndex9(Map index) { index.put("java.io.ByteArrayOutputStream", splitMembers("")); index.put("java.io.DataInput", splitMembers("")); index.put("java.io.DataInputStream", splitMembers("")); - index.put("java.io.DataOutput", splitMembers("")); } private static void fillFieldIndex10(Map index) { + index.put("java.io.DataOutput", splitMembers("")); index.put("java.io.DataOutputStream", splitMembers("")); index.put("java.io.EOFException", splitMembers("")); index.put("java.io.Flushable", splitMembers("")); @@ -2550,10 +2553,10 @@ private static void fillFieldIndex10(Map index) { index.put("java.lang.OutOfMemoryError", splitMembers("")); index.put("java.lang.Override", splitMembers("")); index.put("java.lang.Runnable", splitMembers("")); - index.put("java.lang.Runtime", splitMembers("")); } private static void fillFieldIndex11(Map index) { + index.put("java.lang.Runtime", splitMembers("")); index.put("java.lang.RuntimeException", splitMembers("")); index.put("java.lang.SafeVarargs", splitMembers("")); index.put("java.lang.SecurityException", splitMembers("")); @@ -2617,10 +2620,10 @@ private static void fillFieldIndex11(Map index) { index.put("java.util.Comparator", splitMembers("")); index.put("java.util.ConcurrentModificationException", splitMembers("")); index.put("java.util.Date", splitMembers("")); - index.put("java.util.Deque", splitMembers("")); } private static void fillFieldIndex12(Map index) { + index.put("java.util.Deque", splitMembers("")); index.put("java.util.Dictionary", splitMembers("")); index.put("java.util.EmptyStackException", splitMembers("")); index.put("java.util.Enumeration", splitMembers("")); diff --git a/scripts/cn1playground/common/src/main/java/bsh/cn1/gen/GeneratedAccess_com_codenameone_playground.java b/scripts/cn1playground/common/src/main/java/bsh/cn1/gen/GeneratedAccess_com_codenameone_playground.java index 534b28f9a1..e0aa52581c 100644 --- a/scripts/cn1playground/common/src/main/java/bsh/cn1/gen/GeneratedAccess_com_codenameone_playground.java +++ b/scripts/cn1playground/common/src/main/java/bsh/cn1/gen/GeneratedAccess_com_codenameone_playground.java @@ -33,6 +33,9 @@ private static Class findClassChunk0(String simpleName) { if ("Logger".equals(simpleName)) { return com.codenameone.playground.PlaygroundContext.Logger.class; } + if ("RuntimeErrorReporter".equals(simpleName)) { + return com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter.class; + } if ("PlaygroundLambdaBridge".equals(simpleName)) { return com.codenameone.playground.PlaygroundLambdaBridge.class; } @@ -51,6 +54,10 @@ public static Object construct(Class type, Object[] args) throws Exception { Object[] adaptedArgs = adaptArgs(safeArgs, new Class[]{com.codename1.ui.Form.class, com.codename1.ui.Container.class, com.codename1.ui.util.Resources.class, com.codenameone.playground.PlaygroundContext.Logger.class}, false); return new com.codenameone.playground.PlaygroundContext((com.codename1.ui.Form) adaptedArgs[0], (com.codename1.ui.Container) adaptedArgs[1], (com.codename1.ui.util.Resources) adaptedArgs[2], (com.codenameone.playground.PlaygroundContext.Logger) adaptedArgs[3]); } + if (matches(safeArgs, new Class[]{com.codename1.ui.Form.class, com.codename1.ui.Container.class, com.codename1.ui.util.Resources.class, com.codenameone.playground.PlaygroundContext.Logger.class, com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter.class}, false)) { + Object[] adaptedArgs = adaptArgs(safeArgs, new Class[]{com.codename1.ui.Form.class, com.codename1.ui.Container.class, com.codename1.ui.util.Resources.class, com.codenameone.playground.PlaygroundContext.Logger.class, com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter.class}, false); + return new com.codenameone.playground.PlaygroundContext((com.codename1.ui.Form) adaptedArgs[0], (com.codename1.ui.Container) adaptedArgs[1], (com.codename1.ui.util.Resources) adaptedArgs[2], (com.codenameone.playground.PlaygroundContext.Logger) adaptedArgs[3], (com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter) adaptedArgs[4]); + } } throw unsupportedConstruct(type, safeArgs); } @@ -126,9 +133,16 @@ public static Object invoke(Object target, String name, Object[] args) throws Ex unsupported = ex; } } + if (target instanceof com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter) { + try { + return invoke5((com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter) target, name, safeArgs); + } catch (CN1AccessException ex) { + unsupported = ex; + } + } if (target instanceof com.codenameone.playground.WebsiteThemeNative) { try { - return invoke5((com.codenameone.playground.WebsiteThemeNative) target, name, safeArgs); + return invoke6((com.codenameone.playground.WebsiteThemeNative) target, name, safeArgs); } catch (CN1AccessException ex) { unsupported = ex; } @@ -248,6 +262,12 @@ private static Object invoke1(com.codenameone.playground.PlaygroundContext typed typedTarget.refreshPreview(); return null; } } + if ("reportRuntimeError".equals(name)) { + if (matches(safeArgs, new Class[]{java.lang.String.class, java.lang.Throwable.class}, false)) { + Object[] adaptedArgs = adaptArgs(safeArgs, new Class[]{java.lang.String.class, java.lang.Throwable.class}, false); + typedTarget.reportRuntimeError((java.lang.String) adaptedArgs[0], (java.lang.Throwable) adaptedArgs[1]); return null; + } + } if ("setTitle".equals(name)) { if (matches(safeArgs, new Class[]{java.lang.String.class}, false)) { Object[] adaptedArgs = adaptArgs(safeArgs, new Class[]{java.lang.String.class}, false); @@ -309,7 +329,17 @@ private static Object invoke4(com.codenameone.playground.PlaygroundContext.Logge throw unsupportedInstance(typedTarget, name, safeArgs); } - private static Object invoke5(com.codenameone.playground.WebsiteThemeNative typedTarget, String name, Object[] safeArgs) throws Exception { + private static Object invoke5(com.codenameone.playground.PlaygroundContext.RuntimeErrorReporter typedTarget, String name, Object[] safeArgs) throws Exception { + if ("reportRuntimeError".equals(name)) { + if (matches(safeArgs, new Class[]{java.lang.String.class, java.lang.Throwable.class}, false)) { + Object[] adaptedArgs = adaptArgs(safeArgs, new Class[]{java.lang.String.class, java.lang.Throwable.class}, false); + typedTarget.reportRuntimeError((java.lang.String) adaptedArgs[0], (java.lang.Throwable) adaptedArgs[1]); return null; + } + } + throw unsupportedInstance(typedTarget, name, safeArgs); + } + + private static Object invoke6(com.codenameone.playground.WebsiteThemeNative typedTarget, String name, Object[] safeArgs) throws Exception { if ("isDarkMode".equals(name)) { if (safeArgs.length == 0) { return typedTarget.isDarkMode(); diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java index b84026413d..ca5c2e358e 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java @@ -81,6 +81,7 @@ public class CN1Playground extends Lifecycle { private String currentMobileTab = MOBILE_TAB_CODE; private List currentMessages = new ArrayList<>(); private List currentCssMessages = new ArrayList<>(); + private final List currentRuntimeErrors = new ArrayList<>(); private int editSequence; private int autoRunSequence; private int historySequence; @@ -716,11 +717,17 @@ private void runScript(Form form) { private void executeRunScript(Form form) { CN.callSerially(() -> { List loggedMessages = new ArrayList<>(); + // Lambdas wired up by the previous run may still be holding + // references to widgets we just replaced; clear their stale + // error trail before this run so the panel doesn't show + // failures from a script that is no longer on screen. + currentRuntimeErrors.clear(); PlaygroundContext context = new PlaygroundContext( form, previewColumn.getContentHost(), theme, - message -> loggedMessages.add(new PlaygroundRunner.InlineMessage(0, message, "info")) + message -> loggedMessages.add(new PlaygroundRunner.InlineMessage(0, message, "info")), + this::reportLambdaRuntimeError ); PlaygroundRunner.RunResult result = runner.run(currentScript, context); @@ -754,6 +761,40 @@ private void executeRunScript(Form form) { }); } + /** Receives runtime errors raised by lambdas after a script's initial + * eval — the most common cause is a missing import (so an identifier + * like {@code Util} is unresolved when the event listener fires). The + * EDT would otherwise swallow these silently, leaving the user with + * a UI that no longer reacts. We surface each unique error as an + * inline editor message and flip the top bar to its failed state. */ + private void reportLambdaRuntimeError(String message, Throwable cause) { + if (message == null || message.isEmpty()) { + return; + } + CN.callSerially(() -> { + // Dedup so a listener that keeps firing (e.g. text-field + // keystrokes) doesn't append the same message dozens of times. + for (int i = 0; i < currentRuntimeErrors.size(); i++) { + if (message.equals(currentRuntimeErrors.get(i).text)) { + return; + } + } + PlaygroundRunner.InlineMessage entry = + new PlaygroundRunner.InlineMessage(0, "Runtime error: " + message, "error"); + currentRuntimeErrors.add(entry); + currentMessages.add(entry); + if (editor != null) { + editor.setInlineMessages(currentMessages); + } + if (topBar != null) { + topBar.showFailed(); + } + if (previewColumn != null) { + previewColumn.setStale(true); + } + }); + } + private void replacePreview(Component component) { if (component == null) { previewColumn.setPreview(null); diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundContext.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundContext.java index 6245c882ba..ae943ad48b 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundContext.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundContext.java @@ -20,20 +20,35 @@ public interface Logger { void log(String message); } + /** Receives runtime errors that happen after a script has finished its + * initial evaluation — typically a lambda body that fails on a later + * event firing. Without this hook the EDT silently swallows the + * exception and the user sees a UI that no longer reacts to input. */ + public interface RuntimeErrorReporter { + void reportRuntimeError(String message, Throwable cause); + } + private final Form hostForm; private final Container previewRoot; private final Resources theme; private final Logger logger; + private final RuntimeErrorReporter runtimeErrorReporter; private Form shownForm; private final List createdComponents = new ArrayList(); private Form firstCreatedForm; private Component firstCreatedComponent; public PlaygroundContext(Form hostForm, Container previewRoot, Resources theme, Logger logger) { + this(hostForm, previewRoot, theme, logger, null); + } + + public PlaygroundContext(Form hostForm, Container previewRoot, Resources theme, Logger logger, + RuntimeErrorReporter runtimeErrorReporter) { this.hostForm = hostForm; this.previewRoot = previewRoot; this.theme = theme; this.logger = logger; + this.runtimeErrorReporter = runtimeErrorReporter; } public Form getHostForm() { @@ -97,6 +112,12 @@ public void log(String message) { logger.log(message); } + public void reportRuntimeError(String message, Throwable cause) { + if (runtimeErrorReporter != null) { + runtimeErrorReporter.reportRuntimeError(message, cause); + } + } + public void captureShownForm(Form form) { shownForm = form; } diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundRunner.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundRunner.java index 04f70299ee..3ca75d7a3f 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundRunner.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundRunner.java @@ -81,6 +81,26 @@ List getMessages() { } } + /** Bridges a lambda runtime failure into the playground's + * {@link PlaygroundContext#reportRuntimeError(String, Throwable)} + * so the editor can surface the error instead of having the EDT + * silently swallow it. */ + private final class LambdaErrorBridge implements CN1LambdaSupport.LambdaErrorHandler { + private final PlaygroundContext context; + + LambdaErrorBridge(PlaygroundContext context) { + this.context = context; + } + + @Override + public void onLambdaError(Throwable error, String bodySource) { + if (context == null || error == null) { + return; + } + context.reportRuntimeError(safeMessage(error), error); + } + } + RunResult run(String script, PlaygroundContext context) { List inlineMessages = new ArrayList(); try { @@ -92,6 +112,11 @@ RunResult run(String script, PlaygroundContext context) { } PlaygroundContext.pushCurrent(context); CN1LambdaSupport.pushInterpreter(interpreter); + // Lambdas created during eval capture this handler and use it + // to surface their runtime failures back to the host. We push + // it even when the context can't actually report (no reporter + // wired) — the handler short-circuits in that case. + CN1LambdaSupport.pushErrorHandler(new LambdaErrorBridge(context)); try { ScriptPlan plan = adaptScript(script); for (int i = 0; i < plan.typeDeclarations.size(); i++) { @@ -102,6 +127,7 @@ RunResult run(String script, PlaygroundContext context) { inlineMessages.add(new InlineMessage(0, "Preview updated.", "success")); return new RunResult(component, Collections.emptyList(), inlineMessages); } finally { + CN1LambdaSupport.clearErrorHandler(); CN1LambdaSupport.clearInterpreter(); PlaygroundContext.clearCurrent(); } diff --git a/scripts/cn1playground/common/src/test/java/com/codenameone/playground/PlaygroundSmokeHarness.java b/scripts/cn1playground/common/src/test/java/com/codenameone/playground/PlaygroundSmokeHarness.java index de1a6b2927..57f9229d87 100644 --- a/scripts/cn1playground/common/src/test/java/com/codenameone/playground/PlaygroundSmokeHarness.java +++ b/scripts/cn1playground/common/src/test/java/com/codenameone/playground/PlaygroundSmokeHarness.java @@ -26,6 +26,7 @@ public static void main(String[] args) throws Exception { smokeComponentTypeResolvesWithoutExplicitImport(); smokeUIManagerClassImportDoesNotCollideWithGlobals(); smokeInstanceDispatchSuggestsStaticUtility(); + smokeLambdaRuntimeErrorSurfacesToReporter(); System.out.println("Playground smoke tests passed."); // Codename One/JavaSE initialization may leave non-daemon threads running. // Force a clean exit so CI jobs don't hang after successful completion. @@ -309,6 +310,66 @@ public void log(String message) { "Raw 'Generated instance dispatch' message should be replaced, got: " + summary); } + /** Regression: when a lambda references an unresolved symbol (e.g. a + * missing import for {@code com.codename1.io.Util}), the initial + * script evaluation succeeds because the body is only re-evaluated + * when the event fires. The user types into the field, the EDT + * catches the resulting EvalError, and the UI silently stops + * responding. The runner now captures a {@link + * PlaygroundContext.RuntimeErrorReporter} into each created lambda so + * the later failure surfaces back to the editor. */ + private static void smokeLambdaRuntimeErrorSurfacesToReporter() { + Display.init(null); + + Form host = new Form("Host", new BorderLayout()); + Container preview = new Container(new BorderLayout()); + host.add(BorderLayout.CENTER, preview); + host.show(); + + final List reported = new ArrayList(); + PlaygroundContext context = new PlaygroundContext(host, preview, null, + new PlaygroundContext.Logger() { + public void log(String message) { + } + }, + new PlaygroundContext.RuntimeErrorReporter() { + public void reportRuntimeError(String message, Throwable cause) { + reported.add(message); + } + }); + + PlaygroundRunner runner = new PlaygroundRunner(); + // The lambda references DefinitelyMissingType (no matching import or + // class). Initial eval must still produce the Label — only the + // lambda body's later evaluation fails. + PlaygroundRunner.RunResult result = runner.run( + "import com.codename1.ui.*;\n" + + "Runnable r = () -> DefinitelyMissingType.doStuff();\n" + + "Label hi = new Label(\"OK\");\n" + + "hi.putClientProperty(\"r\", r);\n" + + "hi;\n", + context); + + require(result.getComponent() instanceof Label, + "Script with unresolved lambda body should still build the UI: " + summarizeMessages(result)); + Object stored = ((Label) result.getComponent()).getClientProperty("r"); + require(stored instanceof Runnable, "Lambda should be assignable to Runnable"); + require(reported.isEmpty(), "Reporter should not fire until the lambda is actually invoked"); + + try { + ((Runnable) stored).run(); + require(false, "Invoking the failing lambda should propagate a RuntimeException"); + } catch (RuntimeException expected) { + // expected — EDT would have swallowed this in production + } + + require(reported.size() == 1, + "Runtime error reporter should fire exactly once for the failing lambda, saw: " + reported); + String msg = reported.get(0); + require(msg != null && msg.indexOf("DefinitelyMissingType") >= 0, + "Runtime error message should name the unresolved symbol, got: " + msg); + } + private static void require(boolean condition, String message) { if (!condition) { throw new IllegalStateException(message);