Don't report a failure nobody was there to see

Leaving the app for a while and coming back to it greeted you with a
five-second read timeout. Work started before the screen went away keeps
running -- a build being followed, a download, the manifest poll -- and when
the device sleeps or the link drops, that work fails and reported itself.
The message said nothing about the server and there was nothing left to do
about it, because the thing it described was over before it was read.

Every catch now goes through one `failure(e)`, which answers null while the
screen is not resumed. Null means *clear*, never *leave*, at every site: a
dropped failure that left the card alone would leave a spinner up for an
operation that has already stopped. `setProject` joins `setComponent` so
both levels are written the same way and a caller can hand the answer over
whichever it is.

That is only safe because the resume re-read can now run from any state.
It was guarded on the list being loaded, which meant a failure ruled out
the one thing that would have replaced it -- a card that had gone red
stayed red until somebody found the Refresh button. Arriving and resuming
are also one event now rather than a LaunchedEffect beside a resume effect,
with `loadingList` claimed before the coroutine launches so the two cannot
stack two reads in one frame.

The other half of the same complaint is the read on the way back in: the
link may have been asleep as long as the app was, and the first request
across one still coming back times out at the five seconds every request
gets. The resume's read alone retries once. It costs nothing when the
server really is down, because a refused connection comes back at once
rather than waiting out a timeout -- and it is a retry, not a guess.

Checked all four ways on the emulator, including the two the change was not
written for. Killing the server while the app was backgrounded mid-poll and
returning: the list loads, no timeout. Pressing Refresh with the server
down: the failure is shown at once, in full. Returning while it is still
down: retried, then reported, no stuck spinner. Returning once it is back:
the list recovers, which the old guard would have prevented.
This commit is contained in:
iris committed 2026-09-01 03:39:00 -04:00
1 parent a7f7f4550e
commit 3685ab107d
2 files changed
+168 -46

No files matched your search

+35
View File
@@ -335,6 +335,41 @@ mutable at runtime from the phone.
on *that* answer, which the card's own refresh control and a freshly
added app both use. Waiting on the whole list to settle would make one
card's refresh sit behind another card's.
Arriving and resuming are **one** event, not two: `LifecycleResumeEffect`
runs when the screen first reaches RESUMED, so there is no
`LaunchedEffect(Unit)` beside it and one thing decides when the list is
read. It refreshes from *any* state including a failed one -- guarded on
"loaded", as it used to be, a card that had gone red stayed red until
somebody found the Refresh button, which is the opposite of what
returning to an app should do. `loadingList` is what stops the first
composition and the first resume stacking two reads, and it is claimed
*before* the coroutine launches, because both run in the same frame and
a flag set inside the coroutine is set too late to be a guard.
- **A failure that lands while nobody is looking is not shown.** Work
started before the app went away keeps running -- deliberately, since a
download that finishes in the background is a download that worked -- and
when the device sleeps or the link drops it fails. Reported, that meant
coming back an hour later to a five-second read timeout that said nothing
about the server and that there was nothing left to do about. So every
catch in `UpdaterScreen.kt` goes through `failure(e)`, which answers null
while the screen is not resumed, and **null means clear, never leave** at
every site -- a dropped failure that left the card alone would leave a
spinner up for an operation that has already stopped. `setProject` and
`setComponent` both take a nullable state so the answer can be handed
straight over. Iris asked for this on 2026-09-01: "I got a socket timeout
by leaving the app for too long. Make sure not to show that if the app
just gets unloaded."
The other half of the same complaint is the read on the way back *in*:
the link may have been asleep as long as the app was, and the first
request across one still coming back times out at the five seconds every
request gets. So the resume's read alone retries once (`afterAGap`),
which costs nothing when the server really is down -- a refused
connection comes back at once rather than waiting out a timeout. That is
a retry, not a guess: nothing is displayed that was not measured.
Dropping a failure is only safe *because* the resume re-reads from any
state; the two changes hold each other up, and undoing either alone
leaves the list stuck on a spinner or stuck on a stale error.
- **The Add screen is drawn over the list, not in place of it.** Swapped
out, the list is composed again from nothing on the way back -- and