Android: run main() once per process, not once per activity #161
Merged
logan
merged 3 commits from 2026-08-20 17:18:19 +00:00
fix/52-android-activity-recreation-restarts-the-process into main
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8a757c9bb4 |
docs(android): record the lifecycle model and the device check
The lifecycle answer is load-bearing, so CLAUDE.md states it: an activity is a view onto the process, and main() runs once per process. The "restore the session or cold-start" question the issue asks for a decision on is settled by playback rather than by preference -- the audio lives in the Go process, so a cold start on every recreation stops the music mid-song, which is the thing the foreground service exists to prevent. android-tier.md's build table said "arm64, real device -- unverified, still" for five phases. It is verified now, on a Light Phone III (Android 14, arm64-v8a, WebView Chrome 113 at 424x439), and what the run found is a lifecycle section: how to force an activity recreation on demand, the three-line logcat signature, why `has died: fg TOP` is not a memory kill, and the second assertion that surviving does not imply working. It also carries the correction that "Don't keep activities" -- the report's own suggested lever -- does not work on this device at all, so nobody spends an afternoon on it. A configuration change the manifest does not declare does, in one line. And it stops recommending `wails3 task android:run:device`, which uninstalls the released app and the user's library to install a build with a different id (#159), in favour of the manual sequence. NOTES.md carries the measurements, dated: 8 of 8 recreations fatal before, 5 of 5 survived after, and the note that runs where no recreation happened are inconclusive rather than passes -- a harness that does not check for the second bridge init reports those as green and reads as flakiness. Refs #52, #159, #160 |
||
|
|
d714bd7090 |
fix(android): keep the Go app alive when the activity is destroyed
onDestroy called bridge.shutdown(), which is the natural reading of the
callback and is wrong for this app twice over. Android destroys and
recreates an activity without restarting the process, and when the user
really does leave, this app's reason for existing in the background is
that a song is playing -- which is what the mediaPlayback foreground
service holds the process alive for. Either way, tearing the Go side
down here stops the music.
It was harmless only by accident, and that is worth writing down:
nativeShutdown calls App.Quit(), whose Android destroy() is an empty
method, and Run()'s deferred shutdownServices() cannot fire because
platformRun is `select{}` and never returns. So **no ServiceShutdown has
ever run on Android**. Removing the call changes nothing today; it stops
the day someone implements destroy() from silently killing playback on a
rotation. There is no callback for the process going away -- Android
just kills it -- so durability here is the persist writers, which submit
on every mutation rather than at exit.
WailsBridge.initialize gains the comment for the trap next to it.
Making `initialized` static is the obvious reading of "initialise once
per process" and is wrong: nativeInit also stores the global JNI
reference to *this* bridge, so skipping it leaves Go executing
JavaScript against the destroyed activity's WebView, and the app opens,
renders, and never receives another backend event. The half that must
not repeat is latched in Go instead -- which is also where the damage
was, and the only place that can see it.
Refs #52
|
||
|
|
d64b069053 |
fix(android): run main() once per process, not once per activity
Wails' Android entry point is `nativeInit`, which `MainActivity.onCreate`
calls, and it runs `go mainFunc()` every time. Android destroys and
recreates an activity **without restarting the process** -- a
configuration change the manifest does not declare, memory pressure, or
every background under "Don't keep activities" -- so main() ran again on
a live app.
Every path out of that is fatal. `application.New` returns the existing
app rather than building a second one, `app.Run()` then refuses because
`a.starting` is still true behind Android's `select{}`, and the
`os.Exit(1)` under that error takes the **first**, healthy app down with
it: its database, its queue, and the audio a mediaPlayback foreground
service is holding the process alive to play. ActivityManager restarts
the app, which is the report.
Measured on a Light Phone III (Android 14, arm64): conditional on the
activity actually being recreated, the process died 8 times out of 8.
The runs that "passed" were runs where no recreation happened, which is
the whole of the report's "sometimes". After this, 5/5 recreations
survive on one pid, plus six background/foreground cycles.
It never left evidence because os.Exit is not a crash: no tombstone, no
AndroidRuntime stack, nothing in `logcat -b crash`, and the slog line
naming the error went to /dev/null with the rest of fd 1.
The latch is first in main() because everything below it -- above all
NewYellowJacketApp, which opens the SQLite database -- is work that must
not happen twice in one process. It is inert off Android.
Returning early is not a degraded mode: nativeInit has already
re-pointed the JNI reference at the new bridge, so the recreated
WebView talks to the app that is still running, with its queue and
playback position intact. Verified by hooking dispatchWailsEvent on the
recreated page: IndexStatusChanged, JobsChanged, android:storageAccess.
No tier here runs main() on Android, so the guard is a source sweep, in
the spirit of TestNoDirectRuntimeEmits. The failure it exists for is not
the latch being deleted -- that is loud -- but a line creeping in above
it.
Closes #52
|