Android: run main() once per process, not once per activity #161
No files matched your search
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/golang-cz/devslog"
|
"github.com/golang-cz/devslog"
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -28,7 +29,61 @@ var (
|
|||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var frontendDistAssets embed.FS
|
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() {
|
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
|
// **Mobile has no home directory, and this must run before anything
|
||||||
// asks for a path.** backend/system resolves config and data from
|
// asks for a path.** backend/system resolves config and data from
|
||||||
// $HOME or the OS equivalent, and on Android there is neither: its
|
// $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