From 09d7ffb907788eee6a5425f7e83e14ba87ca1b8e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 15 Oct 2026 01:28:29 +0200 Subject: [PATCH] test(support): bound recomposer teardown in draft reactivation test The reactivation test sent a single frame per step and then waited for the recomposer to become idle without a bound. Apply notifications that arrive on another thread, such as from the desktop global snapshot manager started by earlier UI tests, can resume the recomposer after that frame was sent, so it waits for a frame that never comes. The outer timeout then ran the teardown, where Recomposer.close() does not resume a runner waiting for a frame while an effect is still completing, and the unbounded join hung the test worker. Keep sending frames while recomposition is pending, bound every composition wait with a stage-specific failure, and stop the recomposer with cancel() followed by a bounded join that cancels the runner as a fallback. --- ...support-settings-reactivation-test-hang.md | 7 ++ .../SupportSettingsDraftReactivationTest.kt | 72 ++++++++++++++----- 2 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 changes/unreleased/support-settings-reactivation-test-hang.md diff --git a/changes/unreleased/support-settings-reactivation-test-hang.md b/changes/unreleased/support-settings-reactivation-test-hang.md new file mode 100644 index 000000000..d42c2f87e --- /dev/null +++ b/changes/unreleased/support-settings-reactivation-test-hang.md @@ -0,0 +1,7 @@ +category: internal +issue: none +pull: none +platforms: desktop +user-facing: no + +The desktop support-settings reactivation test now keeps offering frames while recomposition is pending and cancels its recomposer with bounded waits, so a missed frame fails the test instead of hanging the desktop test suite. diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt index b21e5adae..96ff4e29f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt @@ -6,18 +6,22 @@ import androidx.compose.runtime.Composition import androidx.compose.runtime.Recomposer import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotSame +import kotlin.test.fail class SupportSettingsDraftReactivationTest { @Test - fun `live settings composition acquires a new holder after safe reactivation`() = runBlocking { + fun `live settings composition acquires a new holder after safe reactivation`() = runBlocking { val session = NextcloudSession( serverUrl = "https://support-reactivation.example.test", loginName = "alice", @@ -31,19 +35,31 @@ class SupportSettingsDraftReactivationTest { } val composition = Composition(EmptyUnitApplier(), recomposer) var frameTime = 0L - suspend fun advanceComposition() { - yield() - Snapshot.sendApplyNotifications() - yield() - frameClock.sendFrame(frameTime++) - recomposer.awaitIdle() + + // Apply notifications can also arrive from another thread, for example after an earlier + // desktop UI test started the global snapshot manager. A single frame can then be sent + // before the recomposer waits for one, so keep offering frames while work is pending. + suspend fun advanceCompositionUntil(stage: String, settled: () -> Boolean) { + withTimeoutOrNull(COMPOSITION_TIMEOUT_MILLIS) { + do { + yield() + Snapshot.sendApplyNotifications() + yield() + while (recomposer.hasPendingWork) { + frameClock.sendFrame(frameTime++) + yield() + } + } while (!settled()) + } ?: fail("Composition did not settle within $COMPOSITION_TIMEOUT_MILLIS ms while $stage.") } + var rendered: SupportSettingsDraftState? = null + var teardownFailure: String? = null try { composition.setContent { rendered = rememberAccountSupportSettingsDraftState(session) } - advanceComposition() + advanceCompositionUntil("composing the initial holder") { rendered != null } val retiredHolder = requireNotNull(rendered) val staleCallback = retiredHolder::updateReportDraft retiredHolder.updateReportDraft("Private text before removal") @@ -52,9 +68,7 @@ class SupportSettingsDraftReactivationTest { assertFalse(retiredHolder.hasDraftContent()) AccountPrivateMemoryLifecycle.activateAccount(account) - withTimeout(5_000L) { - while (rendered === retiredHolder) advanceComposition() - } + advanceCompositionUntil("waiting for the reactivated holder") { rendered !== retiredHolder } val currentHolder = requireNotNull(rendered) assertNotSame(retiredHolder, currentHolder) @@ -64,14 +78,33 @@ class SupportSettingsDraftReactivationTest { assertEquals("", retiredHolder.reportDraft) assertEquals("Fresh text after recovery", currentHolder.reportDraft) } finally { - composition.dispose() - recomposer.close() - recomposerJob.join() - AccountPrivateMemoryLifecycle.retireAccount(account) - AccountPrivateMemoryLifecycle.activateAccount(account) + try { + composition.dispose() + teardownFailure = stopRecomposer(recomposer, recomposerJob) + } finally { + AccountPrivateMemoryLifecycle.retireAccount(account) + AccountPrivateMemoryLifecycle.activateAccount(account) + } } + teardownFailure?.let { fail(it) } } + /** + * Recomposer.close() does not resume a runner that already waits for a frame once an effect + * is still completing, and this frame clock sends no more frames, so join() would never + * return. cancel() stops the runner in every state; the runner job is cancelled as a fallback. + */ + private suspend fun stopRecomposer(recomposer: Recomposer, runner: Job): String? = + withContext(NonCancellable) { + recomposer.cancel() + if (withTimeoutOrNull(TEARDOWN_TIMEOUT_MILLIS) { runner.join() } != null) { + null + } else { + runner.cancel() + "Recomposer did not stop within $TEARDOWN_TIMEOUT_MILLIS ms after cancellation." + } + } + private class EmptyUnitApplier : AbstractApplier(Unit) { override fun insertTopDown(index: Int, instance: Unit) = Unit override fun insertBottomUp(index: Int, instance: Unit) = Unit @@ -79,4 +112,9 @@ class SupportSettingsDraftReactivationTest { override fun move(from: Int, to: Int, count: Int) = Unit override fun onClear() = Unit } + + private companion object { + const val COMPOSITION_TIMEOUT_MILLIS = 5_000L + const val TEARDOWN_TIMEOUT_MILLIS = 5_000L + } }