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
This commit is contained in:
@@ -194,9 +194,13 @@ like the app's fault and none is:
|
||||
|---|---|---|
|
||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||
| arm64, real device | — | unverified, still |
|
||||
| arm64, real device | **runs** (2026-08-20) | — |
|
||||
|
||||
**A physical arm64 device remains the only verification path.**
|
||||
**A physical arm64 device remains the only verification path**, and it
|
||||
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
|
||||
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
|
||||
launches and stays up; `make android-smoke SECONDS=60` passes on it.
|
||||
What that run *found* is the lifecycle fault below.
|
||||
|
||||
### What was fixed to get here
|
||||
|
||||
@@ -210,6 +214,11 @@ no-op. `backend/system` gained no import of the Wails application
|
||||
package, which matters for the same reason `backend/events` is split by
|
||||
the `indexbuild` tag.
|
||||
|
||||
**And `main()` is now latched to one run per process** (#52). That is
|
||||
the second `os.Exit(1)` in this file's history and it had the same
|
||||
signature as the first, which is the argument for #160: both were named
|
||||
exactly by an `slog` line that went to `/dev/null`.
|
||||
|
||||
### What is still not done
|
||||
|
||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||
@@ -249,10 +258,20 @@ one.
|
||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||
they are the right thing to reach for when you want something one-off:
|
||||
|
||||
> **Do not run `android:run:device` or `android:deploy-device`
|
||||
> against a device that has the released app on it (#159).** Both begin
|
||||
> with `adb uninstall {{.APP_ID}}`, and `APP_ID` defaults to
|
||||
> `app.yellowjacket` — the **release** id — while `run:device` builds
|
||||
> the **debug** variant, whose id is `app.yellowjacket.dev`. So it
|
||||
> uninstalls the user's app, taking the library with it, installs a
|
||||
> different package, and then fails to launch the one it removed. This
|
||||
> is "the identity is declared twice" (below) cashing out. The safe
|
||||
> sequence is at the end of this section.
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # same, first connected physical device
|
||||
wails3 task android:deploy-device # production APK to a device
|
||||
wails3 task android:run:device # UNSAFE, see #159
|
||||
wails3 task android:deploy-device # UNSAFE, see #159
|
||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||
wails3 task android:studio # open build/android/ in Android Studio
|
||||
wails3 task android:device:list
|
||||
@@ -287,6 +306,19 @@ resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
**The safe way to put a debug build on a real device**, which is what
|
||||
#52 used and what #159 exists to make unnecessary:
|
||||
|
||||
```bash
|
||||
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
|
||||
adb install -r bin/yellowjacket.apk # -r, never uninstall
|
||||
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
|
||||
```
|
||||
|
||||
`YJ_ANDROID_PKG=app.yellowjacket.dev` points `scripts/android-emulator.sh`
|
||||
— and therefore `make android-smoke`, `android-logs`, `android-launch`
|
||||
— at the debug id, which is otherwise `app.yellowjacket`.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
@@ -311,6 +343,91 @@ system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
**The third such fault was the activity lifecycle** (#52), and it is
|
||||
the one to re-check after touching `main()`, `WailsBridge` or
|
||||
`MainActivity`. Android destroys and recreates an activity **without
|
||||
restarting the process**, and Wails' `nativeInit` — which
|
||||
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
|
||||
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
|
||||
is still true behind Android's `select{}`), and the `os.Exit(1)` under
|
||||
it took the healthy first app down with it.
|
||||
|
||||
### The lifecycle check, and how to trigger it on demand
|
||||
|
||||
This is the regression guard for #52 on this tier, because no other
|
||||
tier runs `main()` on Android at all. The Go-side guard
|
||||
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
|
||||
latch; only the device catches the latch not working.
|
||||
|
||||
**Trigger a relaunch with a configuration change the manifest does not
|
||||
declare.** `AndroidManifest.xml` lists
|
||||
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
|
||||
in-place and are *not* triggers. `fontScale` is not listed, and it is a
|
||||
one-liner:
|
||||
|
||||
```bash
|
||||
adb shell settings put system font_scale 1.15 # restore the old value after
|
||||
```
|
||||
|
||||
That is the same in-process destroy/recreate that "Don't keep
|
||||
activities", a locale change and a memory trim produce, but on demand.
|
||||
|
||||
**"Don't keep activities" is the report's own lever and did 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. Do not
|
||||
spend an afternoon on it; use the config change.
|
||||
|
||||
**The assertion is the pid, and the tell is two bridge inits in one.**
|
||||
|
||||
```bash
|
||||
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
|
||||
```
|
||||
|
||||
Healthy is one pid appearing twice — the process surviving the
|
||||
recreation:
|
||||
|
||||
```
|
||||
I/WailsBridge(28420): Wails bridge initialized
|
||||
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
|
||||
```
|
||||
|
||||
Broken is that pair followed within a second by:
|
||||
|
||||
```
|
||||
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
|
||||
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
|
||||
```
|
||||
|
||||
Two things about reading that. **`has died: fg TOP` is not a memory
|
||||
kill** — the system does not reclaim the foreground process, so this is
|
||||
the app leaving of its own accord. And there is **no crash record
|
||||
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
|
||||
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
|
||||
and it is why "the system killed it" is the wrong first hypothesis.
|
||||
|
||||
**Surviving is only half of it — check the recreated WebView is still
|
||||
wired to the running app.** A plausible-looking fix (making
|
||||
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
|
||||
keeps the process alive and silently breaks this, because `nativeInit`
|
||||
is also what re-points the JNI reference at the new bridge. Go would go
|
||||
on executing JavaScript against the destroyed activity's WebView: the
|
||||
app opens, renders, and never receives another backend event.
|
||||
|
||||
Ask the page, after a relaunch and a resume:
|
||||
|
||||
```bash
|
||||
make android-inspect
|
||||
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
|
||||
# background and foreground the app, then:
|
||||
make android-eval EXPR='JSON.stringify(window.__probe)'
|
||||
```
|
||||
|
||||
A healthy build answers with events from the live services —
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
|
||||
`[]` means the bridge reference is stale.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
|
||||
@@ -4077,3 +4077,81 @@ have saved the other two cycles.
|
||||
first track with no cover art" selects nothing in particular. The
|
||||
placeholder's presence is asserted instead, which is the property the
|
||||
test actually depends on.
|
||||
|
||||
## An activity recreation kills the process, deterministically (measured 2026-08-20)
|
||||
|
||||
#52's report was "sometimes crashes or restarts when reopened after
|
||||
running in the background". Measured on a real device, the *fault* is
|
||||
not intermittent at all — only its trigger is.
|
||||
|
||||
Device: Light Phone III (TLP301), Android 14 / SDK 34, arm64-v8a,
|
||||
WebView **Chrome 113** at 424x439 CSS px. Debug build
|
||||
(`app.yellowjacket.dev`), installed beside the released `v0.3.1` with
|
||||
`install -r`.
|
||||
|
||||
**Conditional on the activity actually being recreated in a live
|
||||
process, the process died 8 times out of 8** — 3 by hand, then 5/5 in a
|
||||
scripted loop. The runs where it survived were runs where no recreation
|
||||
happened (one `Wails bridge initialized` in the log rather than two), so
|
||||
they are inconclusive rather than passes; a harness that does not check
|
||||
for the second init reports those as green and reads as flakiness.
|
||||
After the fix: 5/5 recreations survived, plus 6 background/foreground
|
||||
cycles and 3 interleaved recreations on one pid.
|
||||
|
||||
The mechanism is three log 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
|
||||
```
|
||||
|
||||
`nativeInit` runs `go mainFunc()` on every activity creation; the second
|
||||
`main()` reaches `app.Run()`, which refuses because `a.starting` is
|
||||
still true behind Android's `select{}`, and `os.Exit(1)` takes the whole
|
||||
process — including the healthy first app — with it.
|
||||
|
||||
Four things worth keeping:
|
||||
|
||||
- **`has died: fg TOP` is not a memory kill.** The system does not
|
||||
reclaim the foreground process. This reads as "the OS killed us",
|
||||
which is the wrong hypothesis and the reason the issue sat unverified.
|
||||
- **There is no crash record of any kind**: `logcat -b crash` empty, no
|
||||
`AndroidRuntime`, no `libc: Fatal signal`, no tombstone. `os.Exit` is
|
||||
not a crash. The one line that named the fault —
|
||||
`slog.Error("application error", "err", ...)`, carrying
|
||||
`"application is running or a previous run has failed"` — went to
|
||||
`/dev/null`. That is #160.
|
||||
- **"Don't keep activities" 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. The report's own
|
||||
suggested lever is a dead end here. What *does* work, deterministically
|
||||
and in one line, is a configuration change the manifest does not
|
||||
declare: `adb shell settings put system font_scale 1.15`
|
||||
(`AndroidManifest.xml` declares `orientation|screenSize|
|
||||
keyboardHidden|uiMode`, so none of those are triggers).
|
||||
- **Surviving is only half the property.** The recreated WebView has to
|
||||
still be wired to the running app, which was verified by hooking
|
||||
`window._wails.dispatchWailsEvent` and backgrounding/foregrounding:
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged",
|
||||
"android:storageAccess"]`. The tempting Java-side fix — making
|
||||
`WailsBridge.initialized` static — passes the pid check and fails
|
||||
this one, because `nativeInit` is also what re-points the JNI
|
||||
reference at the new bridge.
|
||||
|
||||
## The Taskfile's device tasks uninstall the released app (2026-08-20)
|
||||
|
||||
`android:run:device` builds the **debug** variant
|
||||
(`applicationIdSuffix ".dev"`) and then runs
|
||||
`adb uninstall {{.APP_ID}}`, where `APP_ID` defaults to
|
||||
`app.yellowjacket` — the **release** id. So it deletes the user's
|
||||
installed app and its library, installs a different package, and then
|
||||
fails to launch the one it removed. `deploy-device` carries the same
|
||||
uninstall. Filed as #159; `android-tier.md` had been recommending
|
||||
`run:device` as the way onto a device.
|
||||
|
||||
This is the hazard that file already names — "The identity is declared
|
||||
twice ... **Nothing enforces that they agree**" — reached by a second
|
||||
route: the two ids differ not because someone edited one, but because
|
||||
the debug buildType suffixes it.
|
||||
|
||||
@@ -317,6 +317,60 @@ stacking dialogs. Window state moved off that path entirely, onto a
|
||||
window still exists and `OnShutdown` has neither a context nor a
|
||||
window.
|
||||
|
||||
**An activity is a view onto the process, and `main()` runs once per
|
||||
process.** On Android the Wails entry point is `nativeInit`, which
|
||||
`MainActivity.onCreate` calls — and it does two things: it re-points the
|
||||
native library's global JNI reference at the calling `WailsBridge`, and
|
||||
it runs `go mainFunc()`. 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. `application.New`
|
||||
returns the *existing* app rather than building a second one,
|
||||
`app.Run()` then refuses — `a.starting` is still true, because Android's
|
||||
`platformRun` is `select{}` and never returns — and the `os.Exit(1)`
|
||||
under that error took the **first**, healthy app down with it: its
|
||||
database, its queue, and the audio a `mediaPlayback` foreground service
|
||||
was holding the process alive to play. `mainStarted` latches it, first
|
||||
statement in `main()`.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**The answer to "restore the session or cold-start" is settled by
|
||||
playback, not by preference.** The audio lives in the Go process, so a
|
||||
cold start on every activity recreation would stop the music mid-song —
|
||||
which is the exact thing the foreground service exists to prevent. The
|
||||
activity is a view; the app is the process. The frontend already
|
||||
cooperates, because a recreated WebView loads the page fresh and fetches
|
||||
its state from a backend that never went away.
|
||||
|
||||
**Returning early is not a degraded mode, and that is why the latch is
|
||||
in Go rather than in Java.** The obvious fix — making
|
||||
`WailsBridge.initialized` `static`, so the second `nativeInit` is
|
||||
skipped — keeps the process alive and silently breaks the app, because
|
||||
skipping `nativeInit` skips the reference re-point too: Go would keep
|
||||
executing JavaScript against the *destroyed* activity's WebView, and the
|
||||
app would open, render, and never receive another backend event. The
|
||||
latch lets `nativeInit` do its first job and declines only its second.
|
||||
|
||||
**`ServiceShutdown` has never run on Android**, and nothing should be
|
||||
built on the assumption that it will. `App.Quit()` reaches an
|
||||
`androidApp.destroy()` that is an empty method, and `Run()`'s deferred
|
||||
`shutdownServices()` cannot fire behind `select{}`. Durability on this
|
||||
platform is the persist writers, which submit on every mutation rather
|
||||
than at exit — which is also why `MainActivity.onDestroy` no longer
|
||||
calls `bridge.shutdown()`: the activity going away is not the app
|
||||
shutting down, and there is no callback for the process going away
|
||||
because Android simply kills it.
|
||||
|
||||
**No tier here can see any of this**, so the guard is split. A source
|
||||
sweep (`TestMainClaimsBeforeItDoesAnything`) asserts the latch is the
|
||||
*first* statement of `main()` — the failure it exists for is not
|
||||
deletion, which is loud, but a line creeping in above it, since a second
|
||||
`NewYellowJacketApp` opens the SQLite database again on every
|
||||
recreation. The rest is a documented device check in
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`, with the
|
||||
logcat signature and a one-line way to force a recreation.
|
||||
|
||||
`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||||
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
|
||||
shape **removed** 12 spurious bindings and the bogus `context` model
|
||||
|
||||
Reference in New Issue
Block a user