Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a757c9bb4 | ||
|
|
d714bd7090 | ||
|
|
d64b069053 | ||
|
|
5490b2423e |
@@ -194,9 +194,13 @@ like the app's fault and none is:
|
||||
|---|---|---|
|
||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||
| arm64, real device | — | unverified, still |
|
||||
| arm64, real device | **runs** (2026-08-20) | — |
|
||||
|
||||
**A physical arm64 device remains the only verification path.**
|
||||
**A physical arm64 device remains the only verification path**, and it
|
||||
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
|
||||
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
|
||||
launches and stays up; `make android-smoke SECONDS=60` passes on it.
|
||||
What that run *found* is the lifecycle fault below.
|
||||
|
||||
### What was fixed to get here
|
||||
|
||||
@@ -210,6 +214,11 @@ no-op. `backend/system` gained no import of the Wails application
|
||||
package, which matters for the same reason `backend/events` is split by
|
||||
the `indexbuild` tag.
|
||||
|
||||
**And `main()` is now latched to one run per process** (#52). That is
|
||||
the second `os.Exit(1)` in this file's history and it had the same
|
||||
signature as the first, which is the argument for #160: both were named
|
||||
exactly by an `slog` line that went to `/dev/null`.
|
||||
|
||||
### What is still not done
|
||||
|
||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||
@@ -249,10 +258,20 @@ one.
|
||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||
they are the right thing to reach for when you want something one-off:
|
||||
|
||||
> **Do not run `android:run:device` or `android:deploy-device`
|
||||
> against a device that has the released app on it (#159).** Both begin
|
||||
> with `adb uninstall {{.APP_ID}}`, and `APP_ID` defaults to
|
||||
> `app.yellowjacket` — the **release** id — while `run:device` builds
|
||||
> the **debug** variant, whose id is `app.yellowjacket.dev`. So it
|
||||
> uninstalls the user's app, taking the library with it, installs a
|
||||
> different package, and then fails to launch the one it removed. This
|
||||
> is "the identity is declared twice" (below) cashing out. The safe
|
||||
> sequence is at the end of this section.
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # same, first connected physical device
|
||||
wails3 task android:deploy-device # production APK to a device
|
||||
wails3 task android:run:device # UNSAFE, see #159
|
||||
wails3 task android:deploy-device # UNSAFE, see #159
|
||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||
wails3 task android:studio # open build/android/ in Android Studio
|
||||
wails3 task android:device:list
|
||||
@@ -287,6 +306,19 @@ resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
**The safe way to put a debug build on a real device**, which is what
|
||||
#52 used and what #159 exists to make unnecessary:
|
||||
|
||||
```bash
|
||||
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
|
||||
adb install -r bin/yellowjacket.apk # -r, never uninstall
|
||||
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
|
||||
```
|
||||
|
||||
`YJ_ANDROID_PKG=app.yellowjacket.dev` points `scripts/android-emulator.sh`
|
||||
— and therefore `make android-smoke`, `android-logs`, `android-launch`
|
||||
— at the debug id, which is otherwise `app.yellowjacket`.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
@@ -311,6 +343,91 @@ system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
**The third such fault was the activity lifecycle** (#52), and it is
|
||||
the one to re-check after touching `main()`, `WailsBridge` or
|
||||
`MainActivity`. Android destroys and recreates an activity **without
|
||||
restarting the process**, and Wails' `nativeInit` — which
|
||||
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
|
||||
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
|
||||
is still true behind Android's `select{}`), and the `os.Exit(1)` under
|
||||
it took the healthy first app down with it.
|
||||
|
||||
### The lifecycle check, and how to trigger it on demand
|
||||
|
||||
This is the regression guard for #52 on this tier, because no other
|
||||
tier runs `main()` on Android at all. The Go-side guard
|
||||
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
|
||||
latch; only the device catches the latch not working.
|
||||
|
||||
**Trigger a relaunch with a configuration change the manifest does not
|
||||
declare.** `AndroidManifest.xml` lists
|
||||
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
|
||||
in-place and are *not* triggers. `fontScale` is not listed, and it is a
|
||||
one-liner:
|
||||
|
||||
```bash
|
||||
adb shell settings put system font_scale 1.15 # restore the old value after
|
||||
```
|
||||
|
||||
That is the same in-process destroy/recreate that "Don't keep
|
||||
activities", a locale change and a memory trim produce, but on demand.
|
||||
|
||||
**"Don't keep activities" is the report's own lever and did not work on
|
||||
this device**: `settings put global always_finish_activities 1` reads
|
||||
back as `1`, `am set-always-finish-activities` does not exist on this
|
||||
build, and the activity was never finished on backgrounding. Do not
|
||||
spend an afternoon on it; use the config change.
|
||||
|
||||
**The assertion is the pid, and the tell is two bridge inits in one.**
|
||||
|
||||
```bash
|
||||
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
|
||||
```
|
||||
|
||||
Healthy is one pid appearing twice — the process surviving the
|
||||
recreation:
|
||||
|
||||
```
|
||||
I/WailsBridge(28420): Wails bridge initialized
|
||||
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
|
||||
```
|
||||
|
||||
Broken is that pair followed within a second by:
|
||||
|
||||
```
|
||||
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
|
||||
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
|
||||
```
|
||||
|
||||
Two things about reading that. **`has died: fg TOP` is not a memory
|
||||
kill** — the system does not reclaim the foreground process, so this is
|
||||
the app leaving of its own accord. And there is **no crash record
|
||||
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
|
||||
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
|
||||
and it is why "the system killed it" is the wrong first hypothesis.
|
||||
|
||||
**Surviving is only half of it — check the recreated WebView is still
|
||||
wired to the running app.** A plausible-looking fix (making
|
||||
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
|
||||
keeps the process alive and silently breaks this, because `nativeInit`
|
||||
is also what re-points the JNI reference at the new bridge. Go would go
|
||||
on executing JavaScript against the destroyed activity's WebView: the
|
||||
app opens, renders, and never receives another backend event.
|
||||
|
||||
Ask the page, after a relaunch and a resume:
|
||||
|
||||
```bash
|
||||
make android-inspect
|
||||
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
|
||||
# background and foreground the app, then:
|
||||
make android-eval EXPR='JSON.stringify(window.__probe)'
|
||||
```
|
||||
|
||||
A healthy build answers with events from the live services —
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
|
||||
`[]` means the bridge reference is stale.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
|
||||
@@ -4077,3 +4077,81 @@ have saved the other two cycles.
|
||||
first track with no cover art" selects nothing in particular. The
|
||||
placeholder's presence is asserted instead, which is the property the
|
||||
test actually depends on.
|
||||
|
||||
## An activity recreation kills the process, deterministically (measured 2026-08-20)
|
||||
|
||||
#52's report was "sometimes crashes or restarts when reopened after
|
||||
running in the background". Measured on a real device, the *fault* is
|
||||
not intermittent at all — only its trigger is.
|
||||
|
||||
Device: Light Phone III (TLP301), Android 14 / SDK 34, arm64-v8a,
|
||||
WebView **Chrome 113** at 424x439 CSS px. Debug build
|
||||
(`app.yellowjacket.dev`), installed beside the released `v0.3.1` with
|
||||
`install -r`.
|
||||
|
||||
**Conditional on the activity actually being recreated in a live
|
||||
process, the process died 8 times out of 8** — 3 by hand, then 5/5 in a
|
||||
scripted loop. The runs where it survived were runs where no recreation
|
||||
happened (one `Wails bridge initialized` in the log rather than two), so
|
||||
they are inconclusive rather than passes; a harness that does not check
|
||||
for the second init reports those as green and reads as flakiness.
|
||||
After the fix: 5/5 recreations survived, plus 6 background/foreground
|
||||
cycles and 3 interleaved recreations on one pid.
|
||||
|
||||
The mechanism is three log lines:
|
||||
|
||||
```
|
||||
12:47:56.159 I/WailsBridge(22956): Wails bridge initialized
|
||||
12:48:38.898 I/WailsBridge(22956): Wails bridge initialized <- same pid
|
||||
12:48:39.357 I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||
```
|
||||
|
||||
`nativeInit` runs `go mainFunc()` on every activity creation; the second
|
||||
`main()` reaches `app.Run()`, which refuses because `a.starting` is
|
||||
still true behind Android's `select{}`, and `os.Exit(1)` takes the whole
|
||||
process — including the healthy first app — with it.
|
||||
|
||||
Four things worth keeping:
|
||||
|
||||
- **`has died: fg TOP` is not a memory kill.** The system does not
|
||||
reclaim the foreground process. This reads as "the OS killed us",
|
||||
which is the wrong hypothesis and the reason the issue sat unverified.
|
||||
- **There is no crash record of any kind**: `logcat -b crash` empty, no
|
||||
`AndroidRuntime`, no `libc: Fatal signal`, no tombstone. `os.Exit` is
|
||||
not a crash. The one line that named the fault —
|
||||
`slog.Error("application error", "err", ...)`, carrying
|
||||
`"application is running or a previous run has failed"` — went to
|
||||
`/dev/null`. That is #160.
|
||||
- **"Don't keep activities" does not work on this device.**
|
||||
`settings put global always_finish_activities 1` reads back as `1`,
|
||||
`am set-always-finish-activities` does not exist on this build, and
|
||||
the activity was never finished on backgrounding. The report's own
|
||||
suggested lever is a dead end here. What *does* work, deterministically
|
||||
and in one line, is a configuration change the manifest does not
|
||||
declare: `adb shell settings put system font_scale 1.15`
|
||||
(`AndroidManifest.xml` declares `orientation|screenSize|
|
||||
keyboardHidden|uiMode`, so none of those are triggers).
|
||||
- **Surviving is only half the property.** The recreated WebView has to
|
||||
still be wired to the running app, which was verified by hooking
|
||||
`window._wails.dispatchWailsEvent` and backgrounding/foregrounding:
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged",
|
||||
"android:storageAccess"]`. The tempting Java-side fix — making
|
||||
`WailsBridge.initialized` static — passes the pid check and fails
|
||||
this one, because `nativeInit` is also what re-points the JNI
|
||||
reference at the new bridge.
|
||||
|
||||
## The Taskfile's device tasks uninstall the released app (2026-08-20)
|
||||
|
||||
`android:run:device` builds the **debug** variant
|
||||
(`applicationIdSuffix ".dev"`) and then runs
|
||||
`adb uninstall {{.APP_ID}}`, where `APP_ID` defaults to
|
||||
`app.yellowjacket` — the **release** id. So it deletes the user's
|
||||
installed app and its library, installs a different package, and then
|
||||
fails to launch the one it removed. `deploy-device` carries the same
|
||||
uninstall. Filed as #159; `android-tier.md` had been recommending
|
||||
`run:device` as the way onto a device.
|
||||
|
||||
This is the hazard that file already names — "The identity is declared
|
||||
twice ... **Nothing enforces that they agree**" — reached by a second
|
||||
route: the two ids differ not because someone edited one, but because
|
||||
the debug buildType suffixes it.
|
||||
|
||||
@@ -317,6 +317,60 @@ stacking dialogs. Window state moved off that path entirely, onto a
|
||||
window still exists and `OnShutdown` has neither a context nor a
|
||||
window.
|
||||
|
||||
**An activity is a view onto the process, and `main()` runs once per
|
||||
process.** On Android the Wails entry point is `nativeInit`, which
|
||||
`MainActivity.onCreate` calls — and it does two things: it re-points the
|
||||
native library's global JNI reference at the calling `WailsBridge`, and
|
||||
it runs `go mainFunc()`. 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. `application.New`
|
||||
returns the *existing* app rather than building a second one,
|
||||
`app.Run()` then refuses — `a.starting` is still true, because Android's
|
||||
`platformRun` is `select{}` and never returns — and the `os.Exit(1)`
|
||||
under that error took the **first**, healthy app down with it: its
|
||||
database, its queue, and the audio a `mediaPlayback` foreground service
|
||||
was holding the process alive to play. `mainStarted` latches it, first
|
||||
statement in `main()`.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**The answer to "restore the session or cold-start" is settled by
|
||||
playback, not by preference.** The audio lives in the Go process, so a
|
||||
cold start on every activity recreation would stop the music mid-song —
|
||||
which is the exact thing the foreground service exists to prevent. The
|
||||
activity is a view; the app is the process. The frontend already
|
||||
cooperates, because a recreated WebView loads the page fresh and fetches
|
||||
its state from a backend that never went away.
|
||||
|
||||
**Returning early is not a degraded mode, and that is why the latch is
|
||||
in Go rather than in Java.** The obvious fix — making
|
||||
`WailsBridge.initialized` `static`, so the second `nativeInit` is
|
||||
skipped — keeps the process alive and silently breaks the app, because
|
||||
skipping `nativeInit` skips the reference re-point too: Go would keep
|
||||
executing JavaScript against the *destroyed* activity's WebView, and the
|
||||
app would open, render, and never receive another backend event. The
|
||||
latch lets `nativeInit` do its first job and declines only its second.
|
||||
|
||||
**`ServiceShutdown` has never run on Android**, and nothing should be
|
||||
built on the assumption that it will. `App.Quit()` reaches an
|
||||
`androidApp.destroy()` that is an empty method, and `Run()`'s deferred
|
||||
`shutdownServices()` cannot fire behind `select{}`. Durability on this
|
||||
platform is the persist writers, which submit on every mutation rather
|
||||
than at exit — which is also why `MainActivity.onDestroy` no longer
|
||||
calls `bridge.shutdown()`: the activity going away is not the app
|
||||
shutting down, and there is no callback for the process going away
|
||||
because Android simply kills it.
|
||||
|
||||
**No tier here can see any of this**, so the guard is split. A source
|
||||
sweep (`TestMainClaimsBeforeItDoesAnything`) asserts the latch is the
|
||||
*first* statement of `main()` — the failure it exists for is not
|
||||
deletion, which is loud, but a line creeping in above it, since a second
|
||||
`NewYellowJacketApp` opens the SQLite database again on every
|
||||
recreation. The rest is a documented device check in
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`, with the
|
||||
logcat signature and a one-line way to force a recreation.
|
||||
|
||||
`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||||
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
|
||||
shape **removed** 12 spurious bindings and the bogus `context` model
|
||||
|
||||
@@ -891,13 +891,41 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity going away is not the app shutting down.
|
||||
*
|
||||
* <p>The scaffold called {@code bridge.shutdown()} here, which is
|
||||
* the natural reading of onDestroy and is wrong for this app twice
|
||||
* over. Android destroys and recreates an activity for a
|
||||
* configuration change the manifest does not declare, under memory
|
||||
* pressure, and on every background if the user has "Don't keep
|
||||
* activities" on -- all **without restarting the process**. And
|
||||
* when the user really does leave, this app's reason for existing
|
||||
* in the background is that a song is playing, which is what the
|
||||
* {@code mediaPlayback} foreground service is holding the process
|
||||
* alive for. Either way, tearing the Go side down here would stop
|
||||
* the music.
|
||||
*
|
||||
* <p>It was harmless only by accident: {@code nativeShutdown} calls
|
||||
* {@code App.Quit()}, whose Android {@code destroy()} is an empty
|
||||
* method, and {@code Run()}'s deferred {@code shutdownServices()}
|
||||
* can never fire because Android's {@code platformRun} is
|
||||
* {@code select{}} and does not return. So no {@code
|
||||
* ServiceShutdown} has ever run on Android, and removing this call
|
||||
* changes nothing today -- it stops the day someone implements
|
||||
* {@code destroy()} from silently killing playback on a rotation.
|
||||
*
|
||||
* <p>There is no callback for "the process is going away"; Android
|
||||
* simply kills it. Durability on this platform is the persist
|
||||
* writers, which submit on every mutation rather than at exit.
|
||||
*
|
||||
* <p>See #52, and CLAUDE.md, "An activity is a view onto the
|
||||
* process".
|
||||
*/
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
unregisterSystemEventReceivers();
|
||||
if (bridge != null) {
|
||||
bridge.shutdown();
|
||||
}
|
||||
if (webView != null) {
|
||||
webView.destroy();
|
||||
}
|
||||
|
||||
@@ -129,7 +129,24 @@ public class WailsBridge {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the native Go library
|
||||
* Initialize the native Go library.
|
||||
*
|
||||
* <p><b>{@code initialized} is deliberately per-instance, and making
|
||||
* it {@code static} is the trap this comment exists for.</b> A
|
||||
* recreated activity builds a new bridge and calls this again, in a
|
||||
* process where the native library is already loaded and Go's
|
||||
* {@code main()} is already running -- so "initialise once per
|
||||
* process" looks like exactly the right rule. It is not, because
|
||||
* {@code nativeInit} does <i>two</i> things: it runs
|
||||
* {@code go mainFunc()}, and it stores the global JNI reference to
|
||||
* <i>this</i> bridge. Skip it and Go keeps executing JavaScript
|
||||
* against the destroyed activity's WebView: the app opens, renders,
|
||||
* and never receives another backend event.
|
||||
*
|
||||
* <p>So this is called every time, and the half that must not repeat
|
||||
* is latched on the Go side instead, at the top of {@code main()} --
|
||||
* which is also where the damage was ({@code os.Exit(1)}), and the
|
||||
* only place that can see it. See #52.
|
||||
*/
|
||||
public void initialize() {
|
||||
if (initialized) {
|
||||
|
||||
@@ -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