Files
logan 30c6b665f1
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m0s
CI / e2e (pull_request) Successful in 9m37s
fix(system): give the process a temp directory that exists
Android has no /tmp and hands an app no TMPDIR. Go's os.TempDir() falls
back to "/tmp" when the variable is unset, so every library in this
process that wants scratch space was being handed a path that has never
existed.

SQLite is the one that noticed, and it said so precisely:

    W/yellowjacket: msg="champion index rebuild failed"
      explore.search-index.error="populate champion fts: disk I/O error (6410)"

6410 is not a generic I/O error. `6410 & 0xff` is 10, SQLITE_IOERR, and
`6410 >> 8` is 25 -- SQLITE_IOERR_GETTEMPPATH. SQLite could not work out
where to put a temporary file. Two measurements on the device say why:
`ls -d /tmp` does not exist, and the app process's environment carries
no TMPDIR. A shell's does (/data/local/tmp), which is why this is easy
to miss from `adb shell`.

The cost was a silent performance cliff on the slowest device this app
runs on: `championReady` stayed false, so every Explore search took the
generic path over the whole 1,079,667-row index instead of the champion
subset, and the rebuild was re-attempted on every launch.

**The class is fixed rather than the statement.** The trigger is the
*size* of the work, not that query -- anything that spills fails the
same way there, so large sorts, large joins and VACUUM were all waiting
their turn. The repair belongs at the process's one answer to "where do
temporary files go".

`PRAGMA temp_store = MEMORY` was the alternative: cheaper, more local,
and a promise that every future spill fits in RAM on a phone. The
catalog is the largest thing in this app and that is not a promise
worth making silently.

UseTempDir sits beside UseHomeOverride and carries its two rules for
the same reasons. **An empty base is a no-op**, because that is what
application.Mobile.StoragePath() returns on desktop -- so this needs no
build tag and changes nothing off mobile, where /tmp is real. And **an
explicit TMPDIR wins**, so anyone who set one deliberately gets it;
nothing sets it on the platform this exists for. It needs no new Wails
API and no Java change: StoragePath() is already what YJ_HOME is
pointed at, and the directory goes under it.

Two things beyond the rename of a variable.

**Writability is probed, not assumed.** MkdirAll on an existing
unwritable directory succeeds, so without the probe this could set
TMPDIR to a directory nothing can use -- which is the same bug one
directory over, and just as quiet.

**It returns its error, and main logs it.** A temp directory that could
not be created is the same silent failure one step earlier. A failure
is not fatal: it leaves the platform's answer in place, which is what
every release before this one ran with. That log line is readable on
the platform only because of #160.

Verified on the reference device, where the same launch that used to
print the failure now prints:

    I/yellowjacket: msg="champion index rebuilt"
      explore.search-index.elapsed=6.496s

Closes #190
2026-08-21 17:23:23 -04:00

241 lines
8.3 KiB
Go

// Package main is the entry point for the Yellowjacket application.
package main
import (
"embed"
"log/slog"
"os"
"strings"
"sync/atomic"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"yellowjacket/backend"
"yellowjacket/backend/assets"
"yellowjacket/backend/config"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
"yellowjacket/internal/dev"
)
// version and commit are set at build time via ldflags.
var (
version = "dev"
commit = "unknown"
)
//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
// 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
// force the workaround with WEBKIT_DISABLE_DMABUF_RENDERER=1.
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") == "" && isNVIDIAWayland() {
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
isDev := dev.IsDev
// create sLogger
loglevel := resolveLogLevel(isDev)
sLogger := slog.New(newLogHandler(&slog.HandlerOptions{Level: loglevel}))
slog.SetDefault(sLogger)
sLogger.Info("starting yellowjacket", "version", version, "commit", commit)
// Android has no /tmp and gives an app no TMPDIR, so anything in
// this process that spills to a temporary file is handed a path that
// does not exist -- see system.UseTempDir. It runs here rather than
// beside UseHomeOverride above because it has something to say when
// it fails and the logger does not exist up there; what matters is
// that it is before NewYellowJacketApp, which opens the database.
//
// A failure is not fatal: it leaves the platform's own answer in
// place, which is what every release before this one ran with.
if err := system.UseTempDir(application.Mobile.StoragePath()); err != nil {
sLogger.Error("could not set up a temp directory", "err", err.Error())
}
// Start profiling server (pprof + trace). In production builds this
// is a no-op — the compiler eliminates all profiling code.
stopProfiler := profiling.Start(sLogger)
// create asset handler
assetHandler, err := assets.NewAssetHandler(sLogger, frontendDistAssets)
if err != nil {
sLogger.Error("could not create asset handler", "err", err.Error())
stopProfiler()
os.Exit(1)
}
yjApp, err := backend.NewYellowJacketApp(sLogger, assetHandler)
if err != nil {
sLogger.Error("problem initializing yellowjacket", "err", err.Error())
stopProfiler()
os.Exit(1)
}
winCfg := yjApp.WindowConfig()
app := application.New(application.Options{
Name: "yellowjacket",
Description: "A cross-platform desktop music player",
Services: yjApp.Services,
Assets: assetHandler.Options,
// v3 logs through slog directly, so v2's logger.Logger adapter
// (backend/logging) is gone rather than ported.
Logger: sLogger,
ShouldQuit: yjApp.ShouldQuit,
OnShutdown: yjApp.OnShutdown,
})
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "yellowjacket",
Width: winCfg.Width,
Height: winCfg.Height,
MinWidth: config.MinWidth,
MinHeight: config.MinHeight,
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/",
Linux: application.LinuxWindow{
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
},
})
// The window's size has to be read while the window still exists,
// which OnShutdown is too late for.
window.OnWindowEvent(
events.Common.WindowClosing,
func(*application.WindowEvent) { yjApp.SaveWindowState(window) },
)
err = app.Run()
stopProfiler()
if err != nil {
sLogger.Error("application error", "err", err.Error())
os.Exit(1)
}
}
// resolveLogLevel determines the slog level. In dev mode the default
// is Info (not Debug) to avoid flooding stdout during library scans.
// Set YJ_LOG_LEVEL=debug to restore verbose logging.
//
// Accepted values: debug, info, warn, error (case-insensitive).
// Production builds always default to Info.
func resolveLogLevel(_ bool) slog.Level {
if env := os.Getenv("YJ_LOG_LEVEL"); env != "" {
switch strings.ToLower(env) {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
}
}
// Default: Info for both dev and prod.
return slog.LevelInfo
}
// isNVIDIAWayland returns true when running under a Wayland session with
// an NVIDIA GPU. This combination triggers DMABuf rendering crashes in
// WebKitGTK, so we need to disable the DMABuf renderer for it.
func isNVIDIAWayland() bool {
// Not Wayland → safe.
if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("XDG_SESSION_TYPE") != "wayland" {
return false
}
// Check for NVIDIA kernel modules (works even without nvidia-smi).
if data, err := os.ReadFile("/proc/driver/nvidia/version"); err == nil {
_ = data
return true
}
// Fallback: check if the nvidia module is loaded.
if data, err := os.ReadFile("/proc/modules"); err == nil {
if strings.Contains(string(data), "nvidia") {
return true
}
}
return false
}