Merge pull request #1808 from 1Jamie/fix/mobile-lc

fix(mobile): in-process launcher return for Android and iOS
This commit is contained in:
bryanthaboi
2026-08-25 15:14:05 -04:00
committed by GitHub
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 = require("src.core.Game2").new()
Game:load() Game:load()
else else
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in -- Gen1 Game is a module singleton. Always re-require after in-process
-- place; if a prior teardown left load missing, rebuild from source. -- EXIT GAME so a prior session cannot leave a table whose rawget(load)
local gameMod = require("src.core.Game") -- is nil (release Android: bootGame then dies with load-a-nil-value).
if type(gameMod.load) ~= "function" then -- rawget: type(mod.load) can lie via __index and skip a rebuild.
package.loaded["src.core.Game"] = nil 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 end
Game = gameMod Game = gameMod
Game:load() Game:load()
@@ -1125,15 +1127,23 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- 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 -- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below. -- 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() local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted return Game and not Importer and not quitToLauncher and not scripted
and (isAndroid or not launchedIntoGame) and (inProcessReturn or not launchedIntoGame)
end) end)
if wouldReturnToLauncher then if wouldReturnToLauncher then
if isAndroid then if inProcessReturn then
returnToLauncher() 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 end
quitToLauncher = true quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- 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" APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)" APP_NAME="$APP_NAME (test)"
fi 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 # --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then 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 -- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent -- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run. -- 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() function HostShell.restart()
if not (love and love.event and love.event.quit) then return end if not (love and love.event and love.event.quit) then return end
@@ -174,6 +182,14 @@ function HostShell.restart()
love.event.quit() love.event.quit()
return return
end 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") local appimage = os.getenv("APPIMAGE")
if not appimage then 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 -- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession. -- 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) function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end) pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown) pcall(package.loaded["src.core.ChipAudio"].stopMusic)
end end
if package.loaded["src.core.DiscordPresence"] then if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown) pcall(package.loaded["src.core.DiscordPresence"].shutdown)
@@ -87,11 +94,11 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then if game and game.reset then
pcall(function() game:reset() end) pcall(function() game:reset() end)
end end
-- Gen1 Game is the module singleton. If teardown left it without load, -- Gen1 Game is the module singleton. Always drop the cached module after
-- drop the cached module so the next require rebuilds a clean table -- a session that owned it so the next bootGame require rebuilds a clean
-- (bootGame also guards this; doing it here keeps Play-again reliable). -- table. A type(game.load) check is not enough: a wrong table parked in
if game and type(game.load) ~= "function" -- package.loaded (e.g. with __index) can still look like it has load.
and package.loaded["src.core.Game"] == game then if game and package.loaded["src.core.Game"] == game then
package.loaded["src.core.Game"] = nil package.loaded["src.core.Game"] = nil
end end
+27 -1
View File
@@ -109,9 +109,35 @@ do
-- Mimic a shared net module parked on the singleton (handle-style release). -- Mimic a shared net module parked on the singleton (handle-style release).
Game.net = { release = function(id) end } Game.net = { release = function(id) end }
SessionLifecycle.endGameSession(Game) SessionLifecycle.endGameSession(Game)
check(package.loaded["src.core.Game"] == nil,
"endGameSession drops the Gen1 Game module cache")
local again = require("src.core.Game") 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") "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 end
T.finish("android_exit_to_launcher_test") 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 -- 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 -- 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 -- 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 -- luajit tests/engine/host_restart_android_bug575.lua
package.path = "./?.lua;./?/init.lua;" .. package.path 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, "a bridge-less APK quits cleanly instead of crashing")
eq(quits[2].n, 0, "again with no restart argument") 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 -- desktop (no AppImage in a test environment) keeps the in-process restart
if not os.getenv("APPIMAGE") then if not os.getenv("APPIMAGE") then
osName = "OS X" osName = "OS X"
HostShell.restart() HostShell.restart()
eq(quits[3] and quits[3].arg, "restart", eq(quits[4] and quits[4].arg, "restart",
"non-Android still restarts in-process") "non-mobile still restarts in-process")
end end
T.finish("host_restart_android_bug575") T.finish("host_restart_android_bug575")
@@ -64,12 +64,17 @@ do
Game.linkFetch = { Game.linkFetch = {
release = function(id) jobs[id] = nil end, release = function(id) jobs[id] = nil end,
} }
local before = Game
SessionLifecycle.endGameSession(Game) SessionLifecycle.endGameSession(Game)
check(type(Game.load) == "function", check(type(before.load) == "function",
"endGameSession leaves Game.load intact for the next bootGame") "endGameSession reset leaves methods on the old table")
check(package.loaded["src.core.Game"] == Game check(package.loaded["src.core.Game"] == nil,
or type((package.loaded["src.core.Game"] or {}).load) == "function", "endGameSession drops Gen1 Game from package.loaded")
"Gen1 Game module remains require-able after endGameSession") 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 end
-- ---- Game2:reset releases world GPU and present canvases ------------------ -- ---- 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, check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
"Fetch registers its shutdown hook at load") "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, -- 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 -- 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 -- love.quit() veto test, or the editor's abort-quit path would die on a quit