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 +}