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:
1 parent
7225dfddd3
commit
5e9952ac6d
7 files changed
+85
-16
No files matched your search
+4
-2
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user