From 28dc9b9bb0bfecc25eb66724167d793f24681103 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Tue, 25 Aug 2026 11:09:28 -0500 Subject: [PATCH] fix(mobile): in-process launcher return for Android and iOS Stop joining ChipAudio on every EXIT GAME, always rebuild the Gen1 Game module on Play-again, and treat iOS like Android for return-to-launcher so release/mobile LOVE restarts no longer wipe Game.load or crash on quit. --- main.lua | 28 +++++++++++++------ scripts/build_android.sh | 7 +++++ src/core/HostShell.lua | 18 +++++++++++- src/core/SessionLifecycle.lua | 19 +++++++++---- .../engine/android_exit_to_launcher_test.lua | 28 ++++++++++++++++++- tests/engine/host_restart_android_bug575.lua | 15 ++++++++-- .../engine/launcher_session_teardown_test.lua | 15 ++++++---- tests/engine/quit_thread_shutdown.lua | 10 +++++++ 8 files changed, 115 insertions(+), 25 deletions(-) diff --git a/main.lua b/main.lua index 52a4a1f2..eda842d2 100644 --- a/main.lua +++ b/main.lua @@ -384,12 +384,14 @@ function bootGame(version, cartId) Game = require("src.core.Game2").new() Game:load() else - -- Gen1 Game is a module singleton. In-process EXIT GAME resets it in - -- place; if a prior teardown left load missing, rebuild from source. + -- Gen1 Game is a module singleton. Always re-require after in-process + -- EXIT GAME so a prior session cannot leave a table whose rawget(load) + -- is nil (release Android: bootGame then dies with load-a-nil-value). + -- rawget: type(mod.load) can lie via __index and skip a rebuild. + package.loaded["src.core.Game"] = nil local gameMod = require("src.core.Game") - if type(gameMod.load) ~= "function" then - package.loaded["src.core.Game"] = nil - gameMod = require("src.core.Game") + if type(rawget(gameMod, "load")) ~= "function" then + error("src.core.Game missing load after reload") end Game = gameMod Game:load() @@ -1125,15 +1127,23 @@ function love.quit() -- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- this Lua launcher via that hook. Vanilla behavior (used when no mod -- claims the hook) is exactly the condition below. - local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android") + -- + -- Android and iOS both tear down LOVE in-process rather than + -- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes + -- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for + -- every quit while warning that leftover threads make that unreliable. + -- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning + -- real -- endProcess joins them, then the native restart still blows up. + local osName = love.system and love.system.getOS and love.system.getOS() + local inProcessReturn = (osName == "Android" or osName == "iOS") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and (isAndroid or not launchedIntoGame) + and (inProcessReturn or not launchedIntoGame) end) if wouldReturnToLauncher then - if isAndroid then + if inProcessReturn then returnToLauncher() - return true -- abort this quit; the restart lands back in the launcher + return true -- abort this quit; stay in the same LOVE run end quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 49917a19..4cbfd153 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then APPLICATION_ID="$TEST_APPLICATION_ID" APP_NAME="$APP_NAME (test)" fi +# Optional overrides for side-by-side test APKs (never used by CI shipping builds). +if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then + APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID" +fi +if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then + APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME" +fi # --------------------------------------------------------------- preconditions if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index cb6d1082..43d40bc5 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -158,7 +158,15 @@ end -- and the app dies. There we relaunch through the GameActivity.restartApp -- JNI bridge (love.system.restartApp), which schedules our launch intent -- and kills the process so no native state can leak into the fresh run. --- On every other platform the in-process restart works, so keep it. +-- iOS is the same class of problem with a sharper edge: love.cpp under +-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic +-- exit) and comments that leftover threads make that restart unreliable -- +-- which our ChipAudio / Fetch / Check workers are. There is no +-- restartApp bridge on iOS, so callers that want "back to launcher" must +-- use main.lua's in-process returnToLauncher (love.quit aborts the quit); +-- HostShell.restart itself refuses quit("restart") and falls back to a +-- bare quit() so a mod that still calls restart does not pick the worst +-- path on purpose. function HostShell.restart() if not (love and love.event and love.event.quit) then return end @@ -174,6 +182,14 @@ function HostShell.restart() love.event.quit() return end + if osName == "iOS" then + -- No process-kill bridge. A bare quit still becomes DONE_RESTART in + -- love.cpp, but quit("restart") is the path that also runs our + -- endProcess worker joins first and then re-enters runlove -- the + -- combination that crashes EXIT GAME. Prefer the softer quit. + love.event.quit() + return + end local appimage = os.getenv("APPIMAGE") if not appimage then diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index 815a00d4..ca9c9d8d 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -64,11 +64,18 @@ end -- EXIT GAME / intent_game before dropping Game. Stops audio and resets the -- live game instance so map/GPU holders are gone before endMountedSession. +-- +-- ChipAudio's worker stays alive across game sessions (see tier comment +-- above). shutdown() joins the thread and is process-exit only -- calling +-- it here on every Android EXIT GAME has been observed to leave the Gen1 +-- Game singleton unbootable (Game.load nil) on the next Play of a version +-- already opened this process, while a debug APK with a different liblove +-- did not reproduce. function SessionLifecycle.endGameSession(game) pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Sound").stop() end) if package.loaded["src.core.ChipAudio"] then - pcall(package.loaded["src.core.ChipAudio"].shutdown) + pcall(package.loaded["src.core.ChipAudio"].stopMusic) end if package.loaded["src.core.DiscordPresence"] then pcall(package.loaded["src.core.DiscordPresence"].shutdown) @@ -87,11 +94,11 @@ function SessionLifecycle.endGameSession(game) if game and game.reset then pcall(function() game:reset() end) end - -- Gen1 Game is the module singleton. If teardown left it without load, - -- drop the cached module so the next require rebuilds a clean table - -- (bootGame also guards this; doing it here keeps Play-again reliable). - if game and type(game.load) ~= "function" - and package.loaded["src.core.Game"] == game then + -- Gen1 Game is the module singleton. Always drop the cached module after + -- a session that owned it so the next bootGame require rebuilds a clean + -- table. A type(game.load) check is not enough: a wrong table parked in + -- package.loaded (e.g. with __index) can still look like it has load. + if game and package.loaded["src.core.Game"] == game then package.loaded["src.core.Game"] = nil end diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 04e69531..09ff37e0 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -109,9 +109,35 @@ do -- Mimic a shared net module parked on the singleton (handle-style release). Game.net = { release = function(id) end } SessionLifecycle.endGameSession(Game) + check(package.loaded["src.core.Game"] == nil, + "endGameSession drops the Gen1 Game module cache") local again = require("src.core.Game") - check(type(again.load) == "function", + check(type(rawget(again, "load")) == "function", "Play-again can call Game:load after endGameSession") + check(again ~= Game, "Play-again gets a fresh Gen1 Game module table") +end + +-- 7. endGameSession must not join the ChipAudio worker (process-tier only). +-- Joining on every EXIT GAME correlates with release-APK Game.load nil +-- when reopening a version already played this process. +do + local SessionLifecycle = require("src.core.SessionLifecycle") + local ChipAudio = require("src.core.ChipAudio") + local shutdownCalls, stopCalls = 0, 0 + local origShutdown, origStop = ChipAudio.shutdown, ChipAudio.stopMusic + ChipAudio.shutdown = function(...) + shutdownCalls = shutdownCalls + 1 + return origShutdown(...) + end + ChipAudio.stopMusic = function(...) + stopCalls = stopCalls + 1 + return origStop(...) + end + local game = { reset = function() end, load = function() end } + SessionLifecycle.endGameSession(game) + ChipAudio.shutdown, ChipAudio.stopMusic = origShutdown, origStop + eq(shutdownCalls, 0, "endGameSession does not ChipAudio.shutdown") + check(stopCalls >= 1, "endGameSession stops chip music (Music.stop and/or stopMusic)") end T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/host_restart_android_bug575.lua b/tests/engine/host_restart_android_bug575.lua index 82f1dca3..3d5c7dc0 100644 --- a/tests/engine/host_restart_android_bug575.lua +++ b/tests/engine/host_restart_android_bug575.lua @@ -4,7 +4,10 @@ -- The fix prefers the love.system.restartApp JNI bridge (which kills the -- process, so a true return is never observed live) and, on an old APK -- whose liblove lacks the bridge, falls back to a CLEAN quit with no --- argument. Desktop keeps the in-process quit("restart"). +-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART +-- for every quit; HostShell.restart must still refuse quit("restart") so a +-- leftover caller does not pick the worker-join + native-restart path that +-- crashes EXIT GAME. Desktop keeps the in-process quit("restart"). -- luajit tests/engine/host_restart_android_bug575.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -48,12 +51,18 @@ HostShell.restart() eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing") eq(quits[2].n, 0, "again with no restart argument") +-- iOS: no process-kill bridge; never quit("restart") +osName = "iOS" +HostShell.restart() +eq(#quits, 3, "iOS HostShell.restart still quits once") +eq(quits[3].n, 0, "iOS uses a bare quit(), never quit(\"restart\")") + -- desktop (no AppImage in a test environment) keeps the in-process restart if not os.getenv("APPIMAGE") then osName = "OS X" HostShell.restart() - eq(quits[3] and quits[3].arg, "restart", - "non-Android still restarts in-process") + eq(quits[4] and quits[4].arg, "restart", + "non-mobile still restarts in-process") end T.finish("host_restart_android_bug575") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index 5e3b4b66..f4348a42 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -64,12 +64,17 @@ do Game.linkFetch = { release = function(id) jobs[id] = nil end, } + local before = Game SessionLifecycle.endGameSession(Game) - check(type(Game.load) == "function", - "endGameSession leaves Game.load intact for the next bootGame") - check(package.loaded["src.core.Game"] == Game - or type((package.loaded["src.core.Game"] or {}).load) == "function", - "Gen1 Game module remains require-able after endGameSession") + check(type(before.load) == "function", + "endGameSession reset leaves methods on the old table") + check(package.loaded["src.core.Game"] == nil, + "endGameSession drops Gen1 Game from package.loaded") + local again = require("src.core.Game") + check(type(rawget(again, "load")) == "function", + "require rebuilds a bootable Gen1 Game after endGameSession") + check(again ~= before, "Play-again uses a fresh Gen1 Game module table") + Game = again end -- ---- Game2:reset releases world GPU and present canvases ------------------ diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index 4f8a3589..9ee27fca 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -197,6 +197,16 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, "Fetch registers its shutdown hook at load") +-- iOS EXIT GAME must share Android's in-process returnToLauncher: love.cpp +-- under LOVE_IOS forces DONE_RESTART for every quit and warns that leftover +-- threads make that restart unreliable (ChipAudio / Fetch / Check). +check(quitHook:find('osName == "Android" or osName == "iOS"', 1, true) ~= nil, + "love.quit treats Android and iOS as in-process return platforms") +check(quitHook:find("inProcessReturn", 1, true) ~= nil, + "love.quit gates returnToLauncher on inProcessReturn") +check(quitHook:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "desktop return-to-launcher still reaches HostShell.restart") + -- The Android half: LOVE keeps the JVM process after the native main returns, -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit