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
+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