mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
Refactor session management and resource cleanup
- Replaced `teardownMountedSession` and `flushEditorPackageLoaded` with a unified `SessionLifecycle` approach for managing session transitions and resource cleanup. - Implemented `SessionLifecycle.endEditorSession` and `SessionLifecycle.endGameSession` to streamline the teardown process for editor and game sessions. - Introduced `Assets.releaseSession` to handle GPU resource release at session end, ensuring efficient memory management. - Updated `Game` and `Game2` reset methods to include world and canvas resource releases. - Enhanced `MapLoader` with a new `releaseAll` method for eager GPU cache cleanup. - Added tests to verify the new session lifecycle functionality and resource management. This commit improves the stability and performance of in-process transitions, particularly during editor and game session changes. further improves and addresses #1662 specifically around android gc pressure.
This commit is contained in:
@@ -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
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -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
|
||||
|
||||
+25
-2
@@ -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/<id>/<rel>.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user