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.
This commit is contained in:
1jamie
2026-08-25 11:09:28 -05:00
parent bbe0f0d9ae
commit 28dc9b9bb0
8 changed files with 115 additions and 25 deletions
+19 -9
View File
@@ -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.
local gameMod = require("src.core.Game")
if type(gameMod.load) ~= "function" then
-- 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
gameMod = require("src.core.Game")
local 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
+7
View File
@@ -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
+17 -1
View File
@@ -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
+13 -6
View File
@@ -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
+27 -1
View File
@@ -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")
+12 -3
View File
@@ -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")
@@ -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 ------------------
+10
View File
@@ -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