diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md index 01804fa..1e417f5 100644 --- a/.pi/skills/yellowjacket-dev/references/android-tier.md +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -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 diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 3db730d..630793f 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a63e2c6..43b4d8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java index 0840ded..9c84164 100644 --- a/build/android/app/src/main/java/com/wails/app/MainActivity.java +++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java @@ -891,13 +891,41 @@ public class MainActivity extends AppCompatActivity { } } + /** + * The activity going away is not the app shutting down. + * + *

The scaffold called {@code bridge.shutdown()} here, which is + * the natural reading of onDestroy and is wrong for this app twice + * over. Android destroys and recreates an activity for a + * configuration change the manifest does not declare, under memory + * pressure, and on every background if the user has "Don't keep + * activities" on -- all **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 + * {@code mediaPlayback} foreground service is holding the process + * alive for. Either way, tearing the Go side down here would stop + * the music. + * + *

It was harmless only by accident: {@code nativeShutdown} calls + * {@code App.Quit()}, whose Android {@code destroy()} is an empty + * method, and {@code Run()}'s deferred {@code shutdownServices()} + * can never fire because Android's {@code platformRun} is + * {@code select{}} and does not return. So no {@code + * ServiceShutdown} has ever run on Android, and removing this call + * changes nothing today -- it stops the day someone implements + * {@code destroy()} from silently killing playback on a rotation. + * + *

There is no callback for "the process is going away"; Android + * simply kills it. Durability on this platform is the persist + * writers, which submit on every mutation rather than at exit. + * + *

See #52, and CLAUDE.md, "An activity is a view onto the + * process". + */ @Override protected void onDestroy() { super.onDestroy(); unregisterSystemEventReceivers(); - if (bridge != null) { - bridge.shutdown(); - } if (webView != null) { webView.destroy(); } diff --git a/build/android/app/src/main/java/com/wails/app/WailsBridge.java b/build/android/app/src/main/java/com/wails/app/WailsBridge.java index 6198770..ce912cc 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsBridge.java +++ b/build/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -129,7 +129,24 @@ public class WailsBridge { } /** - * Initialize the native Go library + * Initialize the native Go library. + * + *

{@code initialized} is deliberately per-instance, and making + * it {@code static} is the trap this comment exists for. A + * recreated activity builds a new bridge and calls this again, in a + * process where the native library is already loaded and Go's + * {@code main()} is already running -- so "initialise once per + * process" looks like exactly the right rule. It is not, because + * {@code nativeInit} does two things: it runs + * {@code go mainFunc()}, and it stores the global JNI reference to + * this bridge. Skip it and Go keeps executing JavaScript + * against the destroyed activity's WebView: the app opens, renders, + * and never receives another backend event. + * + *

So this is called every time, and the half that must not repeat + * is latched on the Go side instead, at the top of {@code main()} -- + * which is also where the damage was ({@code os.Exit(1)}), and the + * only place that can see it. See #52. */ public void initialize() { if (initialized) { diff --git a/main.go b/main.go index 5c593d4..067d8f2 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "strings" + "sync/atomic" "github.com/golang-cz/devslog" "github.com/wailsapp/wails/v3/pkg/application" @@ -28,7 +29,61 @@ var ( //go:embed all:frontend/dist var frontendDistAssets embed.FS +// mainStarted latches the first entry into main(). +// +// **On Android main() is called once per *activity*, and the process +// outlives the activity.** Wails' JNI entry point is +// `nativeInit`, which does two things: it re-points the native +// library's global reference at the calling `WailsBridge`, and it runs +// `go mainFunc()`. `MainActivity.onCreate` calls it, and Android +// recreates the activity — for a configuration change it does not +// declare, under memory pressure, or on every single background when +// the user has "Don't keep activities" switched on — **without +// restarting the process**. +// +// So main() ran again, on a live app, and every path out of that is +// fatal: +// +// - `application.New` returns the *existing* `globalApplication` when +// there is one, silently discarding the second set of Services. +// - `app.Run()` then refuses, by design: `a.starting` is still true, +// because Android's `platformRun` is `select{}` and never returns. +// It answers "application is running or a previous run has failed". +// - which lands on `os.Exit(1)` at the foot of this function, and +// that takes down the **first**, perfectly healthy app with it — +// its database, its queue, and the audio that a foreground service +// is holding the process alive to play. +// +// ActivityManager then restarts the app, which is the report: "crashes +// or restarts when reopened after running in the background". It never +// left a tombstone because `os.Exit` is not a crash, and it never left +// a log line because an Android app's fd 1 goes to /dev/null. +// +// The latch is the whole fix, and it has to be **first**: everything +// below it — `NewYellowJacketApp` above all, which opens the SQLite +// database — is work that must not happen twice in one process. +// Returning early is not a degraded mode: `nativeInit` has already +// re-attached the bridge, so the recreated activity's WebView talks to +// the app that is still running, with its queue and its playback +// position intact. See CLAUDE.md, "An activity is a view onto the +// process". +// +// It is inert off Android, where a process has exactly one main(). +var mainStarted atomic.Bool + +// claimMainOnce reports whether this is the first call to main() in +// this process. See mainStarted. +func claimMainOnce() bool { + return mainStarted.CompareAndSwap(false, true) +} + func main() { + // Android calls main() once per activity, and the process outlives + // the activity. Nothing below this line may run twice. + if !claimMainOnce() { + return + } + // **Mobile has no home directory, and this must run before anything // asks for a path.** backend/system resolves config and data from // $HOME or the OS equivalent, and on Android there is neither: its diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..df0d087 --- /dev/null +++ b/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// TestMainRunsOncePerProcess pins the latch itself. +func TestMainRunsOncePerProcess(t *testing.T) { + t.Parallel() + + mainStarted.Store(false) + + if !claimMainOnce() { + t.Fatal("the first call to claimMainOnce must claim it") + } + + if claimMainOnce() { + t.Fatal("a second call to claimMainOnce must not claim it: " + + "on Android that second call is a second main() in a live " + + "process, and every path out of it ends in os.Exit(1)") + } +} + +// TestMainClaimsBeforeItDoesAnything is the assertion that actually +// guards #52, and it is a source sweep for the reason +// TestNoDirectRuntimeEmits is: no tier here runs main() on Android, so +// nothing else can see work creeping in above the latch. +// +// The failure it exists for is not the latch being deleted — that is +// loud. It is a line being added above it: a second +// NewYellowJacketApp opens the SQLite database a second time in one +// process, and it would do so on every activity recreation, silently, +// on a build that otherwise looks entirely healthy. +func TestMainClaimsBeforeItDoesAnything(t *testing.T) { + t.Parallel() + + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + + var fn *ast.FuncDecl + + for _, decl := range file.Decls { + d, ok := decl.(*ast.FuncDecl) + if ok && d.Name.Name == "main" && d.Recv == nil { + fn = d + + break + } + } + + if fn == nil { + t.Fatal("no func main in main.go — this test read the wrong file") + } + + if len(fn.Body.List) == 0 { + t.Fatal("func main is empty") + } + + if !claimsMainOnce(fn.Body.List[0]) { + t.Fatalf("the first statement of main() must be the "+ + "`if !claimMainOnce() { return }` guard, got %T — see #52: "+ + "Android calls main() once per activity, in a process that "+ + "outlives the activity, so anything above the guard runs "+ + "again on every recreation", fn.Body.List[0]) + } +} + +// claimsMainOnce reports whether stmt is `if !claimMainOnce() { return }`. +func claimsMainOnce(stmt ast.Stmt) bool { + ifStmt, ok := stmt.(*ast.IfStmt) + if !ok { + return false + } + + unary, ok := ifStmt.Cond.(*ast.UnaryExpr) + if !ok || unary.Op != token.NOT { + return false + } + + call, ok := unary.X.(*ast.CallExpr) + if !ok { + return false + } + + ident, ok := call.Fun.(*ast.Ident) + if !ok || ident.Name != "claimMainOnce" { + return false + } + + if len(ifStmt.Body.List) != 1 { + return false + } + + _, ok = ifStmt.Body.List[0].(*ast.ReturnStmt) + + return ok +}