fix(android): stop canceled work from surfacing failures (#105047)

* fix(android): propagate request cancellation

* chore: keep release notes in PR body
This commit is contained in:
Peter Steinberger
2026-07-12 07:22:27 +01:00
committed by GitHub
parent ae3008c70a
commit 46355cbdcf
6 changed files with 95 additions and 0 deletions
@@ -7,6 +7,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
@@ -160,6 +161,8 @@ class LocationHandler private constructor(
code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time",
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
return GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
@@ -9,6 +9,7 @@ import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
@@ -317,6 +318,8 @@ class MotionHandler private constructor(
)
} catch (err: IllegalArgumentException) {
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE",
@@ -353,6 +356,8 @@ class MotionHandler private constructor(
)
} catch (err: IllegalArgumentException) {
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "MOTION_UNAVAILABLE",
@@ -1,6 +1,7 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -58,6 +59,8 @@ internal class TalkSpeakClient(
paramsJson = json.encodeToString(TalkSpeakRequest.from(text = text, directive = directive)),
timeoutMs = 45_000,
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
return TalkSpeakResult.Failure(err.message ?: "talk.speak request failed")
}
@@ -3,11 +3,13 @@ package ai.openclaw.app.node
import ai.openclaw.app.LocationMode
import android.content.Context
import android.location.LocationManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class LocationHandlerTest : NodeHandlerRobolectricTest() {
@@ -205,6 +207,28 @@ class LocationHandlerTest : NodeHandlerRobolectricTest() {
assertEquals("LOCATION_UNAVAILABLE", result.error?.code)
assertEquals("gps offline", result.error?.message)
}
@Test
fun handleLocationGet_propagatesParentCancellation() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
failure = CancellationException("request retired"),
),
)
try {
handler.handleLocationGet(null)
fail("expected cancellation to propagate")
} catch (err: CancellationException) {
assertEquals("request retired", err.message)
}
}
}
private class FakeLocationDataSource(
@@ -1,6 +1,7 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
@@ -9,6 +10,7 @@ import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class MotionHandlerTest : NodeHandlerRobolectricTest() {
@@ -89,6 +91,46 @@ class MotionHandlerTest : NodeHandlerRobolectricTest() {
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
}
@Test
fun handleMotionActivity_propagatesParentCancellation() =
runTest {
val handler =
MotionHandler.forTesting(
appContext(),
FakeMotionDataSource(
hasPermission = true,
activityError = CancellationException("invoke retired"),
),
)
try {
handler.handleMotionActivity(null)
fail("expected cancellation to propagate")
} catch (err: CancellationException) {
assertEquals("invoke retired", err.message)
}
}
@Test
fun handleMotionPedometer_propagatesParentCancellation() =
runTest {
val handler =
MotionHandler.forTesting(
appContext(),
FakeMotionDataSource(
hasPermission = true,
pedometerError = CancellationException("invoke retired"),
),
)
try {
handler.handleMotionPedometer(null)
fail("expected cancellation to propagate")
} catch (err: CancellationException) {
assertEquals("invoke retired", err.message)
}
}
}
private class FakeMotionDataSource(
@@ -2,9 +2,11 @@ package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class TalkSpeakClientTest {
@@ -128,4 +130,20 @@ class TalkSpeakClientTest {
val result = client.synthesize(text = "Hello", directive = null)
assertTrue(result is TalkSpeakResult.FallbackToLocal)
}
@Test
fun propagatesRequestCancellation() =
runTest {
val client =
TalkSpeakClient(
requestDetailed = { _, _, _ -> throw CancellationException("talk stopped") },
)
try {
client.synthesize(text = "Hello", directive = null)
fail("expected cancellation to propagate")
} catch (err: CancellationException) {
assertEquals("talk stopped", err.message)
}
}
}