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
|
auto-detected system checks (battery optimization, background
|
||||||
restriction, notifications) each with a jump into the exact settings
|
restriction, notifications) each with a jump into the exact settings
|
||||||
page when the setting is wrong, a "report now" kick of the status
|
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,
|
worker (its one-time job has a row of its own, and every worker
|
||||||
newest first, copyable) where every run logs its outcome.
|
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
|
Trade-off: the token is stored in plain DataStore rather than
|
||||||
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
||||||
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
|
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.net.ClawResult
|
||||||
import rs.rhema.phone.work.PHONE_COMMANDS
|
import rs.rhema.phone.work.PHONE_COMMANDS
|
||||||
import rs.rhema.phone.work.PHONE_STATUS
|
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.NOTIFICATIONS
|
||||||
import rs.rhema.phone.work.enqueuePhoneStatusNow
|
import rs.rhema.phone.work.enqueuePhoneStatusNow
|
||||||
|
|
||||||
@@ -98,18 +99,29 @@ private fun WorkCard(container: AppContainer) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val wm = remember { WorkManager.getInstance(context) }
|
val wm = remember { WorkManager.getInstance(context) }
|
||||||
val statusInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_STATUS).collectAsState(initial = null)
|
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 notifyInfos by wm.getWorkInfosForUniqueWorkFlow(NOTIFICATIONS).collectAsState(initial = null)
|
||||||
val commandInfos by wm.getWorkInfosForUniqueWorkFlow(PHONE_COMMANDS).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)) {
|
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_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_notify), notifyInfos)
|
||||||
WorkRow(stringResource(R.string.diag_work_commands), commandInfos)
|
WorkRow(stringResource(R.string.diag_work_commands), commandInfos)
|
||||||
CardRow(
|
CardRow(
|
||||||
title = stringResource(R.string.diag_report_now_title),
|
title = stringResource(R.string.diag_report_now_title),
|
||||||
description = stringResource(R.string.diag_report_now_desc),
|
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))
|
Text(stringResource(R.string.diag_report_now))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,13 +129,19 @@ private fun WorkCard(container: AppContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
// 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 info = infos?.firstOrNull()
|
||||||
val stateLabel = when {
|
val stateLabel = when {
|
||||||
infos == null -> stringResource(R.string.diag_state_checking)
|
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) {
|
else -> when (info.state) {
|
||||||
WorkInfo.State.ENQUEUED -> stringResource(R.string.diag_state_enqueued)
|
WorkInfo.State.ENQUEUED -> stringResource(R.string.diag_state_enqueued)
|
||||||
WorkInfo.State.RUNNING -> stringResource(R.string.diag_state_running)
|
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)
|
WorkInfo.State.CANCELLED -> stringResource(R.string.diag_state_cancelled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val tint = when (info?.state) {
|
val tint = when {
|
||||||
WorkInfo.State.RUNNING -> JadeiteOk
|
infos == null -> JadeiteWarn
|
||||||
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError
|
info == null && absentLabel == null -> JadeiteError
|
||||||
WorkInfo.State.BLOCKED -> JadeiteWarn
|
else -> when (info?.state) {
|
||||||
null -> JadeiteWarn
|
WorkInfo.State.RUNNING -> JadeiteOk
|
||||||
else -> JadeiteText3
|
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> JadeiteError
|
||||||
|
WorkInfo.State.BLOCKED -> JadeiteWarn
|
||||||
|
else -> JadeiteText3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val detail = when {
|
val detail = when {
|
||||||
infos == null -> null
|
infos == null -> null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.work.WorkerParameters
|
|||||||
import rs.rhema.phone.RhemaApp
|
import rs.rhema.phone.RhemaApp
|
||||||
import rs.rhema.phone.diagnostics.diagText
|
import rs.rhema.phone.diagnostics.diagText
|
||||||
import rs.rhema.phone.net.ClawResult
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
// Periodic notification poll. Latency is bounded by the WorkManager interval
|
// 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.
|
// The chat screen is always live via SSE regardless.
|
||||||
class NotificationsWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
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 container = (applicationContext as RhemaApp).container
|
||||||
val deviceId = container.settings.deviceId()
|
val deviceId = container.settings.deviceId()
|
||||||
val since = container.status.notificationCursor.first()
|
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.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
// Periodic control-plane poll: the claw queues commands for this device
|
// Periodic control-plane poll: the claw queues commands for this device
|
||||||
// once their phone consent is granted; each is executed in order, then
|
// 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.
|
// command queued and the next run repeats it — at-least-once delivery.
|
||||||
class PhoneCommandWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
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 container = (applicationContext as RhemaApp).container
|
||||||
val deviceId = container.settings.deviceId()
|
val deviceId = container.settings.deviceId()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.os.BatteryManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import rs.rhema.phone.RhemaApp
|
import rs.rhema.phone.RhemaApp
|
||||||
import rs.rhema.phone.data.PhoneSnapshot
|
import rs.rhema.phone.data.PhoneSnapshot
|
||||||
import rs.rhema.phone.diagnostics.diagText
|
import rs.rhema.phone.diagnostics.diagText
|
||||||
@@ -36,7 +37,22 @@ import kotlinx.serialization.json.put
|
|||||||
// only bound write traffic, not model traffic.
|
// only bound write traffic, not model traffic.
|
||||||
class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
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 container = (applicationContext as RhemaApp).container
|
||||||
val deviceId = container.settings.deviceId()
|
val deviceId = container.settings.deviceId()
|
||||||
// Suspend: the location read may wait out a fresh fix.
|
// 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 PHONE_STATUS = "phone_status"
|
||||||
internal const val NOTIFICATIONS = "notifications"
|
internal const val NOTIFICATIONS = "notifications"
|
||||||
internal const val PHONE_COMMANDS = "phone_commands"
|
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 const val PHONE_COMMANDS_KICK = "phone_commands_kick"
|
||||||
|
|
||||||
private fun networked() = Constraints.Builder()
|
private fun networked() = Constraints.Builder()
|
||||||
|
|||||||
@@ -104,6 +104,8 @@
|
|||||||
<string name="diag_work_status">Status reporting</string>
|
<string name="diag_work_status">Status reporting</string>
|
||||||
<string name="diag_work_notify">Notification poll</string>
|
<string name="diag_work_notify">Notification poll</string>
|
||||||
<string name="diag_work_commands">Command queue</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_next_fmt">next run in %1$s</string>
|
||||||
<string name="diag_work_attempts_fmt">%1$d attempts</string>
|
<string name="diag_work_attempts_fmt">%1$d attempts</string>
|
||||||
<string name="diag_state_checking">checking</string>
|
<string name="diag_state_checking">checking</string>
|
||||||
|
|||||||
Reference in new issue
Block a user