From 7dbd87d160f167ce32f4f4fd49998a94d5f62cf5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 02:49:47 -0700 Subject: [PATCH] test(android): drain runtime fixtures before sandbox teardown (#130861) --- .../openclaw/app/NodeForegroundServiceTest.kt | 7 +- .../ai/openclaw/app/NodeRuntimeTestCleanup.kt | 85 +++++++++++++++++++ .../ai/openclaw/app/VoiceWakeRuntimeTest.kt | 12 ++- 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeTestCleanup.kt diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt index c056541eb977..20dd9435dc45 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt @@ -175,7 +175,7 @@ class NodeForegroundServiceTest { assertEquals(2, Shadows.shadowOf(controller.get()).stopSelfResultId) assertNull(app.peekRuntime()) } finally { - controller.destroy() + closeNodeServiceTestFixture(controller, app) } } @@ -207,8 +207,7 @@ class NodeForegroundServiceTest { assertEquals(Service.START_NOT_STICKY, stopped) assertEquals(Service.START_STICKY, resumed) } finally { - controller.destroy() - app.peekRuntime()?.disconnect() + closeNodeServiceTestFixture(controller, app) } } @@ -225,7 +224,7 @@ class NodeForegroundServiceTest { assertFalse(runtime.isForeground.value) assertFalse(prefs.voiceMicEnabled.value) } finally { - runtime.disconnect() + closeNodeRuntimeTestFixture(runtime) } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeTestCleanup.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeTestCleanup.kt new file mode 100644 index 000000000000..b00e530afd35 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeTestCleanup.kt @@ -0,0 +1,85 @@ +package ai.openclaw.app + +import ai.openclaw.app.chat.AndroidClientDatabases +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import org.robolectric.shadows.ShadowPausedLooper +import org.robolectric.util.ReflectionHelpers + +internal fun closeNodeRuntimeTestFixture(runtime: NodeRuntime) = drainWithMainLooper { closeRuntime(runtime) } + +internal fun closeNodeServiceTestFixture( + controller: ServiceController, + app: NodeApp, +) { + try { + controller.destroy() + } finally { + drainWithMainLooper { + // A canceled service can still be constructing the process runtime. Join its producer + // before taking the app's final runtime snapshot, including the asynchronous STOP task. + ReflectionHelpers + .getField(controller.get(), "scope") + .coroutineContext.job + .join() + ReflectionHelpers + .getField(app, "runtimeScope") + .coroutineContext.job + .cancelAndJoin() + app.peekRuntime()?.let { closeRuntime(it) } + } + } +} + +private suspend fun closeRuntime(runtime: NodeRuntime) { + val databases = ReflectionHelpers.getField(runtime, "clientDatabases") + try { + try { + runtime.disconnect() + } finally { + ReflectionHelpers + .getField(runtime, "scope") + .coroutineContext.job + .cancelAndJoin() + } + } finally { + try { + // Database initialization has its own IO scope. Await its real result before closing so + // native loading cannot escape this sandbox and initialization failures remain test failures. + databases.clientStateDatabase() + } finally { + databases.close() + } + } +} + +private fun drainWithMainLooper(block: suspend () -> Unit) { + val mainLooper = Looper.getMainLooper() + val shadowLooper = shadowOf(mainLooper) as ShadowPausedLooper + val handler = Handler(mainLooper) + var completed = false + val wakeMain = Runnable { completed = true } + try { + runBlocking { + val cleanup = async(Dispatchers.Default) { block() } + cleanup.invokeOnCompletion { handler.post(wakeMain) } + // Robolectric's paused Main looper needs pumping while worker finalizers dispatch to it. + // Completion posts a wakeup so poll cannot strand Main after the last worker finishes. + while (!completed) { + shadowLooper.poll(0) + shadowLooper.idle() + } + cleanup.await() + } + } finally { + handler.removeCallbacks(wakeMain) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt index 2a835c813d93..ce2195738847 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/VoiceWakeRuntimeTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,6 +22,13 @@ import java.util.UUID @RunWith(RobolectricTestRunner::class) class VoiceWakeRuntimeTest { + private var runtimeUnderTest: NodeRuntime? = null + + @After + fun closeRuntime() { + runtimeUnderTest?.let(::closeNodeRuntimeTestFixture) + } + @Test fun disconnectedSaveDoesNotCreateLocalOverride() { val runtime = createTestRuntime() @@ -173,7 +181,7 @@ class VoiceWakeRuntimeTest { ) val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) prefs.setVoiceWakeEnabled(true) - val runtime = NodeRuntime(app, prefs, mode = NodeRuntimeMode.ScreenshotFixture) + val runtime = NodeRuntime(app, prefs, mode = NodeRuntimeMode.ScreenshotFixture).also { runtimeUnderTest = it } val endpoint = GatewayEndpoint.manual("127.0.0.1", 18789) writeField(runtime, "connectedEndpoint", endpoint) @@ -225,7 +233,7 @@ class VoiceWakeRuntimeTest { "openclaw.node.voicewake.runtime.test.${UUID.randomUUID()}", Context.MODE_PRIVATE, ) - return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)).also { runtimeUnderTest = it } } private fun seedConnectedRuntime(runtime: NodeRuntime): GatewayEndpoint {