Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changes/unreleased/support-settings-reactivation-test-hang.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
val session = NextcloudSession(
serverUrl = "https://support-reactivation.example.test",
loginName = "alice",
Expand All @@ -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")
Expand All @@ -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)

Expand All @@ -64,19 +78,43 @@ 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>(Unit) {
override fun insertTopDown(index: Int, instance: Unit) = Unit
override fun insertBottomUp(index: Int, instance: Unit) = Unit
override fun remove(index: Int, count: Int) = Unit
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
}
}
Loading