Android: run main() once per process, not once per activity #161

Merged
logan merged 3 commits from fix/52-android-activity-recreation-restarts-the-process into main 2026-08-20 17:18:19 +00:00
Collaborator

Reopening the app after it had been backgrounded killed the process.
The report said "sometimes"; measured on a device, the fault is
deterministic and only its trigger is occasional.

Commits

commit what issue
d64b069 fix(android): run main() once per process, not once per activity #52
d714bd7 fix(android): keep the Go app alive when the activity is destroyed #52
8a757c9 docs(android): record the lifecycle model and the device check #52, #159, #160

The mechanism

Wails' Android entry point is nativeInit, which MainActivity.onCreate
calls. It does two things: re-points the native library's global JNI
reference at the calling WailsBridge, and runs go mainFunc().
WailsBridge.initialized is a per-instance field, so a recreated
activity's fresh bridge does not know the process already did this.

Android destroys and recreates an activity without restarting the
process
. So main() ran a second time on a live app, and every path
out is fatal:

  • application.New returns the existing globalApplication, silently
    discarding the second Services set;
  • app.Run() refuses by design — a.starting is still true, because
    Android's platformRun is select{} and never returns — with
    "application is running or a previous run has failed";
  • which reaches os.Exit(1), killing the first, healthy app: its
    database, its queue, and the audio the mediaPlayback foreground
    service is holding the process alive to play.

ActivityManager restarts it. That is the report verbatim.

It left no evidence because os.Exit is not a crash — and the slog
line naming the error went to /dev/null with the rest of fd 1, which
is #160.

Runtime evidence

This issue has never had any, so it is the deliverable as much as the
fix. Light Phone III (TLP301), Android 14 / SDK 34, arm64-v8a, WebView
Chrome 113 at 424x439. Debug build (app.yellowjacket.dev) installed
beside the released v0.3.1 with install -r; nothing uninstalled.

The whole bug, three lines:

12:47:56.159 I/WailsBridge(22956): Wails bridge initialized
12:48:38.898 I/WailsBridge(22956): Wails bridge initialized     <- same pid
12:48:39.357 I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg  TOP
12:48:39.386 W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state

with, immediately above it,

12:48:39.291 I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms

Two nativeInits in one pid, dead 459 ms later.

Two things about reading that. has died: fg TOP is not a memory
kill
— the system does not reclaim the foreground process — which is
why "the OS killed it" is the wrong first hypothesis and why this sat
unverified. And there is no crash record at all: logcat -b crash
empty, no AndroidRuntime, no libc: Fatal signal, no tombstone.

Reproduction

"Don't keep activities" — the issue's own suggested lever — does not
work on this device.
settings put global always_finish_activities 1
reads back as 1, am set-always-finish-activities does not exist on
this build, and the activity was never finished on backgrounding.

What works, deterministically and in one line, is a configuration change
the manifest does not declare (AndroidManifest.xml declares
orientation|screenSize|keyboardHidden|uiMode):

adb shell settings put system font_scale 1.15

That is the same in-process destroy/recreate that a memory trim and a
locale change produce.

before after
activity recreations observed 8 5
of which the process died 8 0

Plus, on the fixed build: 6 background/foreground cycles and 3
interleaved recreations, all on one pid, and
make android-smoke SECONDS=60 green.

Runs where no recreation happened are inconclusive, not passes. A
harness that does not check for the second Wails bridge initialized
counts them green — which is exactly what makes this read as flakiness.

The model decision (#52's actual question)

Does the activity restore the existing session, or is a restart a cold
start with the queue restored from the database?

Restore, and playback settles it rather than preference. The audio
lives in the Go process, so a cold start on every activity recreation
stops the music mid-song — the thing the mediaPlayback foreground
service exists to prevent. The activity is a view; the app is the
process. The frontend already cooperates: a recreated WebView loads the
page fresh and fetches state from a backend that never went away.
Written into CLAUDE.md.

Why the fix is in Go and not in Java

The obvious Java fix — making WailsBridge.initialized static, so the
second nativeInit is skipped — keeps the process alive and silently
breaks the app
, because nativeInit is also what re-points the JNI
reference. Go would keep executing JavaScript against the destroyed
activity's WebView: the app opens, renders, and never receives another
backend event.

So the latch lets nativeInit do its first job and declines only its
second. Verified on the recreated page by hooking
window._wails.dispatchWailsEvent and then backgrounding/foregrounding:

["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]

Events from the live services — so the recreated WebView is wired to the
app that never restarted. [] would have meant a stale reference.

The one Java change is the other half of the same question:
onDestroy no longer calls bridge.shutdown(). It was harmless only by
accident — App.Quit() reaches an androidApp.destroy() that is an
empty method, and Run()'s deferred shutdownServices() cannot fire
behind select{}, so no ServiceShutdown has ever run on Android.
Removing it changes nothing today and stops the day someone implements
destroy() from killing playback on a rotation.

What guards it

No tier here runs main() on Android, so "add a spec" is not available
and the guard is split, deliberately and incompletely:

  • TestMainClaimsBeforeItDoesAnything — a source sweep, in the
    spirit of TestNoDirectRuntimeEmits, asserting the latch is the
    first statement of main(). The failure it exists for is not
    deletion (loud) but a line creeping in above it: a second
    NewYellowJacketApp opens the SQLite database again, on every
    recreation, silently. Confirmed non-vacuous — replacing the guard with
    _ = claimMainOnce() fails it.
  • TestMainRunsOncePerProcess — pins the latch itself.
  • A documented device check in
    .pi/skills/yellowjacket-dev/references/android-tier.md: the trigger,
    the logcat signature, and the event probe.

What is not guarded, stated plainly: nothing in CI can see the
Android build's runtime at all, so a future change to Wails'
nativeInit, to MainActivity, or to how Run() reports a second call
would not be caught by any of the above. This is a device-tier
regression and needs a device-tier re-check.

Filed while here

  • #159 (Priority/Critical) — wails3 task android:run:device
    runs adb uninstall app.yellowjacket (the release id) while
    installing the debug variant (app.yellowjacket.dev): it deletes the
    user's library, then fails to launch the package it removed.
    android-tier.md had been recommending it. Found by reading the task
    before running it, on a device carrying a real library.
  • #160 — route slog to logcat via __android_log_write. This
    bug's entire diagnosis was one line the app already writes.

Verification

make lint (3 tag sets) 0 issues
make test (3 tag sets, -race) pass
make ui-test 949 tests, 85 files
make e2e 187 passed, run twice against one app
make css-check / bindings-check / skill-check clean
tsc --noEmit, frontend/ and e2e/ clean
make android release APK builds
android-tagged Go cross-compile clean
device: recreation survival 8/8 fatal → 5/5 survive
device: make android-smoke SECONDS=60 PASS
device: screenshot after resume no change, insets correct

Closes #52

Reopening the app after it had been backgrounded killed the process. The report said "sometimes"; measured on a device, the fault is deterministic and only its *trigger* is occasional. ## Commits | commit | what | issue | |---|---|---| | `d64b069` | `fix(android): run main() once per process, not once per activity` | #52 | | `d714bd7` | `fix(android): keep the Go app alive when the activity is destroyed` | #52 | | `8a757c9` | `docs(android): record the lifecycle model and the device check` | #52, #159, #160 | ## The mechanism Wails' Android entry point is `nativeInit`, which `MainActivity.onCreate` calls. It does two things: re-points the native library's global JNI reference at the calling `WailsBridge`, and runs `go mainFunc()`. `WailsBridge.initialized` is a **per-instance** field, so a recreated activity's fresh bridge does not know the process already did this. Android destroys and recreates an activity **without restarting the process**. So `main()` ran a second time on a live app, and every path out is fatal: - `application.New` returns the *existing* `globalApplication`, silently discarding the second `Services` set; - `app.Run()` refuses by design — `a.starting` is still true, because Android's `platformRun` is `select{}` and never returns — with `"application is running or a previous run has failed"`; - which reaches `os.Exit(1)`, killing the **first**, healthy app: its database, its queue, and the audio the `mediaPlayback` foreground service is holding the process alive to play. ActivityManager restarts it. That is the report verbatim. It left no evidence because `os.Exit` is not a crash — and the `slog` line naming the error went to `/dev/null` with the rest of fd 1, which is #160. ## Runtime evidence This issue has never had any, so it is the deliverable as much as the fix. Light Phone III (TLP301), Android 14 / SDK 34, arm64-v8a, WebView Chrome 113 at 424x439. Debug build (`app.yellowjacket.dev`) installed **beside** the released `v0.3.1` with `install -r`; nothing uninstalled. The whole bug, three lines: ``` 12:47:56.159 I/WailsBridge(22956): Wails bridge initialized 12:48:38.898 I/WailsBridge(22956): Wails bridge initialized <- same pid 12:48:39.357 I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP 12:48:39.386 W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state ``` with, immediately above it, ``` 12:48:39.291 I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms ``` Two `nativeInit`s in one pid, dead 459 ms later. Two things about reading that. **`has died: fg TOP` is not a memory kill** — the system does not reclaim the foreground process — which is why "the OS killed it" is the wrong first hypothesis and why this sat unverified. And there is **no crash record at all**: `logcat -b crash` empty, no `AndroidRuntime`, no `libc: Fatal signal`, no tombstone. ## Reproduction **"Don't keep activities" — the issue's own suggested lever — does not work on this device.** `settings put global always_finish_activities 1` reads back as `1`, `am set-always-finish-activities` does not exist on this build, and the activity was never finished on backgrounding. What works, deterministically and in one line, is a configuration change the manifest does not declare (`AndroidManifest.xml` declares `orientation|screenSize|keyboardHidden|uiMode`): ```bash adb shell settings put system font_scale 1.15 ``` That is the same in-process destroy/recreate that a memory trim and a locale change produce. | | before | after | |---|---|---| | activity recreations observed | 8 | 5 | | of which the process died | **8** | **0** | Plus, on the fixed build: 6 background/foreground cycles and 3 interleaved recreations, all on one pid, and `make android-smoke SECONDS=60` green. **Runs where no recreation happened are inconclusive, not passes.** A harness that does not check for the second `Wails bridge initialized` counts them green — which is exactly what makes this read as flakiness. ## The model decision (#52's actual question) Does the activity restore the existing session, or is a restart a cold start with the queue restored from the database? **Restore, and playback settles it rather than preference.** The audio lives in the Go process, so a cold start on every activity recreation stops the music mid-song — the thing the `mediaPlayback` foreground service exists to prevent. The activity is a view; the app is the process. The frontend already cooperates: a recreated WebView loads the page fresh and fetches state from a backend that never went away. Written into `CLAUDE.md`. ## Why the fix is in Go and not in Java The obvious Java fix — making `WailsBridge.initialized` `static`, so the second `nativeInit` is skipped — keeps the process alive and **silently breaks the app**, because `nativeInit` is also what re-points the JNI reference. Go would keep executing JavaScript against the destroyed activity's WebView: the app opens, renders, and never receives another backend event. So the latch lets `nativeInit` do its first job and declines only its second. Verified on the recreated page by hooking `window._wails.dispatchWailsEvent` and then backgrounding/foregrounding: ``` ["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"] ``` Events from the live services — so the recreated WebView is wired to the app that never restarted. `[]` would have meant a stale reference. The one Java change is the other half of the same question: `onDestroy` no longer calls `bridge.shutdown()`. It was harmless only by accident — `App.Quit()` reaches an `androidApp.destroy()` that is an empty method, and `Run()`'s deferred `shutdownServices()` cannot fire behind `select{}`, so **no `ServiceShutdown` has ever run on Android**. Removing it changes nothing today and stops the day someone implements `destroy()` from killing playback on a rotation. ## What guards it No tier here runs `main()` on Android, so "add a spec" is not available and the guard is split, deliberately and incompletely: - **`TestMainClaimsBeforeItDoesAnything`** — a source sweep, in the spirit of `TestNoDirectRuntimeEmits`, asserting the latch is the *first* statement of `main()`. The failure it exists for is not deletion (loud) but a line creeping in above it: a second `NewYellowJacketApp` opens the SQLite database again, on every recreation, silently. Confirmed non-vacuous — replacing the guard with `_ = claimMainOnce()` fails it. - **`TestMainRunsOncePerProcess`** — pins the latch itself. - **A documented device check** in `.pi/skills/yellowjacket-dev/references/android-tier.md`: the trigger, the logcat signature, and the event probe. **What is not guarded, stated plainly:** nothing in CI can see the Android build's runtime at all, so a future change to Wails' `nativeInit`, to `MainActivity`, or to how `Run()` reports a second call would not be caught by any of the above. This is a device-tier regression and needs a device-tier re-check. ## Filed while here - **#159** (`Priority/Critical`) — `wails3 task android:run:device` runs `adb uninstall app.yellowjacket` (the *release* id) while installing the debug variant (`app.yellowjacket.dev`): it deletes the user's library, then fails to launch the package it removed. `android-tier.md` had been recommending it. Found by reading the task before running it, on a device carrying a real library. - **#160** — route `slog` to logcat via `__android_log_write`. This bug's entire diagnosis was one line the app already writes. ## Verification | | | |---|---| | `make lint` (3 tag sets) | 0 issues | | `make test` (3 tag sets, `-race`) | pass | | `make ui-test` | 949 tests, 85 files | | `make e2e` | 187 passed, run **twice** against one app | | `make css-check` / `bindings-check` / `skill-check` | clean | | `tsc --noEmit`, `frontend/` and `e2e/` | clean | | `make android` | release APK builds | | android-tagged Go cross-compile | clean | | device: recreation survival | 8/8 fatal → 5/5 survive | | device: `make android-smoke SECONDS=60` | PASS | | device: screenshot after resume | no change, insets correct | Closes #52
logan added 3 commits 2026-08-20 17:06:26 +00:00
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
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
docs(android): record the lifecycle model and the device check
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m9s
8a757c9bb4
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
logan merged commit b741b01cdf into main 2026-08-20 17:18:19 +00:00
Sign in to join this conversation.