diff --git a/CodenameOne/src/com/codename1/components/InteractionDialog.java b/CodenameOne/src/com/codename1/components/InteractionDialog.java index 48e6aed17e..89cfc00932 100644 --- a/CodenameOne/src/com/codename1/components/InteractionDialog.java +++ b/CodenameOne/src/com/codename1/components/InteractionDialog.java @@ -101,6 +101,27 @@ 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 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; @@ -289,10 +310,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 +331,17 @@ 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 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().getCurrent(); + } + private Container getLayeredPane(Form f) { //return f.getLayeredPane(); Container c; @@ -347,7 +385,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(); @@ -386,10 +429,31 @@ 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; - Form f = Display.getInstance().getCurrent(); + 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; Style unselectedStyle = getUnselectedStyle(); @@ -458,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(); @@ -562,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(); @@ -643,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) @@ -858,7 +926,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 +961,40 @@ 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(); + 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(); + } + // 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/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); + } + } } 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" 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"