Skip to content

Commit 758a720

Browse files
psjostromclaude
andauthored
fix: prevent crash on boot in airplane mode (#218)
* fix: prevent crash on boot in airplane mode Strimma crashed on every boot when the device had no network. The foreground service entered an infinite restart loop because Ktor's CIO engine surfaces DNS lookup failures as `java.nio.channels.UnresolvedAddressException`, which extends `IllegalArgumentException` — NOT `IOException`. The 14 catch blocks across the five HTTP-touching clients only caught `IOException`, so the exception escaped the coroutine launched by `SyncOrchestrator.start()` on `Dispatchers.Main` and killed the process. Widen the catch in every HTTP-touching client to `Exception` (with `@Suppress("TooGenericExceptionCaught")` and a rationale comment), preserving the more specific `SerializationException` / `SQLiteException` catches above it for differentiated error logging. Affects: - NightscoutClient.kt (4 sites) - NightscoutPuller.kt (1 site) - TreatmentSyncer.kt (2 sites) - LibreLinkUpClient.kt (4 sites) - TidepoolClient.kt (3 sites) Add regression tests using Ktor `MockEngine` to throw `UnresolvedAddressException` directly from the request handler and assert each client surfaces the failure as a return value (or, for `fetchTreatments`, rethrows for the caller to catch) rather than propagating uncaught. Verified on device: with airplane mode on, Strimma now boots cleanly into the foreground notification instead of looping fatal restarts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: address review feedback for airplane-mode crash fix Extract `withNetworkBoundary` helper consolidating the catch policy across 12 of 14 sites. The two exceptions stay manual: TreatmentSyncer.syncSince keeps per-type IntegrationStatus.Error differentiation (Sync failed / DB error / network), and NightscoutClient.fetchTreatments keeps the rethrow contract that callers depend on. Add CoroutineExceptionHandler to StrimmaService.scope, NightscoutPusher.scope, and TidepoolUploader.scope as defense in depth — anything that escapes the leaf catches now logs and is swallowed instead of crashing the foreground service. The leaf catches stay as primary mechanism; the handlers are the belt. Include the exception class name in every catch log so a real programming bug (NPE, ISE, IAE) is distinguishable from a transient network failure when triaging from DebugLog. Fix LibreLinkUpClient and TidepoolClient test seams: both now close the production CIO HttpClient before swapping in the MockEngine-backed test client (consistent with NightscoutClient — previously TidepoolClient leaked one CIO engine per test instance). Add airplane-mode regression tests: - LibreLinkUpClientAirplaneModeTest (4 tests covering all widened catches) - NightscoutPullerAirplaneModeTest (1 test, throwing FakeClient) - TreatmentSyncerAirplaneModeTest (1 test using airplane-mode NightscoutClient) - TidepoolClientAirplaneModeTest.createDataset (3rd test, closes the matrix) Replace nested runBlocking + assertThrows with try/catch + fail() in the fetchTreatments rethrow test (assertFailsWith would need kotlin-test on the classpath). Net diff: +146 / -269 lines. Punted to follow-up: moving SyncOrchestrator off Dispatchers.Main onto a dedicated IO scope. Independent of the changes here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 58c2a32 commit 758a720

14 files changed

Lines changed: 589 additions & 209 deletions

app/src/main/java/com/psjostrom/strimma/network/LibreLinkUpClient.kt

Lines changed: 19 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@ import io.ktor.http.*
1111
import io.ktor.serialization.kotlinx.json.*
1212
import kotlinx.serialization.SerialName
1313
import kotlinx.serialization.Serializable
14-
import kotlinx.serialization.SerializationException
1514
import kotlinx.serialization.json.Json
16-
import java.io.IOException
1715
import java.security.MessageDigest
18-
import kotlin.coroutines.cancellation.CancellationException
1916
import javax.inject.Inject
2017
import javax.inject.Singleton
2118

@@ -117,7 +114,7 @@ class LibreLinkUpClient @Inject constructor() {
117114

118115
private val json = Json { ignoreUnknownKeys = true }
119116

120-
private val client = HttpClient(CIO) {
117+
private var client = HttpClient(CIO) {
121118
install(HttpTimeout) {
122119
requestTimeoutMillis = REQUEST_TIMEOUT_MS
123120
socketTimeoutMillis = SOCKET_TIMEOUT_MS
@@ -127,6 +124,16 @@ class LibreLinkUpClient @Inject constructor() {
127124
}
128125
}
129126

127+
/**
128+
* Test seam: replaces the production CIO client with a [MockEngine]-backed one.
129+
* The default client is constructed by the primary constructor and immediately
130+
* closed here so its engine threads/selectors don't leak per test run.
131+
*/
132+
internal constructor(testClient: HttpClient) : this() {
133+
client.close()
134+
client = testClient
135+
}
136+
130137
private fun lluHeaders(session: LluSession? = null): HeadersBuilder.() -> Unit = {
131138
append("product", PRODUCT)
132139
append("version", CLIENT_VERSION)
@@ -146,22 +153,14 @@ class LibreLinkUpClient @Inject constructor() {
146153
baseUrl: String = DEFAULT_BASE_URL,
147154
allowRedirect: Boolean = true
148155
): LluSession? {
149-
return try {
156+
return withNetworkBoundary<LluSession?>(label = "LLU login", onError = { null }) {
150157
val response = client.post("$baseUrl/llu/auth/login") {
151158
headers(lluHeaders())
152159
setBody(LluLoginRequest(email, password))
153160
}
154161

155162
val loginResponse = response.body<LluLoginResponse>()
156163
parseLoginResponse(loginResponse, email, password, baseUrl, allowRedirect)
157-
} catch (e: CancellationException) {
158-
throw e
159-
} catch (e: IOException) {
160-
DebugLog.log(message = "LLU login error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
161-
null
162-
} catch (e: SerializationException) {
163-
DebugLog.log(message = "LLU login parse error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
164-
null
165164
}
166165
}
167166

@@ -203,50 +202,35 @@ class LibreLinkUpClient @Inject constructor() {
203202
}
204203

205204
suspend fun getConnections(session: LluSession): List<LluConnection>? {
206-
return try {
205+
return withNetworkBoundary<List<LluConnection>?>(label = "LLU connections", onError = { null }) {
207206
val response = client.get("${session.baseUrl}/llu/connections") {
208207
headers(lluHeaders(session))
209208
}
210209
if (!response.status.isSuccess()) {
211210
DebugLog.log(message = "LLU connections HTTP ${response.status.value}")
212-
return null
211+
return@withNetworkBoundary null
213212
}
214213
response.body<LluConnectionsResponse>().data
215-
} catch (e: CancellationException) {
216-
throw e
217-
} catch (e: IOException) {
218-
DebugLog.log(message = "LLU connections error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
219-
null
220-
} catch (e: SerializationException) {
221-
DebugLog.log(message = "LLU connections parse error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
222-
null
223214
}
224215
}
225216

226217
suspend fun getGraph(session: LluSession, patientId: String): LluGraphData? {
227-
return try {
218+
return withNetworkBoundary<LluGraphData?>(label = "LLU graph", onError = { null }) {
228219
val response = client.get("${session.baseUrl}/llu/connections/$patientId/graph") {
229220
headers(lluHeaders(session))
230221
}
231222
if (!response.status.isSuccess()) {
232223
DebugLog.log(message = "LLU graph HTTP ${response.status.value}")
233-
return null
224+
return@withNetworkBoundary null
234225
}
235226
response.body<LluGraphResponse>().data
236-
} catch (e: CancellationException) {
237-
throw e
238-
} catch (e: IOException) {
239-
DebugLog.log(message = "LLU graph error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
240-
null
241-
} catch (e: SerializationException) {
242-
DebugLog.log(message = "LLU graph parse error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
243-
null
244227
}
245228
}
246229

247230
@Suppress("CyclomaticComplexMethod") // 10-region lookup is inherently branchy
248-
private suspend fun resolveRegionUrl(baseUrl: String, region: String): String? {
249-
return try {
231+
@androidx.annotation.VisibleForTesting
232+
internal suspend fun resolveRegionUrl(baseUrl: String, region: String): String? {
233+
return withNetworkBoundary<String?>(label = "LLU region resolve", onError = { null }) {
250234
val response = client.get("$baseUrl/llu/config/country?country=DE") {
251235
headers(lluHeaders())
252236
}
@@ -266,14 +250,6 @@ class LibreLinkUpClient @Inject constructor() {
266250
else -> null
267251
}
268252
regionDef?.lslApi?.takeIf { it.isNotBlank() }
269-
} catch (e: CancellationException) {
270-
throw e
271-
} catch (e: IOException) {
272-
DebugLog.log(message = "LLU region resolve error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
273-
null
274-
} catch (e: SerializationException) {
275-
DebugLog.log(message = "LLU region resolve parse error: ${e.message?.take(NightscoutClient.MAX_ERROR_LENGTH)}")
276-
null
277253
}
278254
}
279255

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.psjostrom.strimma.network
2+
3+
import com.psjostrom.strimma.receiver.DebugLog
4+
import kotlin.coroutines.cancellation.CancellationException
5+
6+
private const val MAX_LOG_LENGTH = 80
7+
8+
/**
9+
* Wraps an HTTP/network call with the project's standard catch policy:
10+
*
11+
* - `CancellationException` propagates so structured concurrency stays intact.
12+
* - Any other `Exception` is logged (with class name and message) and converted
13+
* to a return value via [onError].
14+
*
15+
* The broad catch is mandatory, not a smell. Ktor's CIO engine surfaces
16+
* airplane-mode DNS failures as `UnresolvedAddressException`, which extends
17+
* `IllegalArgumentException` rather than `IOException` — a narrower catch lets
18+
* these escape the foreground-service scope and crash the process.
19+
*
20+
* The exception class name is included in the log so a real programming bug
21+
* (NPE, ISE, IAE) is distinguishable from a transient network failure when
22+
* triaging from `DebugLog`.
23+
*
24+
* Use [onError] to return a default (`{ false }`, `{ null }`,
25+
* `{ Result.failure(it) }`) or to rethrow (`{ throw it }`).
26+
*/
27+
internal suspend inline fun <T> withNetworkBoundary(
28+
label: String,
29+
onError: (Exception) -> T,
30+
block: () -> T,
31+
): T = try {
32+
block()
33+
} catch (e: CancellationException) {
34+
throw e
35+
} catch (
36+
@Suppress("TooGenericExceptionCaught")
37+
e: Exception
38+
) {
39+
DebugLog.log("$label error [${e.javaClass.simpleName}]: ${e.message?.take(MAX_LOG_LENGTH)}")
40+
onError(e)
41+
}

app/src/main/java/com/psjostrom/strimma/network/NightscoutClient.kt

Lines changed: 42 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import java.time.Instant
2424
import java.time.ZoneOffset
2525
import java.time.format.DateTimeFormatter
2626
import java.time.format.DateTimeParseException
27-
import kotlin.coroutines.cancellation.CancellationException
2827
import javax.inject.Inject
2928
import javax.inject.Singleton
3029

@@ -131,7 +130,10 @@ open class NightscoutClient @Inject constructor() {
131130
val hashedSecret = hashSecret(apiSecret)
132131
val statusUrl = "${normalizeUrl(baseUrl)}/api/v1/status.json"
133132

134-
return try {
133+
return withNetworkBoundary(
134+
label = "Connection test",
135+
onError = { ConnectionTestResult(false, error = it.message?.take(MAX_ERROR_LENGTH) ?: "Connection failed") }
136+
) {
135137
val response = client.get(statusUrl) { header("api-secret", hashedSecret) }
136138
if (response.status.isSuccess()) {
137139
val body = response.bodyAsText()
@@ -140,24 +142,12 @@ open class NightscoutClient @Inject constructor() {
140142
.jsonObject["settings"]
141143
?.jsonObject?.get("customTitle")
142144
?.jsonPrimitive?.contentOrNull
143-
} catch (_: kotlinx.serialization.SerializationException) { null }
145+
} catch (_: SerializationException) { null }
144146
catch (_: IllegalArgumentException) { null }
145147
ConnectionTestResult(true, serverName = name)
146148
} else {
147149
ConnectionTestResult(false, error = "HTTP ${response.status.value}")
148150
}
149-
} catch (e: CancellationException) {
150-
throw e
151-
} catch (e: IOException) {
152-
com.psjostrom.strimma.receiver.DebugLog.log(
153-
message = "Connection test error: ${e.message?.take(MAX_ERROR_LENGTH)}"
154-
)
155-
ConnectionTestResult(false, error = e.message?.take(MAX_ERROR_LENGTH) ?: "Connection failed")
156-
} catch (e: SerializationException) {
157-
com.psjostrom.strimma.receiver.DebugLog.log(
158-
message = "Connection test parse error: ${e.message?.take(MAX_ERROR_LENGTH)}"
159-
)
160-
ConnectionTestResult(false, error = e.message?.take(MAX_ERROR_LENGTH) ?: "Connection failed")
161151
}
162152
}
163153

@@ -179,7 +169,7 @@ open class NightscoutClient @Inject constructor() {
179169
)
180170
}
181171

182-
return try {
172+
return withNetworkBoundary(label = "Push", onError = { false }) {
183173
val fullUrl = "${normalizeUrl(baseUrl)}/api/v1/entries"
184174
val response = client.post(fullUrl) {
185175
contentType(ContentType.Application.Json)
@@ -192,18 +182,6 @@ open class NightscoutClient @Inject constructor() {
192182
)
193183
}
194184
response.status.isSuccess()
195-
} catch (e: CancellationException) {
196-
throw e
197-
} catch (e: IOException) {
198-
com.psjostrom.strimma.receiver.DebugLog.log(
199-
message = "Push error: ${e.message?.take(MAX_ERROR_LENGTH)}"
200-
)
201-
false
202-
} catch (e: SerializationException) {
203-
com.psjostrom.strimma.receiver.DebugLog.log(
204-
message = "Push serialize error: ${e.message?.take(MAX_ERROR_LENGTH)}"
205-
)
206-
false
207185
}
208186
}
209187

@@ -219,7 +197,7 @@ open class NightscoutClient @Inject constructor() {
219197
val hashedSecret = hashSecret(apiSecret)
220198
val fullUrl = buildFetchUrl(baseUrl, since, count, before)
221199

222-
return try {
200+
return withNetworkBoundary<List<NightscoutEntryResponse>?>(label = "Fetch", onError = { null }) {
223201
val response = client.get(fullUrl) {
224202
header("api-secret", hashedSecret)
225203
}
@@ -231,18 +209,6 @@ open class NightscoutClient @Inject constructor() {
231209
} else {
232210
response.body<List<NightscoutEntryResponse>>()
233211
}
234-
} catch (e: CancellationException) {
235-
throw e
236-
} catch (e: IOException) {
237-
com.psjostrom.strimma.receiver.DebugLog.log(
238-
message = "Fetch error: ${e.message?.take(MAX_ERROR_LENGTH)}"
239-
)
240-
null
241-
} catch (e: SerializationException) {
242-
com.psjostrom.strimma.receiver.DebugLog.log(
243-
message = "Fetch parse error: ${e.message?.take(MAX_ERROR_LENGTH)}"
244-
)
245-
null
246212
}
247213
}
248214

@@ -258,58 +224,46 @@ open class NightscoutClient @Inject constructor() {
258224
val sinceIso = isoFormatter.format(Instant.ofEpochMilli(since))
259225
val fullUrl = "${normalizeUrl(baseUrl)}/api/v1/treatments.json?find[created_at][\$gte]=$sinceIso&count=$count"
260226

261-
val response = try {
262-
client.get(fullUrl) { header("api-secret", hashedSecret) }
263-
} catch (e: CancellationException) {
264-
throw e
265-
} catch (e: IOException) {
266-
debugLogAndRethrow(e, "Treatments fetch error: ${e.message?.take(MAX_ERROR_LENGTH)}")
267-
}
268-
269-
if (response.status.value == HTTP_NOT_FOUND) {
270-
com.psjostrom.strimma.receiver.DebugLog.log(
271-
message = "Treatments: 404 — server doesn't support treatments"
272-
)
273-
return emptyList()
274-
}
275-
if (!response.status.isSuccess()) {
276-
debugLogAndRethrow(
277-
IOException("HTTP ${response.status.value}"),
278-
"Treatments HTTP ${response.status.value}: $fullUrl"
279-
)
280-
}
227+
return withNetworkBoundary<List<Treatment>>(label = "Treatments fetch", onError = { throw it }) {
228+
val response = client.get(fullUrl) { header("api-secret", hashedSecret) }
281229

282-
val nsTreatments = try {
283-
response.body<List<NightscoutTreatment>>()
284-
} catch (e: SerializationException) {
285-
debugLogAndRethrow(e, "Treatments parse error: ${e.message?.take(MAX_ERROR_LENGTH)}")
286-
}
230+
if (response.status.value == HTTP_NOT_FOUND) {
231+
com.psjostrom.strimma.receiver.DebugLog.log(
232+
message = "Treatments: 404 — server doesn't support treatments"
233+
)
234+
return@withNetworkBoundary emptyList()
235+
}
236+
if (!response.status.isSuccess()) {
237+
// IOException message is short ("HTTP 500") so callers can pattern-match on it;
238+
// the helper logs class name + message, so URL detail goes in a separate log line.
239+
com.psjostrom.strimma.receiver.DebugLog.log(
240+
message = "Treatments HTTP ${response.status.value}: $fullUrl"
241+
)
242+
throw IOException("HTTP ${response.status.value}")
243+
}
287244

288-
val now = System.currentTimeMillis()
289-
return nsTreatments.mapNotNull { ns ->
290-
val createdAtStr = ns.createdAt ?: return@mapNotNull null
291-
val eventType = ns.eventType ?: return@mapNotNull null
292-
val createdAtMs = parseIsoTimestamp(createdAtStr) ?: return@mapNotNull null
293-
val id = ns.id ?: generateTreatmentId(createdAtStr, eventType, ns.insulin, ns.carbs)
294-
Treatment(
295-
id = id,
296-
createdAt = createdAtMs,
297-
eventType = eventType,
298-
insulin = ns.insulin,
299-
carbs = ns.carbs,
300-
basalRate = ns.absolute,
301-
duration = ns.duration,
302-
enteredBy = ns.enteredBy,
303-
fetchedAt = now
304-
)
245+
val nsTreatments = response.body<List<NightscoutTreatment>>()
246+
val now = System.currentTimeMillis()
247+
nsTreatments.mapNotNull { ns ->
248+
val createdAtStr = ns.createdAt ?: return@mapNotNull null
249+
val eventType = ns.eventType ?: return@mapNotNull null
250+
val createdAtMs = parseIsoTimestamp(createdAtStr) ?: return@mapNotNull null
251+
val id = ns.id ?: generateTreatmentId(createdAtStr, eventType, ns.insulin, ns.carbs)
252+
Treatment(
253+
id = id,
254+
createdAt = createdAtMs,
255+
eventType = eventType,
256+
insulin = ns.insulin,
257+
carbs = ns.carbs,
258+
basalRate = ns.absolute,
259+
duration = ns.duration,
260+
enteredBy = ns.enteredBy,
261+
fetchedAt = now
262+
)
263+
}
305264
}
306265
}
307266

308-
private fun debugLogAndRethrow(error: Throwable, message: String): Nothing {
309-
com.psjostrom.strimma.receiver.DebugLog.log(message = message)
310-
throw error
311-
}
312-
313267
private fun parseIsoTimestamp(iso: String): Long? {
314268
return try {
315269
java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()

0 commit comments

Comments
 (0)