Compare commits

...
3 Commits
Author SHA1 Message Date
logan f7dc76c955 docs(android): an arm64 image will not run on an x86_64 host
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m42s
CI / check (push) Successful in 2m22s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Build & publish the Android APK / apk (push) Failing after 50s
Emulator 37 refuses cross-architecture emulation outright -- "Avd's CPU
Architecture 'arm64' is not supported by the QEMU2 emulator on x86_64
host" -- and there is no flag for it. Google dropped it.

That matters because the previous commit's finding points at arm64 as
the ABI that works, so the obvious next move is to boot an arm64 AVD,
and the obvious next move costs a 3.8 GB download before it fails.
Written down so the next session does not spend it.

The consequence is stated rather than hidden: the claim that arm64
avoids the seccomp trap rests on reading modernc's two code paths, not
on having run it. Verifying it needs an arm64 host, a physical device
or adb connect.
2026-08-16 16:27:52 -04:00
logan ed975019dc fix(dev): the smoke target died silently on a genuinely dead app
Two harness bugs and the finding that exposed them.

**`pidof` exits 1 when it finds nothing**, and under `set -e` a failing
command substitution killed the script before it could print anything
-- rc=1, no output. That was invisible for as long as the app
crash-*looped*, because there is always some pid in that state. It
appeared the moment the app died for good and ActivityManager stopped
respawning it, which is precisely the run you most want output from.

**And an install failure said nothing useful.** Both ways it fails are
about identity rather than the build: INSTALL_FAILED_VERSION_DOWNGRADE
when a bare `make android` (versionCode 1) meets something a versioned
build left behind, and a signature mismatch when a debug-signed local
build meets a release-signed one. Both were hit in one session, and
both are fixed by uninstalling. The target says so now instead of
leaving someone to read the constant name.

The finding: with the startup bug fixed the app reaches the database
and takes SIGSYS on the x86_64 emulator, because modernc.org/libc's
Xlstat64 issues a raw lstat syscall on linux/amd64 and Android's
seccomp filter forbids it -- bionic never issues it. arm64 has no lstat
syscall at all, so ccgo_linux_arm64.go routes Xlstat through fstatat
and is structurally unaffected; Go's own syscall package already used
fstatat on both.

So the default emulator cannot verify this app, and the skill says so
rather than letting the next session read a tombstone as a regression.
2026-08-16 16:25:33 -04:00
logan 0c7f34ab90 fix(android): give the app a home directory so it starts
backend/system resolves config and data from $HOME or the OS
equivalent, and Android has neither: buildUserDirPath switches on
runtime.GOOS with cases for darwin, linux and windows and a default
returning errUnsupportedOS. So NewYellowJacketApp failed and main()
called os.Exit(1) about six milliseconds after the JNI bridge came up.

That failure is invisible in all three places anyone would look. There
is no panic, no AndroidRuntime stack and no tombstone, because os.Exit
is not a crash; Go's stdout does not reach logcat, so the slog line
naming the error is discarded; and ActivityManager respawns the process
fast enough that pidof always answers, so a crash-looping app looks
alive.

main() now sets the override before anything asks for a path.
application.Mobile.StoragePath() is the platform's own answer --
getFilesDir() on Android, Application Support on iOS -- and returns ""
on desktop, where UseHomeOverride is a no-op, so this needs no build
tag and changes nothing off mobile. resolveUserDirPath already honours
YJ_HOME on every OS, so there was a seam for it.

The knowledge stays in main(): backend/system gains no import of the
Wails application package, for the same reason backend/events is split
by the indexbuild tag.

UseHomeOverride's two rules are tested because nothing else would
notice them breaking. An empty base does nothing, which is exactly the
desktop case. And an override already set wins, so YJ_HOME still
relocates a sandbox on the one platform that would otherwise decide for
itself.

This is not the end of the port. The app now reaches the database and
takes SIGSYS on the x86_64 emulator -- modernc.org/libc issues a raw
lstat syscall on linux/amd64 and Android's seccomp forbids it. arm64,
which is what ships to phones, has no lstat syscall at all and routes
through fstatat, so it is structurally unaffected. See NOTES.md.
2026-08-16 16:25:20 -04:00
6 changed files with 255 additions and 30 deletions
@@ -96,34 +96,71 @@ command. The emulator is addressed by its saved pid in
## The current state of the build
`make android-smoke` **fails today, and the cause is known.**
`backend/system`'s `buildUserDirPath` switches on `runtime.GOOS` with
cases for darwin, linux and windows, and a `default:` that returns
`errUnsupportedOS`. `runtime.GOOS` is `"android"`, so it takes the
default, `NewYellowJacketApp` fails, and `main()` calls `os.Exit(1)` —
about 6 ms after the bridge initialises, which is exactly the signature
described above.
**The app starts. The x86_64 emulator cannot run it, and that is not a
bug in the app.**
**The fix is a documented Wails API and needs no build tags.**
`application.Mobile.StoragePath()` returns the app's private files
directory and returns `""` on desktop (`mobile_stub.go`), and
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
OS — so setting that override from `StoragePath()` early in `main()`,
when it is non-empty, is the whole change. Do *not* import
`pkg/application` into `backend/system`: that package is deliberately
Wails-free, which is what the `indexbuild` tag split is protecting.
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
database layer, sits on — issues a **raw `lstat` syscall on
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
syscall 6 on x86_64, because bionic never issues it, so the process
takes `SIGSYS` the first time anything touches the database:
It is the *first* thing that stops it, not the only one. MPRIS is
compiled in (`android` implies the `linux` build tag, so
`mpris_linux.go` is in the build and will look for a session bus that
does not exist), and the desktop shell is still a desktop shell. Fixing
one and re-running the smoke is how you find the next.
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
**And one that no amount of porting will fix:** open-*directory*
dialogs return an error on Android — the Storage Access Framework gives
tree URIs, not filesystem paths — as do save-file dialogs. This app's
first run is "choose your music folder" and its library model is
filesystem paths, so that is a design question, not a port.
**arm64 is unaffected, and structurally so.** There is no `lstat`
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
`syscall` package already uses `fstatat` on both architectures, which
is why this is *only* the modernc path.
So: **verify on arm64, and on this machine that means a real device.**
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
says nothing about your change.
**Do not reach for an arm64 system image — it will not run here, and
finding that out costs a 3.8 GB download.** Emulator 37 refuses
outright:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation; there is no flag. The
options are an arm64 host, a physical device, or `adb connect` to one.
Two consequences worth holding onto. The x86_64 half of the fat APK is
*only* useful for emulators, and cannot work on any Android until
modernc fixes this — including x86 Chromebooks. And the tombstone is at
least honest: unlike the `os.Exit` that came before it, this one leaves
a real crash record with a backtrace.
### What was fixed to get here
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
`default:` returning `errUnsupportedOS`, so Android failed at startup
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
StoragePath())` before anything asks for a path — a documented,
build-tag-free API that returns `""` on desktop, where the setter is a
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.
### What is still not done
MPRIS is compiled in (`android` implies the `linux` build tag), the
shell is still a desktop shell, and — the largest one — open-*directory*
dialogs return an error on Android, because the Storage Access Framework
yields tree URIs rather than filesystem paths. This app's first run is
"choose your music folder" and its library model is filesystem paths, so
that is a design question rather than a port.
## The scaffold's own tasks
+72
View File
@@ -2587,3 +2587,75 @@ under the `Wails` tag and are inspectable from `chrome://inspect`;
production builds compile that out — so a debug APK is the more
informative one when something is wrong. And the docs recommend
`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine.
## The app starts on Android; x86_64 Android cannot run it (2026-08-16)
Two findings, and the second is the one with consequences.
**The startup bug is fixed.** `backend/system`'s `buildUserDirPath`
switched on `runtime.GOOS` and Android took the `default:` branch, so
`main()` called `os.Exit(1)` six milliseconds after the JNI bridge came
up. `main()` now calls
`system.UseHomeOverride(application.Mobile.StoragePath())` before
anything asks for a path. `StoragePath()` is `getFilesDir()` on
Android, Application Support on iOS and `""` on desktop — where
`UseHomeOverride` is a no-op — so the change needs no build tag and
alters nothing off mobile. `backend/system` gained no import of the
Wails application package, deliberately: that is the same constraint
the `indexbuild` split protects in `backend/events`.
**And then it takes SIGSYS on the x86_64 emulator.**
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
Syscall 6 on x86_64 is `lstat`, and the caller is **not our code and
not Go's**. Go's `syscall` package already routes both `Stat` and
`Lstat` through `fstatat` on amd64 *and* arm64. The caller is
`modernc.org/libc`, which `modernc.org/sqlite` sits on and therefore
the entire database layer: `libc_linux_amd64.go`'s `Xlstat64` issues
`unix.Syscall(unix.SYS_LSTAT, …)` directly. Android's seccomp filter
forbids it because bionic never issues it.
**arm64 is unaffected, structurally rather than by luck.** arm64 has no
`lstat` syscall at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which is
permitted. `grep -c SYS_LSTAT ccgo_linux_arm64.go` returns 0 against 1
for amd64.
Three consequences:
- **The default emulator cannot verify this app.** `make android-smoke`
on an x86_64 AVD reports a tombstone that says nothing about your
change. Verification needs an `arm64-v8a` image (full software
emulation on an x86_64 host, so slow) or a real device.
- **The x86_64 half of the fat APK is dead weight on every Android**,
not just emulators — an x86 Chromebook would hit exactly this. It is
31 MB of a 27 MB compressed artifact. Dropping it is a real option;
keeping it costs size and buys an emulator target that does not work.
Not decided here.
- The failure is at least *legible*. Unlike the `os.Exit` it replaced,
SIGSYS leaves a tombstone with a backtrace into `libwails.so`, which
is how it was identified in one pass.
Worth knowing for anything else that reaches for a pure-Go C library:
this class of bug is invisible to every build and every desktop test,
and appears only under a platform's syscall filter.
### …and the arm64 emulator is not an option on an x86_64 host
Emulator 37.1.11 refuses outright, after the 3.8 GB image download:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation and there is no flag for
it. So the arm64 claim above rests on reading modernc's two code paths,
not on having run it: verifying the shipped ABI needs an arm64 host, a
physical device, or `adb connect` to one. The image was deleted again;
do not re-download it.
+23
View File
@@ -29,6 +29,29 @@ const (
// without touching the current user's real config.toml or yj.db.
const envHomeOverride = "YJ_HOME"
// UseHomeOverride points every config and data path at base, by setting
// the same override a development sandbox uses.
//
// It exists for mobile, where the switch in buildUserDirPath has no
// answer: there is no home directory and no XDG, only a per-app private
// directory the OS hands out at runtime. The caller is main(), which is
// the only place that can ask the platform for it — deliberately, so
// this package stays free of the Wails application package that knows
// (see backend/events' indexbuild split for why that matters).
//
// Two rules. An empty base is a no-op, because that is exactly what
// application.Mobile.StoragePath() returns on desktop. And an override
// that is already set wins, so YJ_HOME on the command line still
// relocates a sandbox on a platform that would otherwise decide for
// itself.
func UseHomeOverride(base string) {
if base == "" || os.Getenv(envHomeOverride) != "" {
return
}
_ = os.Setenv(envHomeOverride, base)
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
+37
View File
@@ -1,6 +1,7 @@
package system
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -50,3 +51,39 @@ func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
)
}
}
// UseHomeOverride carries two rules that a mobile launch depends on and
// that nothing else would notice breaking: an empty base must do
// nothing, because that is precisely what StoragePath() returns on
// desktop, and an override already set must win, or YJ_HOME would stop
// relocating a sandbox on the platform that decides for itself.
func TestUseHomeOverride(t *testing.T) {
const (
storage = "/data/user/0/app.yellowjacket/files"
sandbox = "/tmp/sandbox"
)
tests := []struct {
name string
already string
base string
want string
}{
{name: "empty base is a no-op", already: "", base: "", want: ""},
{name: "sets the override when unset", already: "", base: storage, want: storage},
{name: "an existing override wins", already: sandbox, base: storage, want: sandbox},
{name: "empty base keeps an existing override", already: sandbox, base: "", want: sandbox},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envHomeOverride, tt.already)
UseHomeOverride(tt.base)
if got := os.Getenv(envHomeOverride); got != tt.want {
t.Errorf("%s = %q, want %q", envHomeOverride, got, tt.want)
}
})
}
}
+15
View File
@@ -15,6 +15,7 @@ import (
"yellowjacket/backend/assets"
"yellowjacket/backend/config"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
"yellowjacket/internal/dev"
)
@@ -28,6 +29,20 @@ var (
var frontendDistAssets embed.FS
func main() {
// **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
// switch on runtime.GOOS took the default branch and returned
// errUnsupportedOS, so NewYellowJacketApp failed and main() exited
// six milliseconds after the JNI bridge came up -- with no panic and
// no tombstone, because os.Exit is not a crash.
//
// StoragePath() is the platform's own answer (getFilesDir() on
// Android, Application Support on iOS) and returns "" on desktop,
// where UseHomeOverride is then a no-op -- so this needs no build
// tag and changes nothing off mobile.
system.UseHomeOverride(application.Mobile.StoragePath())
// WebKitGTK's DMABuf renderer crashes on NVIDIA GPUs under Wayland.
// Only disable it for that specific combo so AMD/Intel and X11 users
// keep full hardware-accelerated buffer sharing. Users can also
+46 -5
View File
@@ -162,7 +162,42 @@ cmd_install() {
need_sdk
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
"$ADB" install -r bin/yellowjacket.apk
# The two ways this fails are both about identity rather than the
# build, and neither error says what to do about it.
#
# A *downgrade* is the versionCode rule working as designed: a bare
# `make android` produces versionCode 1, so it will not install over
# anything a versioned build left behind. A *signature* mismatch is
# the rule this whole pipeline exists for — a debug-signed local
# build cannot replace a release-signed one.
#
# Both are fixed by uninstalling, and on a throwaway emulator that
# costs nothing, so say so rather than making someone read the
# constant name.
out=$("$ADB" install -r bin/yellowjacket.apk 2>&1) || {
printf '%s\n' "$out"
case "$out" in
*INSTALL_FAILED_VERSION_DOWNGRADE*)
echo
echo "The installed copy has a higher versionCode than this build."
echo "A bare 'make android' builds versionCode 1; a versioned one"
echo "builds e.g. 10301. Either uninstall:"
echo " $ADB uninstall $PKG"
echo "or build with a version:"
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
;;
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *signatures do not match*)
echo
echo "The installed copy was signed with a different key. Android"
echo "never allows that as an update — which is exactly why CI"
echo "refuses to publish a debug-signed APK. Uninstall:"
echo " $ADB uninstall $PKG"
;;
esac
return 1
}
printf '%s\n' "$out"
}
cmd_launch() {
@@ -190,11 +225,17 @@ cmd_smoke() {
cmd_launch
sleep 3
local first
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
# **`|| true` is load-bearing.** `pidof` exits 1 when it finds
# nothing, and under `set -e` a failing command substitution kills
# the script -- silently, before it can print why. That is invisible
# for as long as the app crash-*loops*, because there is always some
# pid; it appears the moment the app dies for good and ActivityManager
# stops respawning it, which is exactly the run you most want output
# from.
local first second
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
sleep "$wait_s"
local second
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
if [ -n "$first" ] && [ "$first" = "$second" ]; then
echo "PASS: $PKG alive as pid $first after ${wait_s}s"