From: Svjatoslav Agejenko Date: Sun, 20 Sep 2026 14:12:36 +0000 (+0300) Subject: fix(gui): close-path deadlock, slow window close, null-panel click NPE X-Git-Tag: aukio-3d-1.0.0~1 X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=2be827c3d54a6848a5d7e5fa74ea23162092ac54;p=aukio-3d.git fix(gui): close-path deadlock, slow window close, null-panel click NPE Two live-session defects fixed (diagnosed from a frozen launcher process via jstack + telemetry log): - ViewPanel.stop(): interrupt the render thread BEFORE transformExecutor.shutdownNow() and clear pendingPaints only after the join. shutdownNow() cancelled queued paint tasks whose finally countDown() then never ran, parking the render thread forever on paintLatch.await() and hanging the EDT in renderThread.join() — window unclosable, SIGKILL required. - Slow close (~2.8 s on every demo): device poll threads sleep 2 s and stop() did a bare join(1000), burning the full timeout on the EDT. HeadTrackingManager/SpaceMouseManager now interrupt before join. SpaceNavigatorHid polls (250 ms) before read() — Linux close() does not wake a read blocked on a silent-at-rest device — and stop() joins before closing the fd. HeadTracker.stop() closes before join. - ViewPanel.stop() now stops GlobalIllumination: GI worker threads used to leak across demos in the launcher JVM (~3 cores stolen from the next demo). colorOf() instanceof-guards the SolidPolygon cast — textured triangles in a GI scene caused a ClassCastException storm. - Clicking a text editor threw NPE (this.viewPanel is null): demos build scenes with a null ViewPanel for the headless golden harness, but GuiComponent.mouseClicked dereferenced the field. Focus never engaged (no red border) and the exception aborted the frame being painted (black screen until camera move). MouseInteractionController gains a 4-arg mouseClicked carrying the dispatching view's KeyboardFocusStack; RenderingContext/ViewPanel forward it; GuiComponent focuses via the stack (legacy 1-arg null-guarded). Verified: 48/48 engine tests, demos ALL GOLDENS PASS; repro harness closes in 64 ms where the old code hung or took 2.8 s; live user check of editor focus. --- diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java index a86a807..1ee05df 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java @@ -6,6 +6,7 @@ package eu.svjatoslav.aukio.e3d.gui; import eu.svjatoslav.aukio.e3d.geometry.Box; import eu.svjatoslav.aukio.e3d.geometry.Point3D; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack; import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardHelper; import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardInputHandler; import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; @@ -167,12 +168,25 @@ public class GuiComponent extends AbstractCompositeShape implements @Override public boolean mouseClicked(int button) { + // Direct-call path (no dispatch, no focus stack supplied). Only + // works for components constructed with a live panel; headless-built + // components (null panel) ignore the click instead of throwing. + if (viewPanel == null) + return false; + return mouseClicked(button, Double.NaN, Double.NaN, + viewPanel.getKeyboardFocusStack()); + } + + @Override + public boolean mouseClicked(final int button, final double textureU, + final double textureV, + final KeyboardFocusStack focusStack) { if (button == MouseEvent.BUTTON_MIDDLE) { // middle click releases keyboard focus, like ESC - viewPanel.getKeyboardFocusStack().popFocusOwner(); + focusStack.popFocusOwner(); return true; } - return viewPanel.getKeyboardFocusStack().pushFocusOwner(this); + return focusStack.pushFocusOwner(this); } @Override diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java index 04e9e61..9203394 100755 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java @@ -839,7 +839,8 @@ public class ViewPanel extends Canvas { // but that must not kill the render loop with an NPE here. if (mouseInThisEye && pending.segmentContexts != null) { combineMouseResults(pending.segmentContexts, pending.context); - viewRepaintNeeded = pending.context.handlePossibleComponentMouseEvent(); + viewRepaintNeeded = pending.context.handlePossibleComponentMouseEvent( + getKeyboardFocusStack()); } if (developerTools.showSegmentBoundaries) { @@ -1340,6 +1341,14 @@ public class ViewPanel extends Canvas { deviceHotplug.stop(); deviceHotplug = null; } + if (globalIllumination != null) { + // Stop the GI sweep with the view: the worker threads trace + // rays against THIS view's scene, so after close they are pure + // CPU burn (observed 2026-09-20: 4 leaked threads at ~70% duty + // minutes after the demo window was gone). + globalIllumination.stop(); + globalIllumination = null; + } renderThreadRunning = false; presentThreadRunning = false; presentSignal.release(); @@ -1347,11 +1356,14 @@ public class ViewPanel extends Canvas { if (dropped != null) { dropped.gate.countDown(); } - pendingPaints.clear(); - if (transformExecutor != null) { - transformExecutor.shutdownNow(); - } if (renderThread != null) { + // Interrupt BEFORE any executor teardown: the render thread may + // be parked in flushPendingPaint's paintLatch.await(), and + // shutdownNow() cancels queued paint tasks — a cancelled task + // never runs its finally countDown(), so the latch never fires + // and an uninterrupted join here hangs forever (observed + // 2026-09-20: close button dead, EDT stuck in this join). + renderThread.interrupt(); try { renderThread.join(); } catch (InterruptedException e) { @@ -1359,6 +1371,13 @@ public class ViewPanel extends Canvas { } renderThread = null; } + // Tear down the pipeline only with the render thread dead: clearing + // the deque and cancelling queued tasks while it was still flushing + // raced its peek/poll and orphaned the latches it awaited. + pendingPaints.clear(); + if (transformExecutor != null) { + transformExecutor.shutdownNow(); + } } /** diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java index 7187850..b822dcc 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java @@ -183,12 +183,14 @@ public final class HeadTracker { public void stop() { running = false; + // Close first: the reader thread is parked in a native read that no + // interrupt can wake; closing the fd fails the read instantly. + device.close(); try { readerThread.join(500); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } - device.close(); } private void readLoop() { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java index bda7ff8..3808541 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java @@ -52,6 +52,9 @@ public final class HeadTrackingManager { public void stop() { running = false; + // Wake the 2 s poll sleep; without the interrupt the join below + // burns its full 1 s timeout on the EDT at every window close. + thread.interrupt(); try { thread.join(1000); } catch (final InterruptedException e) { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java index d0b8800..cd2b597 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java @@ -38,6 +38,30 @@ public interface MouseInteractionController { return mouseClicked(button); } + /** + * Called when mouse is clicked on component, additionally carrying the + * keyboard focus stack of the dispatching view. + * + *

Components that take keyboard focus on click should override THIS + * method rather than reaching for a stored ViewPanel reference: scenes + * built headlessly (golden-image harness) construct components with a + * null panel, but the dispatching view always has a focus stack.

+ * + *

The default implementation ignores the focus stack and delegates + * to {@link #mouseClicked(int, double, double)}.

+ * + * @param button the mouse button that was clicked (1 = left, 2 = middle, 3 = right) + * @param textureU texture-space X of the clicked point, or {@link Double#NaN} + * @param textureV texture-space Y of the clicked point, or {@link Double#NaN} + * @param focusStack keyboard focus stack of the dispatching view + * @return {@code true} if view update is needed as a consequence of this mouse click + */ + default boolean mouseClicked(final int button, final double textureU, + final double textureV, + final KeyboardFocusStack focusStack) { + return mouseClicked(button, textureU, textureV); + } + /** * Called when the mouse wheel is turned while this component has * keyboard focus. Both axes are reported: horizontal wheel input diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java index 68f0d0e..d2a5cd9 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java @@ -54,6 +54,9 @@ public final class SpaceMouseManager { public void stop() { running = false; + // Wake the 2 s poll sleep; without the interrupt the join below + // burns its full 1 s timeout on the EDT at every window close. + thread.interrupt(); try { thread.join(1000); } catch (final InterruptedException e) { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java index 9976b84..7709c5c 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java @@ -64,6 +64,9 @@ public final class SpaceNavigatorHid { int close(int fd); int read(int fd, Memory buffer, int count); + + /** poll(2) on a single-element struct pollfd (8 bytes: fd, events, revents). */ + int poll(Memory fds, int nfds, int timeout); } private final File deviceNode; @@ -132,7 +135,29 @@ public final class SpaceNavigatorHid { } private void readLoop() { + // Poll before reading: the device is SILENT at rest, so a plain + // blocking read never returns when idle — and close() from stop() + // does not wake a blocked read on Linux, which made every window + // close wait out the full 1 s join timeout. + final Memory pollfd = new Memory(8); while (running) { + pollfd.clear(); + pollfd.setInt(0, fd); + pollfd.setShort(4, (short) 0x1); // events = POLLIN + final int ready = CLib.INSTANCE.poll(pollfd, 1, 250); + if (ready == 0) + continue; // idle device: re-check running + if (ready < 0) { + if (Native.getLastError() == 4) // EINTR + continue; + broken = true; + return; + } + final short revents = pollfd.getShort(6); + if ((revents & 0x1) == 0) { // no POLLIN: ERR/HUP/NVAL = unplugged + broken = true; + return; + } final int n = CLib.INSTANCE.read(fd, readBuffer, 64); if (n < 0) { broken = true; // unplugged @@ -170,10 +195,9 @@ public final class SpaceNavigatorHid { public void stop() { running = false; - if (fd >= 0) { - CLib.INSTANCE.close(fd); - fd = -1; - } + // Join first: the poll loop notices the flag within 250 ms. Closing + // the fd before the reader is dead risks the fd number being reused + // under a concurrent open while the reader is mid-read. if (reader != null) { try { reader.join(1000); @@ -182,5 +206,9 @@ public final class SpaceNavigatorHid { } reader = null; } + if (fd >= 0) { + CLib.INSTANCE.close(fd); + fd = -1; + } } } diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderingContext.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderingContext.java index 3ba085b..4ecf4d4 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderingContext.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderingContext.java @@ -7,6 +7,7 @@ import eu.svjatoslav.aukio.e3d.diag.DebugLogBuffer; import eu.svjatoslav.aukio.e3d.gui.DeveloperTools; import eu.svjatoslav.aukio.e3d.geometry.Point2D; +import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack; import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent; import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController; import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager; @@ -680,9 +681,12 @@ public class RenderingContext { /** * Handles mouse events for components and returns whether a view repaint is needed. * + * @param focusStack keyboard focus stack of the dispatching view, handed + * to clicked components so they can acquire focus + * without holding a ViewPanel reference * @return {@code true} if view update is needed as a consequence of this mouse event */ - public boolean handlePossibleComponentMouseEvent() { + public boolean handlePossibleComponentMouseEvent(final KeyboardFocusStack focusStack) { if (mouseEvent == null) return false; boolean viewRepaintNeeded = false; @@ -697,7 +701,8 @@ public class RenderingContext { if (mouseEvent.button != 0 && currentObjectUnderMouseCursor != null) { // Mouse button was clicked on some component. viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseClicked( - mouseEvent.button, currentMouseTextureU, currentMouseTextureV); + mouseEvent.button, currentMouseTextureU, currentMouseTextureV, + focusStack); } else if (currentObjectUnderMouseCursor != null) // hover: let the component track the pointer position viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseHover( diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java index c9ae4ef..d5fa783 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java @@ -99,6 +99,9 @@ public class GlobalIllumination implements GiLightProvider { /** Debug statistics with {@code -De3d.gi.debug}. */ private static final boolean DEBUG = Boolean.getBoolean("e3d.gi.debug"); + /** Albedo for snapshot entries that carry no flat color (textured triangles). */ + private static final Color FALLBACK_ALBEDO = new Color(128, 128, 128); + /** * EMA policy for the inner per-sample indirect blend: "fixed" (default, * 0.15) keeps every ray hit equally intensive forever — fading toward @@ -541,7 +544,13 @@ public class GlobalIllumination implements GiLightProvider { private static Color colorOf(final TriangleBvh.Entry entry) { if (entry.lightmap != null) return entry.lightmap.baseColor; - return ((SolidPolygon) entry.polygon).getColor(); + if (entry.polygon instanceof SolidPolygon) + return ((SolidPolygon) entry.polygon).getColor(); + // Snapshot triangles are not all SolidPolygons (e.g. textured + // triangles from a TextCanvas in a GI scene carry no flat color) — + // a neutral gray albedo keeps bounce light plausible instead of + // throwing ClassCastException into the worker loop. + return FALLBACK_ALBEDO; } // ------------------------------------------------------------------