Files
logan 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
2026-08-20 13:03:51 -04:00

105 lines
2.5 KiB
Go

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
}