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:
1jamie
2026-08-23 10:46:19 -05:00
parent e88f2ef060
commit 5807b8d836
16 changed files with 353 additions and 131 deletions
+3 -1
View File
@@ -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
-- ---------------------------------------------------------------------------
+13
View File
@@ -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
+16
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+31
View File
@@ -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.