diff --git a/main.lua b/main.lua index 7b6fba4c..62175e4d 100644 --- a/main.lua +++ b/main.lua @@ -84,40 +84,8 @@ local closeEditor -- forward declaration: openEditor hands it to the editor -- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted -- version session (save editor or game). closeEditor and returnToLauncher --- both go through here so neither path can forget a singleton the other resets. -local function teardownMountedSession(version) - if version then - require("src.import.CacheFs").unmountVersion(version) - end - require("src.core.Data"):unloadGenerated() - local Runtime = require("src.mods.Runtime") - if Runtime.reset then Runtime.reset() end - local Assets = require("src.render.Assets") - if Assets.installLoader then Assets.installLoader(nil) end - local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") - if okCompat and LegacyCompat.reset then LegacyCompat.reset() end -end - --- Evict every save-editor module from package.loaded without a hardcoded --- panel whitelist. Flat require names (App, Party, …) resolve under --- tools/save-editor/; path-style keys may also appear. A key is flushed --- when it names a save-editor path or when tools/save-editor/{panels/}K.lua --- exists for a flat name K -- new panels are picked up automatically. -local function flushEditorPackageLoaded() - local fs = love and love.filesystem - local function isEditorFlat(name) - if not (fs and fs.getInfo) then return false end - if name:find("[./]") then return false end - return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil - or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil - end - for k in pairs(package.loaded) do - if type(k) == "string" - and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then - package.loaded[k] = nil - end - end -end +-- both go through SessionLifecycle so neither path forgets a singleton. +local SessionLifecycle = require("src.core.SessionLifecycle") -- The editor's modules use flat names (require("Kit"), require("Party")), so -- their directories have to be on the require path. It must be @@ -194,9 +162,7 @@ local function openEditor(version, slotId) local okReq, appOrErr = pcall(require, "App") if not okReq then editorMode = false - if version then - require("src.import.CacheFs").unmountVersion(version) - end + SessionLifecycle.endEditorSession({ version = version, app = nil }) restoreWindow() Importer = editorHost editorHost = nil @@ -216,10 +182,7 @@ local function openEditor(version, slotId) editorMode = false if EditorApp.unload then pcall(EditorApp.unload) end EditorApp = nil - if version then - teardownMountedSession(version) - end - flushEditorPackageLoaded() + SessionLifecycle.endEditorSession({ version = version, app = nil }) restoreWindow() Importer = editorHost editorHost = nil @@ -238,13 +201,10 @@ end -- next Edit or Play does not inherit the editor's dead mod loader. function closeEditor() local version = editorVersion + local app = EditorApp editorMode = false - if EditorApp and EditorApp.unload then EditorApp.unload() end EditorApp = nil - if version then - teardownMountedSession(version) - end - flushEditorPackageLoaded() + SessionLifecycle.endEditorSession({ version = version, app = app }) editorVersion = nil restoreWindow() Importer = editorHost @@ -345,40 +305,14 @@ end local function returnToLauncher() if not Game then return end - 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) - end - if package.loaded["src.core.DiscordPresence"] then - pcall(package.loaded["src.core.DiscordPresence"].shutdown) - end - if package.loaded["src.core.gen2.Clock"] then - pcall(package.loaded["src.core.gen2.Clock"].shutdown) - end - if package.loaded["src.net.Gen1Tls"] then - pcall(package.loaded["src.net.Gen1Tls"].shutdown) - end - if love.audio and love.audio.stop then - pcall(love.audio.stop) - end - pcall(function() require("src.render.SecondScreen").setEnabled(false) end) - local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() - teardownMountedSession(currentVersion) - - if Game.reset then - pcall(function() Game:reset() end) - end + SessionLifecycle.endGameSession(Game) Game = nil autopilot = nil driverCo = nil - local Input = require("src.core.Input") - local TouchControls = require("src.core.TouchControls") - Input:reset() - TouchControls:reset() + SessionLifecycle.endMountedSession(currentVersion) require("src.core.Orientation").applyOptions( require("src.core.SaveData").loadOptions()) @@ -1181,22 +1115,7 @@ function love.quit() pcall(function() require("src.core.DiscordPresence").shutdown() end) - -- LOVE waits for every live love.thread before the process exits, and both - -- background workers idle in a loop that only a "quit" command breaks, so - -- without this the process outlived the window and the next launch re-entered - -- the dead one instead of starting fresh (#339) - if package.loaded["src.core.ChipAudio"] then - pcall(package.loaded["src.core.ChipAudio"].shutdown) - end - if package.loaded["src.update.Check"] then - pcall(package.loaded["src.update.Check"].shutdown) - end - -- The launcher's fetch pool is the same story: its workers idle in - -- Channel:demand(), which never returns on its own, so a launcher that ever - -- touched the network would hang the process on exit (#339's shape again). - if package.loaded["src.net.Fetch"] then - pcall(package.loaded["src.net.Fetch"].shutdown) - end + SessionLifecycle.endProcess() end function love.filedropped(file) diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index b7b09faa..ef07d40e 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -371,7 +371,7 @@ function ChipAudio.shutdown() if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end worker, cmdCh, outCh = nil, nil, nil - workerReady = false + workerReady = nil end function ChipAudio.currentSource() @@ -498,6 +498,8 @@ end -- program (20 §2 cache contract, chip music row) Assets.register(ChipAudio.invalidate) +require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown) + -- --------------------------------------------------------------------------- -- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources -- --------------------------------------------------------------------------- diff --git a/src/core/Game.lua b/src/core/Game.lua index b1b730b9..71ae4d68 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1389,12 +1389,25 @@ function Game:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end + if self.world and self.world.release then + pcall(function() self.world:release() end) + end + if self._canvases then + for _, canvas in pairs(self._canvases) do + if canvas and canvas.release then pcall(canvas.release, canvas) end + end + end if self.renderer and self.renderer.releaseCanvases then pcall(function() self.renderer:releaseCanvases() end) end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then + if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then + if type(value) == "table" and value.release then + pcall(value.release, value) + end + end keys[#keys + 1] = key end end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4ab8a479..145c97ed 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2130,9 +2130,25 @@ function Game2:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end + if self.world and self.world.release then + pcall(function() self.world:release() end) + end + if self._canvases then + for _, canvas in pairs(self._canvases) do + if canvas and canvas.release then pcall(canvas.release, canvas) end + end + end + if self.renderer and self.renderer.releaseCanvases then + pcall(function() self.renderer:releaseCanvases() end) + end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then + if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then + if type(value) == "table" and value.release then + pcall(value.release, value) + end + end keys[#keys + 1] = key end end diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua new file mode 100644 index 00000000..84508a67 --- /dev/null +++ b/src/core/SessionLifecycle.lua @@ -0,0 +1,101 @@ +-- Central session lifecycle orchestrator. Subsystems register teardown hooks +-- at module load (Assets release/invalidate bus, process shutdown below); the +-- host only calls phase entry points. +-- +-- Three tiers: +-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets +-- game endGameSession — audio + game:reset; workers stay alive +-- process endProcess — worker shutdown on real app exit (love.quit) +-- +-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever. + +local SessionLifecycle = {} + +local processShutdowns = {} + +function SessionLifecycle.registerProcessShutdown(fn) + processShutdowns[#processShutdowns + 1] = fn +end + +-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted +-- version session (save editor or game). GPU release runs before soft +-- invalidate via installLoader(nil). +function SessionLifecycle.endMountedSession(version) + local Assets = require("src.render.Assets") + if Assets.releaseSession then Assets.releaseSession() end + if version then + require("src.import.CacheFs").unmountVersion(version) + end + require("src.core.Data"):unloadGenerated() + local Runtime = require("src.mods.Runtime") + if Runtime.reset then Runtime.reset() end + if Assets.installLoader then Assets.installLoader(nil) end + local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") + if okCompat and LegacyCompat.reset then LegacyCompat.reset() end +end + +-- Evict every save-editor module from package.loaded without a hardcoded +-- panel whitelist. Flat require names (App, Party, …) resolve under +-- tools/save-editor/; path-style keys may also appear. +local function flushEditorPackageLoaded() + local fs = love and love.filesystem + local function isEditorFlat(name) + if not (fs and fs.getInfo) then return false end + if name:find("[./]") then return false end + return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil + or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil + end + for k in pairs(package.loaded) do + if type(k) == "string" + and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then + package.loaded[k] = nil + end + end +end + +function SessionLifecycle.endEditorSession(opts) + opts = opts or {} + if opts.app and opts.app.unload then pcall(opts.app.unload) end + flushEditorPackageLoaded() + if opts.version then + SessionLifecycle.endMountedSession(opts.version) + end +end + +-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the +-- live game instance so map/GPU holders are gone before endMountedSession. +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) + end + if package.loaded["src.core.DiscordPresence"] then + pcall(package.loaded["src.core.DiscordPresence"].shutdown) + end + if package.loaded["src.core.gen2.Clock"] then + pcall(package.loaded["src.core.gen2.Clock"].shutdown) + end + if package.loaded["src.net.Gen1Tls"] then + pcall(package.loaded["src.net.Gen1Tls"].shutdown) + end + if love.audio and love.audio.stop then + pcall(love.audio.stop) + end + pcall(function() require("src.render.SecondScreen").setEnabled(false) end) + + if game and game.reset then + pcall(function() game:reset() end) + end + + local Input = require("src.core.Input") + local TouchControls = require("src.core.TouchControls") + Input:reset() + TouchControls:reset() +end + +function SessionLifecycle.endProcess() + for _, fn in ipairs(processShutdowns) do pcall(fn) end +end + +return SessionLifecycle diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 2a987125..9e8562cf 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -227,7 +227,9 @@ function Fetch.shutdown() end for _, th in ipairs(workers) do pcall(function() th:wait() end) end workers = {} - cmdCh, resCh, quitCh, ready = nil, nil, nil, false + cmdCh, resCh, quitCh, ready = nil, nil, nil, nil end +require("src.core.SessionLifecycle").registerProcessShutdown(Fetch.shutdown) + return Fetch diff --git a/src/render/Assets.lua b/src/render/Assets.lua index afcc577b..3469d0f1 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -14,6 +14,8 @@ local Assets = {} local cache = {} -- downstream caches that must empty when the search path changes local invalidators = {} +-- optional GPU release hooks for session end (never run on hot reload flush) +local releasers = {} -- The loader bridge: overrideOrder() yields mods highest-priority-first -- and derivedPath(rel) yields an existing save/mod-derived//. @@ -68,8 +70,18 @@ function Assets.imageData(path) return love.image.newImageData(Assets.resolve(path)) end -function Assets.register(invalidate) - invalidators[#invalidators + 1] = invalidate +-- Register a cache invalidator, or { invalidate = fn, release = fn } when a +-- module caches LOVE Images/Canvases and can eagerly free them at session end. +-- release is optional and is NOT run on flush/invalidate (HotReload safe). +-- MapLoader is the canonical split-hook example: invalidateAll clears tables +-- without GPU release; releaseAll evicts every resident map renderer. +function Assets.register(hooks) + if type(hooks) == "function" then + invalidators[#invalidators + 1] = hooks + return + end + if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end + if hooks.release then releasers[#releasers + 1] = hooks.release end end -- hot reload's single entry point (20-developer-tooling): drop the central @@ -82,6 +94,17 @@ end Assets.flush = Assets.invalidate +-- In-process return-to-launcher / editor close: release central Images and +-- run release hooks only. Does not call invalidate hooks (MapLoader must +-- keep invalidateAll separate from releaseAll). +function Assets.releaseSession() + for _, img in pairs(cache) do + if img and img.release then pcall(img.release, img) end + end + cache = {} + for _, fn in ipairs(releasers) do pcall(fn) end +end + -- Loader:load hands over the live mod set once the merge is done. Load -- order is priority ascending, so the search walks it backwards: the mod -- that wins the record merge wins the asset lookup too. diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5ed14d47..08ddbe21 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -117,6 +117,8 @@ function Renderer:releaseCanvases() self.worldOverride = nil end +Renderer.release = Renderer.releaseCanvases + function Renderer:init() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same diff --git a/src/update/Check.lua b/src/update/Check.lua index b918f2f3..938889e0 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -343,7 +343,9 @@ function Check.shutdown() if cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end worker, cmdCh, stateCh = nil, nil, nil - workerReady = false + workerReady = nil end +require("src.core.SessionLifecycle").registerProcessShutdown(Check.shutdown) + return Check diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua index 0452fec0..c52e3ed4 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -114,12 +114,25 @@ function MapLoader.invalidateAll() lru = {} end +-- Eagerly release every resident map's TileRenderer GPU objects. Only safe +-- when nothing live holds map instances (session end after game:reset). +function MapLoader.releaseAll() + local ids = {} + for id in pairs(cache) do ids[#ids + 1] = id end + for _, id in ipairs(ids) do MapLoader.evict(id) end +end + -- kept as the pre-v2 name MapLoader.clearCache = MapLoader.invalidateAll -- the cached Map objects own the per-map TileRenderer instances, so a flush -- that skipped this one would leave live SpriteBatches built from the old --- search path (14 cache-invalidation contract, rows 1 and 3) -Assets.register(MapLoader.invalidateAll) +-- search path (14 cache-invalidation contract, rows 1 and 3). invalidateAll +-- deliberately does NOT release GPU (hot reload / live overworld); releaseAll +-- is the session-end path only. +Assets.register({ + invalidate = MapLoader.invalidateAll, + release = MapLoader.releaseAll, +}) return MapLoader diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e0391b3f..1f02377d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -228,6 +228,11 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH) return out end +function OverworldState:exit() + self.map = nil + self.neighbors = nil +end + function OverworldState:enter(mapId, x, y, facing, opts) Game = require("src.core.Game") Game.overworld = self diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ef84997a..ec83fa7b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -5257,6 +5257,37 @@ function World:dropMapImages(mapId) if self.connectionMaps then self.connectionMaps[mapId] = nil end end +local function safeRelease(obj) + if obj and obj ~= false and obj.release then pcall(obj.release, obj) end +end + +-- Eagerly free session-owned GPU caches. Assets.image-backed atlases are +-- nilled without release; unique bakes, strips, and roof composites are released. +function World:release() + if self.mapImages then + for _, img in pairs(self.mapImages) do safeRelease(img) end + self.mapImages = {} + end + if self.scrollStrips then + for _, strip in pairs(self.scrollStrips) do safeRelease(strip) end + self.scrollStrips = {} + end + safeRelease(self.tiltCanvas) + self.tiltCanvas = nil + if self.grassAtlases then + for _, atlas in pairs(self.grassAtlases) do safeRelease(atlas) end + self.grassAtlases = {} + end + if self.atlasCache then + for key, atlas in pairs(self.atlasCache) do + if key:find("|", 1, true) then safeRelease(atlas) end + end + self.atlasCache = {} + end + self.animQuads = nil + self.connectionMaps = nil +end + -- LoadMapAttributes' refill, for every map the session has edited. Neighbour -- strips share the same buffer on the cart, so a connection crossing reloads -- them too: this runs on any setMap, seamless or not. diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 4033cbed..efe7a839 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper, local mainFile = assert(io.open("main.lua", "rb")) local main = mainFile:read("*a") mainFile:close() -check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), - "returning from a game disables mod-owned secondary output") +check(main:find("SessionLifecycle.endGameSession", 1, true), + "returning from a game goes through SessionLifecycle.endGameSession") +local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb")) +local lifecycle = lifecycleFile:read("*a") +lifecycleFile:close() +check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), + "endGameSession disables mod-owned secondary output") check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index 6ceee3e2..e63d3fd7 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -1,5 +1,5 @@ -- In-process launcher session teardown: Game:reset, Renderer canvas release, --- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush. +-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush. -- luajit tests/engine/launcher_session_teardown_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime") local Assets = require("src.render.Assets") local LegacyCompat = require("src.mods.LegacyCompat") local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") local Renderer = require("src.render.Renderer") local StateStack = require("src.core.StateStack") +local SessionLifecycle = require("src.core.SessionLifecycle") +local MapLoader = require("src.world.MapLoader") +local World = require("src.world.gen2.World") -- ---- Game:reset drops instance state, keeps methods ---------------------- do @@ -38,6 +42,35 @@ do check(StateStack:top() == nil, "Game:reset cleared the shared StateStack") end +-- ---- Game2:reset releases world GPU and present canvases ------------------ +do + local game2 = Game2.new() + local canvas = love.graphics.newCanvas(4, 4) + game2.world = World.new({}) + game2.world.mapImages = { ["MAP|d|1"] = canvas } + game2._canvases = { love.graphics.newCanvas(8, 8) } + game2:reset() + check(canvas.released == true, "Game2:reset releases World mapImages") + check(game2.world == nil, "Game2:reset clears world reference") + check(game2._canvases == nil, "Game2:reset clears _canvases") +end + +-- ---- World:release frees owned GPU caches -------------------------------- +do + local world = World.new({}) + local bake = love.graphics.newCanvas(16, 16) + local strip = love.graphics.newCanvas(8, 64) + local tilt = love.graphics.newCanvas(160, 144) + world.mapImages = { ["R1|DAY|1"] = bake } + world.scrollStrips = { ["TS|1|0,0"] = strip } + world.tiltCanvas = tilt + world:release() + check(bake.released == true, "World:release frees map bake canvases") + check(strip.released == true, "World:release frees scroll strips") + check(tilt.released == true, "World:release frees tiltCanvas") + eq(next(world.mapImages), nil, "World:release clears mapImages table") +end + -- ---- Renderer:init releases prior canvases before realloc ---------------- do local first = love.graphics.newCanvas(16, 16) @@ -52,14 +85,13 @@ do "Renderer:init allocates a fresh primary canvas") check(Renderer.canvas.released ~= true, "the new primary canvas is not released") - -- second init also releases the one just created local second = Renderer.canvas Renderer:init() check(second.released == true, "a second Renderer:init releases the canvas from the prior init") end --- ---- Shared singleton teardown contract (closeEditor / returnToLauncher) +-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) -- do Runtime.install({ emit = function() end }, { call = function() end }, { "e" }) Assets.installLoader({ @@ -68,44 +100,79 @@ do }) LegacyCompat.reports = { some_mod = { order = {} } } - -- Mirrors main.lua teardownMountedSession without mounting CacheFs. - require("src.core.Data"):unloadGenerated() - Runtime.reset() - Assets.installLoader(nil) - LegacyCompat.reset() + SessionLifecycle.endMountedSession(nil) - check(Runtime.errors == nil, "teardown clears Runtime.errors") - check(Assets.loader == nil, "teardown clears Assets.loader") - eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports") + check(Runtime.errors == nil, "endMountedSession clears Runtime.errors") + check(Assets.loader == nil, "endMountedSession clears Assets.loader") + eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports") end --- ---- Editor package.loaded discovery flush (no panel whitelist) --------- +-- ---- releaseSession empties MapLoader via releaseAll, not flush ------------- +do + local data = { + maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1, + blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } }, + tilesets = { TS = { id = "TS", image = "assets/generated/t.png", + walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } }, + tilesPerRow = 1 } }, + } + MapLoader.load(data, "T1") + check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release") + Assets.releaseSession() + check(MapLoader.cached("T1") == nil, + "releaseSession evicts MapLoader via releaseAll") +end + +-- ---- Editor package.loaded discovery flush via endEditorSession ----------- do love.filesystem.write("tools/save-editor/App.lua", "return {}") love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}") package.loaded["App"] = { stale = true } package.loaded["NewPanel"] = { stale = true } - package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep + package.loaded["src.core.Data"] = package.loaded["src.core.Data"] - local function isEditorFlat(name) - if name:find("[./]") then return false end - return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil - or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil - end - for k in pairs(package.loaded) do - if type(k) == "string" - and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then - package.loaded[k] = nil - end - end + SessionLifecycle.endEditorSession({ version = nil, app = nil }) - check(package.loaded["App"] == nil, "discovery flush drops flat App") + check(package.loaded["App"] == nil, "endEditorSession drops flat App") check(package.loaded["NewPanel"] == nil, - "discovery flush drops a new panel without a hardcoded list") + "endEditorSession drops a new panel without a hardcoded list") check(package.loaded["src.core.Data"] ~= nil, - "discovery flush leaves engine modules alone") + "endEditorSession leaves engine modules alone") love.filesystem.remove("tools/save-editor/App.lua") love.filesystem.remove("tools/save-editor/panels/NewPanel.lua") end +-- ---- Fetch shutdown clears ready so Play-again can respawn workers --------- +do + local Fetch = require("src.net.Fetch") + local spawnAttempts = 0 + love.thread = love.thread or {} + local savedNewThread = love.thread.newThread + local savedGetChannel = love.thread.getChannel + love.thread.getChannel = function() + return { + clear = function() end, + push = function() end, + pop = function() return nil end, + demand = function() end, + } + end + love.thread.newThread = function() + spawnAttempts = spawnAttempts + 1 + return { + start = function() end, + wait = function() end, + getError = function() return nil end, + } + end + Fetch.available() + local afterFirst = spawnAttempts + Fetch.shutdown() + Fetch.available() + check(spawnAttempts > afterFirst, + "Fetch.available retries worker spawn after shutdown (ready=nil)") + love.thread.newThread = savedNewThread + love.thread.getChannel = savedGetChannel +end + T.finish("launcher_session_teardown_test") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35b..4f8a3589 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil, local mainSrc = source("main.lua") local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n") check(quitHook ~= nil, "love.quit is still a single top-level function") -quitHook = quitHook or "" -check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil, - "love.quit shuts the chip worker down") -check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil, - "love.quit shuts the update worker down") +check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil, + "love.quit shuts workers down via SessionLifecycle.endProcess") + +local lifecycleSrc = source("src/core/SessionLifecycle.lua") +check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil, + "SessionLifecycle exposes registerProcessShutdown") +check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil, + "SessionLifecycle.endProcess fans out registered hooks") +check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil, + "ChipAudio registers its shutdown hook at load") +check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil, + "Check registers its shutdown hook at load") +check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, + "Fetch registers its shutdown hook at load") -- 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 diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 5b9ff89d..f60ac7d1 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -218,8 +218,12 @@ for name, module in pairs({ Assets = Assets, TileRenderer = TileRenderer, end check(type(require("src.world.MapLoader").invalidateAll) == "function", "MapLoader keeps its wave-1 invalidateAll") +check(type(require("src.world.MapLoader").releaseAll) == "function", + "MapLoader exposes releaseAll for session end") check(type(require("src.core.Sound").invalidate) == "function", "Sound keeps its wave-1 invalidate") +check(type(Assets.releaseSession) == "function", + "Assets exposes releaseSession for in-process session end") -- the central cache hands back one image per resolved path, and flush -- fans out to every registered downstream cache @@ -240,6 +244,14 @@ Assets.register(function() reached = true end) Assets.flush() check(reached, "a throwing invalidator does not stop the fan-out") +-- flush/invalidate must not run release hooks (HotReload / live overworld safe) +local releaseCalls = 0 +Assets.register({ release = function() releaseCalls = releaseCalls + 1 end }) +Assets.flush() +check(releaseCalls == 0, "flush() does not call release hooks") +Assets.releaseSession() +check(releaseCalls == 1, "releaseSession() calls registered release hooks") + -- ------- animated tiles as tileset data local overworld = TileRenderer.defaultAnimatedTiles(