From d64b069053e9531d4b951cdc0c6a7932382c597b Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 20 Aug 2026 13:03:51 -0400 Subject: [PATCH] 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 --- main.go | 55 +++++++++++++++++++++++++++ main_test.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 main_test.go 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 +}