From 8dcd1e5371306f7096d2aaeaabb26f1bb42173c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:00:14 +0300 Subject: [PATCH 1/5] InteractionDialog: host on the right form + don't lose queued sibling dialogs Fixes the "dialog never appears / appears later on another screen" symptoms of #5193, which the stackable layer fix (#5195) didn't cover: - show() and showPopupDialog(Rectangle) anchored the dialog to Display.getCurrent(), which still returns the outgoing form while a form transition is queued or in flight (Form.show() is asynchronous). The dialog would attach to the form leaving the screen, stay invisible, and only materialize when that form's animation queue was flushed on re-show or minimize/restore. They now resolve the host via the newly public Display.getCurrentUpcoming(), which returns the transition's destination form. - showPopupDialog(Component) now hosts the popup on the target component's own form instead of Display.getCurrent(), and pins it so the show() call at the end of the positioning flow targets the same form used for the positioning math. - In stackable mode, cleanupLayer() decided to detach the shared layer using getComponentCount() == 0, which doesn't see inserts that Container.insertComponentAt() deferred to the animation queue (e.g. a sibling dialog shown while this dialog's dispose animation ran). The pending insert would then flush into the detached container and the sibling dialog was lost forever. Use getChildrenAsList(true), which reflects queued changes. - resize() now uses the form the dialog is actually on rather than Display.getCurrent(). Co-Authored-By: Claude Fable 5 --- .../components/InteractionDialog.java | 58 +++++++++++++++++-- CodenameOne/src/com/codename1/ui/Display.java | 20 +++++-- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/InteractionDialog.java b/CodenameOne/src/com/codename1/components/InteractionDialog.java index 48e6aed17e..73092063c1 100644 --- a/CodenameOne/src/com/codename1/components/InteractionDialog.java +++ b/CodenameOne/src/com/codename1/components/InteractionDialog.java @@ -101,6 +101,18 @@ public void run() { /// between (e.g. by `#showPopupDialog(Component)`). private boolean shownInFormMode; + /// The form the dialog should be hosted on, pinned by the popup entry points + /// (the form of the component passed to `#showPopupDialog(Component)`) and + /// consumed by `#show(int, int, int, int)` so the whole popup flow targets + /// one consistent form. When unset, `#resolveHostForm()` falls back to + /// `Display#getCurrentUpcoming()` rather than `Display#getCurrent()`: + /// during a form transition the latter still returns the outgoing form, so a + /// dialog shown right after `Form.show()` would silently attach to the form + /// that is leaving the screen and only materialize when that form is shown + /// again -- the "dialog never appears / appears later on another screen" + /// symptom of #5193. + private Form popupHostForm; + private boolean pressedOutOfBounds; private ActionListener pressedListener; private ActionListener releasedListener; @@ -289,10 +301,16 @@ private void cleanupLayer(Form f) { // left it, so it neither nukes siblings nor lingers empty. Use the // mode captured at show() time so we clean the pane the dialog was // actually added to even if formMode changed in the meantime. + // getComponentCount() can't be used for the emptiness check: while + // an animation is in progress (e.g. this dialog's own dispose + // animation) a sibling's add is only queued on the change queue and + // isn't counted, so the layer would be detached with the pending + // insert flushing into it later -- losing that dialog forever. + // getChildrenAsList(true) reflects queued inserts/removals. Container c = shownInFormMode ? f.getFormLayeredPane(InteractionDialog.class, true) : f.getLayeredPane(InteractionDialog.class, true); - if (c.getComponentCount() == 0) { + if (c.getChildrenAsList(true).isEmpty()) { c.remove(); } return; @@ -304,6 +322,16 @@ private void cleanupLayer(Form f) { } } + /// Resolves the form this dialog should be hosted on: the form pinned by the + /// popup entry points when available, otherwise the form that will actually + /// be on screen once any in-flight transition completes. See `#popupHostForm`. + private Form resolveHostForm() { + if (popupHostForm != null) { + return popupHostForm; + } + return Display.getInstance().getCurrentUpcoming(); + } + private Container getLayeredPane(Form f) { //return f.getLayeredPane(); Container c; @@ -347,7 +375,12 @@ protected void deinitialize() { public void resize(final int top, final int bottom, final int left, final int right) { if (!disposed) { - final Form f = Display.getInstance().getCurrent(); + // prefer the form the dialog is actually showing on; during a form + // transition Display.getCurrent() may point at the outgoing form + Form f = getComponentForm(); + if (f == null) { + f = resolveHostForm(); + } Style unselectedStyle = getUnselectedStyle(); @@ -389,7 +422,8 @@ public void resize(final int top, final int bottom, final int left, final int ri public void show(int top, int bottom, int left, int right) { getUnselectedStyle().setOpacity(255); disposed = false; - Form f = Display.getInstance().getCurrent(); + Form f = resolveHostForm(); + popupHostForm = null; shownInFormMode = formMode; Style unselectedStyle = getUnselectedStyle(); @@ -858,7 +892,10 @@ public void showPopupDialog(Component c, boolean bias) { componentPos.setX(componentPos.getX() - c.getScrollX()); componentPos.setY(componentPos.getY() - c.getScrollY()); setOwner(c); - showPopupDialog(componentPos); + // host the popup on the form the target component actually belongs to; + // Display.getCurrent() may still be the outgoing form while a transition + // is in flight, which would make the dialog appear to never show (#5193) + showPopupDialogImpl(componentPos, Display.getInstance().isPortrait(), f); } /// A popup dialog is shown with the context of a component and its selection. You should use `#setDisposeWhenPointerOutOfBounds(boolean)` to make it dispose @@ -890,10 +927,21 @@ public void showPopupDialog(Rectangle rect, boolean bias) { } private void showPopupDialogImpl(Rectangle rect, boolean bias) { + showPopupDialogImpl(rect, bias, null); + } + + private void showPopupDialogImpl(Rectangle rect, boolean bias, Form hostForm) { if (rect == null) { throw new IllegalArgumentException("rect cannot be null"); } - Form f = Display.getInstance().getCurrent(); + Form f = hostForm; + if (f == null) { + f = resolveHostForm(); + } + // pin the host form so the show(int, int, int, int) call at the end of + // this method (and any subclass override of it) targets the same form + // used for all the positioning math below + popupHostForm = f; Rectangle origRect = rect; rect = new Rectangle(rect); rect.setX(rect.getX() - getLayeredPane(f).getAbsoluteX()); diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 549355afd0..05de2ceeca 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -2546,15 +2546,23 @@ Form getCurrentInternal() { return impl.getCurrentForm(); } - /// Same as getCurrent with the added exception of looking into the future - /// transitions and returning the last current in the transition (the upcoming - /// value for current) + /// Returns the form that will be displayed once any in-flight form transition + /// completes. While a transition is queued or running `#getCurrent()` still + /// returns the outgoing form, which makes it easy to accidentally add overlays + /// (e.g. an `InteractionDialog`) to the form that is about to leave the + /// screen right after invoking `Form#show()`. This method returns the + /// transition's destination form in that case, otherwise it behaves like + /// `#getCurrent()`. /// /// #### Returns /// - /// @return the form currently displayed on the screen or null if no form is - /// currently displayed - Form getCurrentUpcoming() { + /// the upcoming form if a transition is pending, otherwise the form currently + /// displayed on the screen or null if no form is currently displayed + /// + /// #### Since + /// + /// 8.0 + public Form getCurrentUpcoming() { return getCurrentUpcomingForm(false); } From 61017b443cb6cfc17e4701a3e2796f3baa35d158 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:46:43 +0300 Subject: [PATCH 2/5] Drop public getCurrentUpcoming; defer show via callSerially during transitions Review feedback: getCurrentUpcoming is too niche/confusing to expose. Display.java is reverted to master. Instead, show(int,int,int,int) and showPopupDialogImpl gate on the existing public Display.isInTransition() and defer themselves with callSerially: the EDT doesn't process serial calls while a form transition is animating (edtLoopImpl returns right after paintTransitionAnimation), so the deferred show runs once the destination form has become the current form and Display.getCurrent() is correct. A pendingShow flag keeps isShowing()/showDialog() truthful through the deferral window and lets dispose() abandon a deferred show. The component-form pinning in showPopupDialog(Component), the stackable cleanupLayer fix and the resize() fix are unchanged. Co-Authored-By: Claude Fable 5 --- .../components/InteractionDialog.java | 77 ++++++++++++++++--- CodenameOne/src/com/codename1/ui/Display.java | 20 ++--- 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/InteractionDialog.java b/CodenameOne/src/com/codename1/components/InteractionDialog.java index 73092063c1..89cfc00932 100644 --- a/CodenameOne/src/com/codename1/components/InteractionDialog.java +++ b/CodenameOne/src/com/codename1/components/InteractionDialog.java @@ -104,15 +104,24 @@ public void run() { /// The form the dialog should be hosted on, pinned by the popup entry points /// (the form of the component passed to `#showPopupDialog(Component)`) and /// consumed by `#show(int, int, int, int)` so the whole popup flow targets - /// one consistent form. When unset, `#resolveHostForm()` falls back to - /// `Display#getCurrentUpcoming()` rather than `Display#getCurrent()`: - /// during a form transition the latter still returns the outgoing form, so a - /// dialog shown right after `Form.show()` would silently attach to the form - /// that is leaving the screen and only materialize when that form is shown - /// again -- the "dialog never appears / appears later on another screen" - /// symptom of #5193. + /// one consistent form. When no form is pinned the show methods use + /// `Display#getCurrent()`, deferring themselves with `callSerially` while a + /// form transition is in flight: during a transition `getCurrent()` still + /// returns the outgoing form, so a dialog shown right after `Form.show()` + /// would silently attach to the form that is leaving the screen and only + /// materialize when that form is shown again -- the "dialog never appears / + /// appears later on another screen" symptom of #5193. The EDT doesn't + /// process serial calls while a transition is animating, so a deferred show + /// runs once the destination form has become the current form. private Form popupHostForm; + /// True while a show was deferred with `callSerially` because a form + /// transition was in flight (see `#popupHostForm`). Counted by + /// `#isShowing()` so `#showDialog()` keeps blocking through the deferral + /// window; cleared by dispose so a deferred show is abandoned if the dialog + /// was disposed before it ran. + private boolean pendingShow; + private boolean pressedOutOfBounds; private ActionListener pressedListener; private ActionListener releasedListener; @@ -323,13 +332,14 @@ private void cleanupLayer(Form f) { } /// Resolves the form this dialog should be hosted on: the form pinned by the - /// popup entry points when available, otherwise the form that will actually - /// be on screen once any in-flight transition completes. See `#popupHostForm`. + /// popup entry points when available, otherwise the current form. Callers + /// that can't pin a form must defer while a transition is in flight (see + /// `#popupHostForm`) since the current form is then about to leave the screen. private Form resolveHostForm() { if (popupHostForm != null) { return popupHostForm; } - return Display.getInstance().getCurrentUpcoming(); + return Display.getInstance().getCurrent(); } private Container getLayeredPane(Form f) { @@ -419,9 +429,29 @@ public void resize(final int top, final int bottom, final int left, final int ri /// - `left`: space in pixels between the left of the screen and the form /// /// - `right`: space in pixels between the right of the screen and the form - public void show(int top, int bottom, int left, int right) { + public void show(final int top, final int bottom, final int left, final int right) { getUnselectedStyle().setOpacity(255); disposed = false; + if (popupHostForm == null && Display.getInstance().isInTransition()) { + // Display.getCurrent() still returns the outgoing form while a + // transition is in flight, so attaching now would put the dialog + // on the form that is leaving the screen where it stays invisible + // until that form is shown again (#5193). The EDT doesn't process + // serial calls while a transition is animating, so this re-runs + // once the destination form has become the current form. + pendingShow = true; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + pendingShow = false; + if (!disposed) { + show(top, bottom, left, right); + } + } + }); + return; + } + pendingShow = false; Form f = resolveHostForm(); popupHostForm = null; shownInFormMode = formMode; @@ -492,6 +522,7 @@ public void show(int top, int bottom, int left, int right) { @Override public void dispose() { disposed = true; + pendingShow = false; Container p = getParent(); if (p != null) { Form f = p.getComponentForm(); @@ -596,6 +627,7 @@ private void disposeTo(int direction) { private void disposeTo(int direction, final Runnable onFinish) { disposed = true; + pendingShow = false; final Container p = getParent(); if (p != null) { final Form f = p.getComponentForm(); @@ -677,7 +709,9 @@ public void run() { /// /// true if showing public boolean isShowing() { - return getParent() != null; + // pendingShow covers the window where show was deferred to the end of + // an in-flight form transition, so showDialog() keeps blocking + return pendingShow || getParent() != null; } /// Indicates whether show/dispose should be animated or not. When true (the default) @@ -934,6 +968,25 @@ private void showPopupDialogImpl(Rectangle rect, boolean bias, Form hostForm) { if (rect == null) { throw new IllegalArgumentException("rect cannot be null"); } + if (hostForm == null && Display.getInstance().isInTransition()) { + // see show(int, int, int, int): the current form is about to be + // replaced so the positioning math below would run against the + // outgoing form; defer until the destination form is current + disposed = false; + pendingShow = true; + final Rectangle deferredRect = rect; + final boolean deferredBias = bias; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + pendingShow = false; + if (!disposed) { + showPopupDialogImpl(deferredRect, deferredBias, null); + } + } + }); + return; + } Form f = hostForm; if (f == null) { f = resolveHostForm(); diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 05de2ceeca..549355afd0 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -2546,23 +2546,15 @@ Form getCurrentInternal() { return impl.getCurrentForm(); } - /// Returns the form that will be displayed once any in-flight form transition - /// completes. While a transition is queued or running `#getCurrent()` still - /// returns the outgoing form, which makes it easy to accidentally add overlays - /// (e.g. an `InteractionDialog`) to the form that is about to leave the - /// screen right after invoking `Form#show()`. This method returns the - /// transition's destination form in that case, otherwise it behaves like - /// `#getCurrent()`. + /// Same as getCurrent with the added exception of looking into the future + /// transitions and returning the last current in the transition (the upcoming + /// value for current) /// /// #### Returns /// - /// the upcoming form if a transition is pending, otherwise the form currently - /// displayed on the screen or null if no form is currently displayed - /// - /// #### Since - /// - /// 8.0 - public Form getCurrentUpcoming() { + /// @return the form currently displayed on the screen or null if no form is + /// currently displayed + Form getCurrentUpcoming() { return getCurrentUpcomingForm(false); } From 512bb088ac17dc679c53bff76d16903fea49967d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:21:29 +0300 Subject: [PATCH 3/5] Add regression tests for the #5193 InteractionDialog fixes Five new tests in InteractionDialogTest, all verified to fail against the pre-fix InteractionDialog and pass with the fix: - showDuringFormTransitionAttachesToDestinationForm: show() during an in-flight form transition defers (isShowing() true, no form attach) and lands on the transition's destination form, not the outgoing one. - showPopupDialogPinsTargetComponentFormDuringTransition: the component popup attaches immediately to the target component's own form even mid-transition. - showPopupDialogRectDefersDuringTransition: the rect popup defers like show() and ends up on the destination form. - disposeDuringTransitionAbandonsDeferredShow: a dialog disposed during the deferral window never materializes. - stackableDisposeKeepsSharedLayerWhenSiblingInsertQueued: reproduces the queued-sibling-insert state deterministically (manual gate animation + single updateAnimations pop) and asserts dispose keeps the shared layer so the sibling materializes when the queue drains. Co-Authored-By: Claude Fable 5 --- .../components/InteractionDialogTest.java | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java index a47cd8fa5f..f547138b95 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java @@ -2,9 +2,14 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.AnimationManager; import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.DisplayTest; import com.codename1.ui.Form; import com.codename1.ui.Label; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.animations.ComponentAnimation; import com.codename1.ui.geom.Rectangle; import com.codename1.ui.layouts.BorderLayout; @@ -12,6 +17,7 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.*; @@ -495,4 +501,204 @@ private T getPrivateField(Object target, String name, Class type) throws field.setAccessible(true); return type.cast(field.get(target)); } + + /** + * Queue gate for the #5193 stackable test: a ComponentAnimation whose + * progress the test controls, used to force the form's AnimationManager + * into "animating" while a sibling dialog's insert gets queued. + */ + private static final class ManualAnimation extends ComponentAnimation { + boolean inProgress = true; + + @Override + public boolean isInProgress() { + return inProgress; + } + + @Override + protected void updateState() { + } + } + + @FormTest + void showDuringFormTransitionAttachesToDestinationForm() { + // Regression for #5193: show() anchored the dialog to + // Display.getCurrent(), which still returns the *outgoing* form + // while a form transition is queued (Form.show() is asynchronous). + // The dialog attached to the form leaving the screen, stayed + // invisible, and only materialized when that form was shown again + // ("the ID shows up at another screen"). show() must defer to the + // end of the transition and attach to the destination form. + Form outgoing = Display.getInstance().getCurrent(); + Form destination = new Form("Destination", new BorderLayout()); + destination.setTransitionInAnimator( + CommonTransitions.createSlide(CommonTransitions.SLIDE_HORIZONTAL, true, 150)); + destination.show(); + assertTrue(Display.getInstance().isInTransition(), + "test setup: a form transition must be in flight"); + + InteractionDialog dialog = new InteractionDialog("Deferred"); + dialog.setAnimateShow(false); + dialog.show(10, 10, 10, 10); + + // mid-transition the show is deferred: reported as showing but not + // attached to any form yet -- in particular not to the outgoing one + assertTrue(dialog.isShowing(), + "a deferred show must still report isShowing() so showDialog() keeps blocking"); + assertNull(dialog.getComponentForm(), + "#5193: the dialog must not attach mid-transition (the current form is leaving the screen)"); + + DisplayTest.flushEdt(); + + assertSame(destination, Display.getInstance().getCurrent(), + "test setup: the transition must have completed"); + assertTrue(dialog.isShowing(), "the deferred show must run once the transition completes"); + assertSame(destination, dialog.getComponentForm(), + "#5193: the dialog must attach to the destination form, not " + + (dialog.getComponentForm() == outgoing ? "the outgoing form" : "elsewhere")); + assertTrue(destination.getLayeredPane(InteractionDialog.class, true).contains(dialog)); + dialog.dispose(); + } + + @FormTest + void showPopupDialogPinsTargetComponentFormDuringTransition() { + // Regression for #5193: showPopupDialog(Component) computed the + // target's form for the formMode check but then anchored the dialog + // to Display.getCurrent() anyway -- the outgoing form during a + // transition. The popup must host on the form the target component + // actually belongs to, immediately, with no deferral needed. + Form destination = new Form("Destination", new BorderLayout()); + Label target = new Label("Target"); + destination.add(BorderLayout.CENTER, target); + destination.setTransitionInAnimator( + CommonTransitions.createSlide(CommonTransitions.SLIDE_HORIZONTAL, true, 150)); + destination.show(); + assertTrue(Display.getInstance().isInTransition(), + "test setup: a form transition must be in flight"); + + InteractionDialog dialog = new InteractionDialog("Pinned"); + dialog.setAnimateShow(false); + dialog.showPopupDialog(target); + + assertSame(destination, dialog.getComponentForm(), + "#5193: the popup must attach to the target component's form even mid-transition"); + + DisplayTest.flushEdt(); + + assertTrue(dialog.isShowing()); + assertSame(destination, dialog.getComponentForm()); + dialog.dispose(); + } + + @FormTest + void showPopupDialogRectDefersDuringTransition() { + // Same #5193 anchor bug for the rect-based popup entry point: the + // positioning math ran against the outgoing form's layered pane. + // With no component to pin a form from, the popup must defer to the + // end of the transition like show() does. + Form destination = new Form("Destination", new BorderLayout()); + destination.setTransitionInAnimator( + CommonTransitions.createSlide(CommonTransitions.SLIDE_HORIZONTAL, true, 150)); + destination.show(); + assertTrue(Display.getInstance().isInTransition(), + "test setup: a form transition must be in flight"); + + InteractionDialog dialog = new InteractionDialog(); + dialog.setAnimateShow(false); + dialog.showPopupDialog(new Rectangle(20, 30, 80, 60)); + + assertTrue(dialog.isShowing(), "deferred popup must report isShowing()"); + assertNull(dialog.getComponentForm(), "popup must not attach mid-transition"); + + DisplayTest.flushEdt(); + + assertSame(destination, dialog.getComponentForm(), + "#5193: the popup must end up on the destination form"); + dialog.dispose(); + } + + @FormTest + void disposeDuringTransitionAbandonsDeferredShow() { + // A dialog disposed while its show is still deferred (waiting for a + // form transition to finish) must never materialize. + Form destination = new Form("Destination", new BorderLayout()); + destination.setTransitionInAnimator( + CommonTransitions.createSlide(CommonTransitions.SLIDE_HORIZONTAL, true, 150)); + destination.show(); + assertTrue(Display.getInstance().isInTransition(), + "test setup: a form transition must be in flight"); + + InteractionDialog dialog = new InteractionDialog("Abandoned"); + dialog.setAnimateShow(false); + dialog.show(0, 0, 0, 0); + assertTrue(dialog.isShowing()); + + dialog.dispose(); + assertFalse(dialog.isShowing(), "dispose must clear the pending-show state"); + + DisplayTest.flushEdt(); + + assertFalse(dialog.isShowing(), "an abandoned deferred show must not run after the transition"); + assertNull(dialog.getComponentForm(), + "#5193: a dialog disposed during the deferral window must never attach to a form"); + } + + @FormTest + void stackableDisposeKeepsSharedLayerWhenSiblingInsertQueued() throws Exception { + // Regression for #5193 (stackable mode): cleanupLayer decided to + // detach the shared class layer with getComponentCount() == 0, which + // does not see inserts that Container.insertComponentAt deferred to + // the animation queue. Real-world sequence: dialog B is shown while + // dialog A's blocking dispose animation runs (invokeAndBlock keeps + // serving EDT callbacks), so B's add is only queued; A's dispose + // then saw an "empty" layer, detached it, and B's queued insert + // later flushed into the detached container -- B was lost forever. + // This test reproduces the exact state deterministically: a queued + // sibling insert with zero real children at cleanup time. + InteractionDialog.setStackable(true); + try { + Form form = Display.getInstance().getCurrent(); + InteractionDialog first = new InteractionDialog("First"); + first.setAnimateShow(false); + first.show(0, 0, 0, 0); + Container layer = form.getLayeredPane(InteractionDialog.class, true); + assertEquals(1, layer.getComponentCount(), "test setup: first dialog attached directly"); + + // force the AnimationManager into "animating" so the second + // dialog's insert is deferred onto the change queue + ManualAnimation gate = new ManualAnimation(); + form.getAnimationManager().addAnimation(gate); + + InteractionDialog second = new InteractionDialog("Second"); + second.setAnimateShow(false); + second.show(0, 0, 0, 0); + assertEquals(1, layer.getComponentCount(), "test setup: second insert must be queued, not real"); + assertEquals(2, layer.getChildrenAsList(true).size(), + "test setup: the queued insert must be visible to getChildrenAsList(true)"); + + // finish the gate and pop only it from the serial animation + // queue, leaving the second dialog's insert pending -- the + // state the animateUnlayoutAndWait dispose flow reaches + gate.inProgress = false; + Method update = AnimationManager.class.getDeclaredMethod("updateAnimations"); + update.setAccessible(true); + update.invoke(form.getAnimationManager()); + assertFalse(form.getAnimationManager().isAnimating(), + "test setup: manager idle with the sibling insert still queued"); + + first.dispose(); + + assertNotNull(layer.getParent(), + "#5193: dispose must not detach the shared layer while a sibling's insert is queued"); + + DisplayTest.flushAnimations(); + assertTrue(second.isShowing(), + "#5193: the queued sibling dialog must materialize after the queue drains"); + assertSame(form, second.getComponentForm(), + "#5193: the sibling dialog must be attached to the form, not a detached layer"); + second.dispose(); + } finally { + InteractionDialog.setStackable(false); + } + } } From fc82aa8b0289c3d3f38592fa3d59dae32a5dc732 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:21:29 +0300 Subject: [PATCH 4/5] mac-native harness: stream suite progress + sample wedged app on timeout The mac-native screenshot job intermittently stops mid-suite (observed on master runs 27287581172/27208374291/27183747605 and PR #5214's first attempt, always inside the dual-appearance theme phase at a varying test) and the CI log shows nothing between the last capture and the 1500s DeviceRunner timeout, because the app's CN1SS suite markers only ever landed in artifact files. - Stream the per-test CN1SS:INFO/ERR:suite markers (the #5213 stage logging) into the CI console live from whichever log channel (unified-log stream or `open` stdout capture) produces them first, so a wedged run shows exactly which test it died on. - On STAGE:TIMEOUT, dump the last 25 suite markers from both channels and capture a 5s `sample` of the stuck process into the uploaded artifacts (ParparVM emits readable com_codename1_* symbols, so the sample shows where the EDT is parked), echoing the CN1 frames to the console. Co-Authored-By: Claude Fable 5 --- scripts/run-mac-native-ui-tests.sh | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/scripts/run-mac-native-ui-tests.sh b/scripts/run-mac-native-ui-tests.sh index 1b97b689e9..94b450aa0e 100755 --- a/scripts/run-mac-native-ui-tests.sh +++ b/scripts/run-mac-native-ui-tests.sh @@ -353,8 +353,74 @@ fi END_MARKER="CN1SS:SUITE:FINISHED" TIMEOUT_SECONDS="${CN1SS_SUITE_TIMEOUT_SECONDS:-1500}" START_TIME="$(date +%s)" + +# Stream the DeviceRunner's per-test suite markers (the stage logging added +# for the iOS silent-timeout work in #5213) into the CI console as they +# appear, so a wedged run shows exactly which test it died on instead of +# timing out silently. The unified-log stream and the `open` stdout capture +# both carry the markers; lock onto whichever produces them first to avoid +# printing every line twice. +PROGRESS_SRC="" +PROGRESS_OFFSET=0 +SUITE_MARKER_REGEX="CN1SS:(INFO|ERR):suite|CN1SS:SUITE:" +emit_suite_progress() { + if [ -z "$PROGRESS_SRC" ]; then + if [ -s "$TEST_LOG" ] && grep -qE "$SUITE_MARKER_REGEX" "$TEST_LOG" 2>/dev/null; then + PROGRESS_SRC="$TEST_LOG" + elif [ -s "$FALLBACK_LOG" ] && grep -qE "$SUITE_MARKER_REGEX" "$FALLBACK_LOG" 2>/dev/null; then + PROGRESS_SRC="$FALLBACK_LOG" + else + return 0 + fi + rm_log "Streaming suite progress from $(basename "$PROGRESS_SRC")" + fi + local total + total="$(wc -l < "$PROGRESS_SRC" 2>/dev/null | tr -d '[:space:]' || echo 0)" + [ -n "$total" ] && [ "$total" -gt "$PROGRESS_OFFSET" ] || return 0 + tail -n +"$(( PROGRESS_OFFSET + 1 ))" "$PROGRESS_SRC" 2>/dev/null \ + | grep -E "$SUITE_MARKER_REGEX" \ + | cut -c -300 \ + | sed 's/^/[device-runner] /' || true + PROGRESS_OFFSET="$total" +} + +# On a wedge (suite timeout) capture everything needed to diagnose it: the +# last suite markers from both log channels and a native thread sample of +# the stuck app (ParparVM emits readable com_codename1_* C symbols, so the +# sample shows where the EDT is parked). The full sample lands in the +# uploaded artifacts; the CN1-related frames are echoed to the console. +dump_wedge_diagnostics() { + local src pid sample_file + rm_log "---- last suite markers before timeout ----" + for src in "$TEST_LOG" "$FALLBACK_LOG"; do + [ -s "$src" ] || continue + (grep -E "$SUITE_MARKER_REGEX" "$src" 2>/dev/null \ + | tail -n 25 | cut -c -300 \ + | sed "s|^|[$(basename "$src")] |") || true + done + pid="${RESOLVED_APP_PID:-}" + if [ -z "$pid" ] || ! kill -0 "$pid" >/dev/null 2>&1; then + pid="$(pgrep -x "$APP_PROCESS_NAME" 2>/dev/null | head -n 1 || true)" + fi + if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then + sample_file="$ARTIFACTS_DIR/app-sample-timeout.txt" + rm_log "Sampling wedged app (pid=$pid) for 5s -> $(basename "$sample_file")" + sample "$pid" 5 -file "$sample_file" >/dev/null 2>&1 || true + if [ -s "$sample_file" ]; then + rm_log "---- CN1 frames in wedged-app sample (full sample in artifacts) ----" + (grep -E "com_codename1|java_lang_Thread|Thread_[0-9]+" "$sample_file" \ + | head -n 40 | sed 's/^/[sample] /') || true + else + rm_log "Thread sample produced no output" + fi + else + rm_log "App process not running at timeout -- no thread sample possible" + fi +} + rm_log "Waiting for DeviceRunner completion marker ($END_MARKER) -- timeout ${TIMEOUT_SECONDS}s" while true; do + emit_suite_progress if [ -s "$FALLBACK_LOG" ] && grep -q "$END_MARKER" "$FALLBACK_LOG"; then rm_log "Detected completion marker in unified log" break @@ -373,10 +439,13 @@ while true; do NOW="$(date +%s)" if [ $(( NOW - START_TIME )) -ge $TIMEOUT_SECONDS ]; then rm_log "STAGE:TIMEOUT -> DeviceRunner did not emit completion marker within ${TIMEOUT_SECONDS}s" + dump_wedge_diagnostics break fi sleep 5 done +# Flush any markers that landed between the last poll and loop exit. +emit_suite_progress END_TIME=$(date +%s) echo "Test Execution : $(( (END_TIME - START_TIME) * 1000 )) ms" >> "$ARTIFACTS_DIR/mac-test-stats.txt" From 1f62204d628f351472ba8ae74542f4919d56a033 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:18:19 +0300 Subject: [PATCH 5/5] iOS harness: stream suite progress + sample wedged app on timeout Same tracing as the mac-native harness: build-ios wedged twice on PR #5214 (33/124 then 52/124 covered, both times stopping silently inside the graphics test block) and the console showed nothing between launch and the 1500s DeviceRunner timeout because the per-test CN1SS suite markers only existed in the device-log artifact. Stream the markers live into the CI console, and on STAGE:TIMEOUT dump the trailing markers plus a 5s `sample` of the simulator app process (it runs as a host macOS process; ParparVM's com_codename1_* symbols make the EDT stack readable) into the uploaded artifacts. Co-Authored-By: Claude Fable 5 --- scripts/run-ios-ui-tests.sh | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh index dc6a251ffd..507cfe4674 100755 --- a/scripts/run-ios-ui-tests.sh +++ b/scripts/run-ios-ui-tests.sh @@ -733,8 +733,54 @@ END_MARKER="CN1SS:SUITE:FINISHED" # the env override stays for local runs that want to push it further. TIMEOUT_SECONDS="${CN1SS_SUITE_TIMEOUT_SECONDS:-1500}" START_TIME="$(date +%s)" +# Stream the DeviceRunner's per-test suite markers (the #5213 stage +# logging) into the CI console as they appear, so a wedged run shows +# exactly which test it died on instead of timing out silently with the +# markers buried in the device-log artifact. +PROGRESS_OFFSET=0 +SUITE_MARKER_REGEX="CN1SS:(INFO|ERR):suite|CN1SS:SUITE:" +emit_suite_progress() { + [ -s "$TEST_LOG" ] || return 0 + local total + total="$(wc -l < "$TEST_LOG" 2>/dev/null | tr -d '[:space:]' || echo 0)" + [ -n "$total" ] && [ "$total" -gt "$PROGRESS_OFFSET" ] || return 0 + tail -n +"$(( PROGRESS_OFFSET + 1 ))" "$TEST_LOG" 2>/dev/null \ + | grep -E "$SUITE_MARKER_REGEX" \ + | cut -c -300 \ + | sed 's/^/[device-runner] /' || true + PROGRESS_OFFSET="$total" +} + +# On a wedge (suite timeout) dump the trailing suite markers and take a +# native thread sample of the simulated app -- it runs as a host macOS +# process, and ParparVM emits readable com_codename1_* C symbols, so the +# sample shows where the EDT is parked. The full sample lands in the +# uploaded artifacts; the CN1-related frames are echoed to the console. +dump_wedge_diagnostics() { + local pid sample_file + ri_log "---- last suite markers before timeout ----" + (grep -E "$SUITE_MARKER_REGEX" "$TEST_LOG" 2>/dev/null \ + | tail -n 25 | cut -c -300 | sed 's/^/[device-runner] /') || true + pid="$(pgrep -x "$APP_PROCESS_NAME" 2>/dev/null | head -n 1 || true)" + if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then + sample_file="$ARTIFACTS_DIR/app-sample-timeout.txt" + ri_log "Sampling wedged app (pid=$pid) for 5s -> $(basename "$sample_file")" + sample "$pid" 5 -file "$sample_file" >/dev/null 2>&1 || true + if [ -s "$sample_file" ]; then + ri_log "---- CN1 frames in wedged-app sample (full sample in artifacts) ----" + (grep -E "com_codename1|java_lang_Thread|Thread_[0-9]+" "$sample_file" \ + | head -n 40 | sed 's/^/[sample] /') || true + else + ri_log "Thread sample produced no output" + fi + else + ri_log "App process $APP_PROCESS_NAME not running at timeout -- no thread sample possible" + fi +} + ri_log "Waiting for DeviceRunner completion marker ($END_MARKER)" while true; do + emit_suite_progress if grep -q "$END_MARKER" "$TEST_LOG"; then ri_log "Detected DeviceRunner completion marker" break @@ -742,10 +788,13 @@ while true; do NOW="$(date +%s)" if [ $(( NOW - START_TIME )) -ge $TIMEOUT_SECONDS ]; then ri_log "STAGE:TIMEOUT -> DeviceRunner did not emit completion marker within ${TIMEOUT_SECONDS}s" + dump_wedge_diagnostics break fi sleep 5 done +# Flush any markers that landed between the last poll and loop exit. +emit_suite_progress END_TIME=$(date +%s) echo "Test Execution : $(( (END_TIME - START_TIME) * 1000 )) ms" >> "$ARTIFACTS_DIR/ios-test-stats.txt"