Android: run main() once per process, not once per activity #161
@@ -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
|
||||
|
||||
+104
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user