fix(android): a worker crash lands in the log instead of dying silent

The diagnostics page showed the status job "waiting" while its runs
and the report-now kick died without a trace: any exception before the
first log line was invisible, indistinguishable from a job that never
starts. Every worker now wraps its run — a crash logs its type, message,
and first frame; a cancellation logs as such and rethrows. The screen
gains the report-now kick's own row (a one-time job's empty list is
"never run", not an error) and the tap lands in the log, so the next
stale-row mystery names its cause instead of hiding it.
This commit is contained in:
vhaudiquet committed 2026-10-25 19:18:13 +02:00
1 parent 7225dfddd3
commit 5e9952ac6d
7 files changed
+85 -16

No files matched your search

+4 -2
View File
@@ -29,8 +29,10 @@ manages the device fleet the claw keeps.
auto-detected system checks (battery optimization, background
restriction, notifications) each with a jump into the exact settings
page when the setting is wrong, a "report now" kick of the status
worker, and the workers' in-memory flight recorder (a 200-line ring,
newest first, copyable) where every run logs its outcome.
worker (its one-time job has a row of its own, and every worker
crash-lands in the log instead of dying silently), and the workers'
in-memory flight recorder (a 200-line ring, newest first, copyable)
where every run logs its outcome.
Trade-off: the token is stored in plain DataStore rather than
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
@@ -46,6 +46,7 @@ import rs.rhema.phone.di.AppContainer
import rs.rhema.phone.net.ClawResult
import rs.rhema.phone.work.PHONE_COMMANDS
import rs.rhema.phone.work.PHONE_STATUS
import rs.rhema.phone.work.PHONE_STATUS_KICK
import rs.rhema.phone.work.NOTIFICATIONS
import rs.rhema.phone.work.enqueuePhoneStatusNow
@@ -98,18 +99,29 @@ private fun WorkCard(container: AppContainer) {
val context = LocalContext.current
val wm = remember { WorkManager.getInstance(context) }
val statusInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_STATUS).collectAsState(initial = null)
val kickInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_STATUS_KICK).collectAsState(initial = null)
val notifyInfos by wm.getWorkInfosForUniqueWorkFlow(NOTIFICATIONS).collectAsState(initial = null)
val commandInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_COMMANDS).collectAsState(initial = null)
RhemaCard(title = stringResource(R.string.card_diag_work), meta = stringResource(R.string.diag_work_hint)) {
WorkRow(stringResource(R.string.diag_work_status), statusInfos, first = true)
WorkRow(
stringResource(R.string.diag_work_kick),
kickInfos,
// A one-time kick is absent until first used: "not scheduled"
// would read as an error when it is just the initial state.
absentLabel = stringResource(R.string.diag_state_never),
)
WorkRow(stringResource(R.string.diag_work_notify), notifyInfos)
WorkRow(stringResource(R.string.diag_work_commands), commandInfos)
CardRow(
title = stringResource(R.string.diag_report_now_title),
description = stringResource(R.string.diag_report_now_desc),
) {
Button(onClick = { enqueuePhoneStatusNow(context) }) {
Button(onClick = {
container.diag.log("diag: report now tapped")
enqueuePhoneStatusNow(context)
}) {
Text(stringResource(R.string.diag_report_now))
}
}
@@ -117,13 +129,19 @@ private fun WorkCard(container: AppContainer) {
}
@Composable
private fun WorkRow(label: String, infos: List<WorkInfo>?, first: Boolean = false) {
private fun WorkRow(
label: String,
infos: List<WorkInfo>?,
first: Boolean = false,
absentLabel: String? = null,
) {
// Periodic work holds one info; take the first for both the state and
// the next window. An empty list means the schedule itself is gone.
// the next window. An empty list means the schedule itself is gone —
// or, for a one-time row, simply that it has never been enqueued.
val info = infos?.firstOrNull()
val stateLabel = when {
infos == null -> stringResource(R.string.diag_state_checking)
info == null -> stringResource(R.string.diag_state_not_scheduled)
info == null -> absentLabel ?: stringResource(R.string.diag_state_not_scheduled)
else -> when (info.state) {
WorkInfo.State.ENQUEUED -> stringResource(R.string.diag_state_enqueued)
WorkInfo.State.RUNNING -> stringResource(R.string.diag_state_running)
@@ -133,12 +151,15 @@ private fun WorkRow(label: String, infos: List<WorkInfo>?, first: Boolean = fals
WorkInfo.State.CANCELLED -> stringResource(R.string.diag_state_cancelled)
}
}
val tint = when (info?.state) {
WorkInfo.State.RUNNING -> JadeiteOk
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError
WorkInfo.State.BLOCKED -> JadeiteWarn
null -> JadeiteWarn
else -> JadeiteText3
val tint = when {
infos == null -> JadeiteWarn
info == null && absentLabel == null -> JadeiteError
else -> when (info?.state) {
WorkInfo.State.RUNNING -> JadeiteOk
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError
WorkInfo.State.BLOCKED -> JadeiteWarn
else -> JadeiteText3
}
}
val detail = when {
infos == null -> null
@@ -6,6 +6,7 @@ import androidx.work.WorkerParameters
import rs.rhema.phone.RhemaApp
import rs.rhema.phone.diagnostics.diagText
import rs.rhema.phone.net.ClawResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
// Periodic notification poll. Latency is bounded by the WorkManager interval
@@ -13,7 +14,20 @@ import kotlinx.coroutines.flow.first
// The chat screen is always live via SSE regardless.
class NotificationsWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
override suspend fun doWork(): Result = try {
pollOnce()
} catch (cancelled: CancellationException) {
(applicationContext as RhemaApp).container.diag.log("notify: cancelled")
throw cancelled
} catch (t: Throwable) {
(applicationContext as RhemaApp).container.diag.log(
"notify: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" +
t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(),
)
Result.failure()
}
private suspend fun pollOnce(): Result {
val container = (applicationContext as RhemaApp).container
val deviceId = container.settings.deviceId()
val since = container.status.notificationCursor.first()
@@ -12,6 +12,7 @@ import rs.rhema.phone.net.PhoneCommand
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.coroutines.CancellationException
// Periodic control-plane poll: the claw queues commands for this device
// once their phone consent is granted; each is executed in order, then
@@ -19,7 +20,20 @@ import kotlinx.serialization.json.contentOrNull
// command queued and the next run repeats it — at-least-once delivery.
class PhoneCommandWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
override suspend fun doWork(): Result = try {
drainOnce()
} catch (cancelled: CancellationException) {
(applicationContext as RhemaApp).container.diag.log("commands: cancelled")
throw cancelled
} catch (t: Throwable) {
(applicationContext as RhemaApp).container.diag.log(
"commands: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" +
t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(),
)
Result.failure()
}
private suspend fun drainOnce(): Result {
val container = (applicationContext as RhemaApp).container
val deviceId = container.settings.deviceId()
@@ -9,6 +9,7 @@ import android.os.BatteryManager
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.CancellationException
import rs.rhema.phone.RhemaApp
import rs.rhema.phone.data.PhoneSnapshot
import rs.rhema.phone.diagnostics.diagText
@@ -36,7 +37,22 @@ import kotlinx.serialization.json.put
// only bound write traffic, not model traffic.
class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
override suspend fun doWork(): Result = try {
reportOnce()
} catch (cancelled: CancellationException) {
(applicationContext as RhemaApp).container.diag.log("status: cancelled")
throw cancelled
} catch (t: Throwable) {
// A crash before the first log line used to be invisible: the row
// just sat "waiting" while every attempt died silently.
(applicationContext as RhemaApp).container.diag.log(
"status: crashed: ${t.javaClass.simpleName}: ${t.message ?: "no message"}" +
t.stackTrace.firstOrNull()?.let { " at ${it.className}.${it.methodName}" }.orEmpty(),
)
Result.failure()
}
private suspend fun reportOnce(): Result {
val container = (applicationContext as RhemaApp).container
val deviceId = container.settings.deviceId()
// Suspend: the location read may wait out a fresh fix.
@@ -15,7 +15,7 @@ import java.time.Duration
internal const val PHONE_STATUS = "phone_status"
internal const val NOTIFICATIONS = "notifications"
internal const val PHONE_COMMANDS = "phone_commands"
private const val PHONE_STATUS_KICK = "phone_status_kick"
internal const val PHONE_STATUS_KICK = "phone_status_kick"
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
private fun networked() = Constraints.Builder()
@@ -104,6 +104,8 @@
<string name="diag_work_status">Status reporting</string>
<string name="diag_work_notify">Notification poll</string>
<string name="diag_work_commands">Command queue</string>
<string name="diag_work_kick">Report-now run</string>
<string name="diag_state_never">never run</string>
<string name="diag_work_next_fmt">next run in %1$s</string>
<string name="diag_work_attempts_fmt">%1$d attempts</string>
<string name="diag_state_checking">checking</string>