memory tooling, state handling changes
This commit is contained in:
668
modules/MemoryScanner.lua
Normal file
668
modules/MemoryScanner.lua
Normal file
@@ -0,0 +1,668 @@
|
||||
---@class MemoryScanner
|
||||
---@field _StateManager table
|
||||
---@field _Context table
|
||||
---@field _ImageCache table
|
||||
---@field _ErrorHandler table
|
||||
local MemoryScanner = {}
|
||||
|
||||
---Initialize MemoryScanner with dependencies
|
||||
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
|
||||
function MemoryScanner.init(deps)
|
||||
MemoryScanner._StateManager = deps.StateManager
|
||||
MemoryScanner._Context = deps.Context
|
||||
MemoryScanner._ImageCache = deps.ImageCache
|
||||
MemoryScanner._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
---Count items in a table
|
||||
---@param tbl table
|
||||
---@return number
|
||||
local function countTable(tbl)
|
||||
local count = 0
|
||||
for _ in pairs(tbl) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
---Calculate memory size estimate for a table (recursive)
|
||||
---@param tbl table
|
||||
---@param visited table? Tracking table to prevent circular references
|
||||
---@param depth number? Current recursion depth
|
||||
---@return number bytes Estimated memory usage in bytes
|
||||
local function estimateTableSize(tbl, visited, depth)
|
||||
if type(tbl) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
|
||||
visited = visited or {}
|
||||
depth = depth or 0
|
||||
|
||||
-- Limit recursion depth to prevent stack overflow
|
||||
if depth > 10 then
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Check for circular references
|
||||
if visited[tbl] then
|
||||
return 0
|
||||
end
|
||||
visited[tbl] = true
|
||||
|
||||
local size = 40 -- Base table overhead (approximate)
|
||||
|
||||
for k, v in pairs(tbl) do
|
||||
-- Key size
|
||||
if type(k) == "string" then
|
||||
size = size + #k + 24 -- String overhead
|
||||
elseif type(k) == "number" then
|
||||
size = size + 8
|
||||
else
|
||||
size = size + 8 -- Reference
|
||||
end
|
||||
|
||||
-- Value size
|
||||
if type(v) == "string" then
|
||||
size = size + #v + 24
|
||||
elseif type(v) == "number" then
|
||||
size = size + 8
|
||||
elseif type(v) == "boolean" then
|
||||
size = size + 4
|
||||
elseif type(v) == "table" then
|
||||
size = size + estimateTableSize(v, visited, depth + 1)
|
||||
elseif type(v) == "function" then
|
||||
size = size + 16 -- Function reference
|
||||
else
|
||||
size = size + 8 -- Other references
|
||||
end
|
||||
end
|
||||
|
||||
return size
|
||||
end
|
||||
|
||||
---Scan StateManager for memory issues
|
||||
---@return table report Detailed report of StateManager memory usage
|
||||
function MemoryScanner.scanStateManager()
|
||||
local report = {
|
||||
stateCount = 0,
|
||||
stateStoreSize = 0,
|
||||
metadataSize = 0,
|
||||
callSiteCounterSize = 0,
|
||||
orphanedStates = {},
|
||||
staleStates = {},
|
||||
largeStates = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._StateManager then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "StateManager not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
local stateStore = internal.stateStore
|
||||
local stateMetadata = internal.stateMetadata
|
||||
local callSiteCounters = internal.callSiteCounters
|
||||
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
|
||||
|
||||
-- Count states
|
||||
report.stateCount = countTable(stateStore)
|
||||
|
||||
-- Estimate sizes
|
||||
report.stateStoreSize = estimateTableSize(stateStore)
|
||||
report.metadataSize = estimateTableSize(stateMetadata)
|
||||
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
|
||||
|
||||
-- Check for orphaned states (metadata without state)
|
||||
for id, _ in pairs(stateMetadata) do
|
||||
if not stateStore[id] then
|
||||
table.insert(report.orphanedStates, id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for stale states (not accessed in many frames)
|
||||
local staleThreshold = 120 -- 2 seconds at 60fps
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = currentFrame - meta.lastFrame
|
||||
if framesSinceAccess > staleThreshold then
|
||||
table.insert(report.staleStates, {
|
||||
id = id,
|
||||
framesSinceAccess = framesSinceAccess,
|
||||
createdFrame = meta.createdFrame,
|
||||
accessCount = meta.accessCount,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for large states (may indicate memory bloat)
|
||||
for id, state in pairs(stateStore) do
|
||||
local stateSize = estimateTableSize(state)
|
||||
if stateSize > 1024 then -- More than 1KB
|
||||
table.insert(report.largeStates, {
|
||||
id = id,
|
||||
size = stateSize,
|
||||
keyCount = countTable(state),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check callSiteCounters (should be near 0 after frame cleanup)
|
||||
local callSiteCount = countTable(callSiteCounters)
|
||||
if callSiteCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
|
||||
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive state count
|
||||
if report.stateCount > 500 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("High state count: %d states", report.stateCount),
|
||||
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for orphaned states
|
||||
if #report.orphanedStates > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
|
||||
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for stale states
|
||||
if #report.staleStates > 10 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
|
||||
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan Context for memory issues
|
||||
---@return table report Detailed report of Context memory usage
|
||||
function MemoryScanner.scanContext()
|
||||
local report = {
|
||||
topElementCount = 0,
|
||||
zIndexElementCount = 0,
|
||||
frameElementCount = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._Context then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "Context not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
-- Count elements
|
||||
report.topElementCount = #MemoryScanner._Context.topElements
|
||||
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
|
||||
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
|
||||
|
||||
-- Check for stale z-index elements (should be cleared each frame)
|
||||
if MemoryScanner._Context._immediateMode then
|
||||
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
|
||||
-- If it has elements outside of frame rendering, that's a leak
|
||||
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
|
||||
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for excessive element count
|
||||
if report.topElementCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("High top-level element count: %d", report.topElementCount),
|
||||
suggestion = "Consider consolidating elements or using fewer top-level containers",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan ImageCache for memory issues
|
||||
---@return table report Detailed report of ImageCache memory usage
|
||||
function MemoryScanner.scanImageCache()
|
||||
local report = {
|
||||
imageCount = 0,
|
||||
estimatedMemory = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._ImageCache then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "ImageCache not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local stats = MemoryScanner._ImageCache.getStats()
|
||||
report.imageCount = stats.count
|
||||
report.estimatedMemory = stats.memoryEstimate
|
||||
|
||||
-- Check for excessive memory usage (>100MB)
|
||||
if report.estimatedMemory > 100 * 1024 * 1024 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
|
||||
suggestion = "Consider implementing cache eviction or clearing unused images",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive image count
|
||||
if report.imageCount > 50 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("ImageCache has %d images", report.imageCount),
|
||||
suggestion = "Review if all cached images are necessary",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Check if a circular reference is intentional (parent-child, module, or metatable)
|
||||
---@param path string The current path where circular ref was detected
|
||||
---@param originalPath string The original path where the table was first seen
|
||||
---@return boolean True if this is an intentional circular reference
|
||||
local function isIntentionalCircularReference(path, originalPath)
|
||||
-- Pattern 1: child.parent points back to parent
|
||||
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
|
||||
if path:match("%.parent$") then
|
||||
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if parentPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
|
||||
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
|
||||
if originalPath:match("%.parent$") then
|
||||
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if childParentPath == path then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 3: Check for nested parent-child cycles
|
||||
-- child.children[n].parent -> child
|
||||
local segments = {}
|
||||
for segment in path:gmatch("[^.]+") do
|
||||
table.insert(segments, segment)
|
||||
end
|
||||
|
||||
-- Look for .children.N.parent pattern
|
||||
for i = 1, #segments - 2 do
|
||||
if segments[i] == "children" and segments[i + 2] == "parent" then
|
||||
-- Reconstruct path without the .children.N.parent suffix
|
||||
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
|
||||
if reconstructedPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 4: Metatable __index self-references (modules)
|
||||
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
|
||||
if path:match("%.__index$") then
|
||||
local basePath = path:match("^(.+)%.__index$")
|
||||
if basePath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 5: Shared module references (elements sharing same module instances)
|
||||
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
|
||||
-- These start with _ and are typically modules
|
||||
local pathModuleName = path:match("%.(_[%w]+)%.")
|
||||
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
|
||||
|
||||
if pathModuleName and originalModuleName then
|
||||
-- If both paths reference the same internal module (starting with _), it's intentional
|
||||
if pathModuleName == originalModuleName then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 6: Shared Color/Transform objects between elements
|
||||
-- These are value objects that can be safely shared
|
||||
if path:match("Color") and originalPath:match("Color") then
|
||||
return true
|
||||
end
|
||||
if path:match("Transform") and originalPath:match("Transform") then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pattern 7: LayoutEngine holding reference to its element
|
||||
-- Example: "element._layoutEngine.element" -> "element"
|
||||
if path:match("%._layoutEngine%.element$") then
|
||||
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
|
||||
if elementPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 8: Renderer holding references to element properties
|
||||
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
|
||||
if path:match("%._renderer%.") then
|
||||
local rendererBasePath = path:match("^(.+)%._renderer%.")
|
||||
local originalBasePath = originalPath:match("^(.+)%.")
|
||||
if rendererBasePath == originalBasePath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 9: Context reference from layout engine (shared singleton)
|
||||
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
|
||||
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
---Detect circular references in a table
|
||||
---@param tbl table Table to check
|
||||
---@param path string? Current path (for reporting)
|
||||
---@param visited table? Tracking table
|
||||
---@return table[] circularRefs Array of circular reference paths
|
||||
---@return table[] intentionalRefs Array of intentional parent-child refs
|
||||
local function detectCircularReferences(tbl, path, visited)
|
||||
if type(tbl) ~= "table" then
|
||||
return {}, {}
|
||||
end
|
||||
|
||||
path = path or "root"
|
||||
visited = visited or {}
|
||||
local circularRefs = {}
|
||||
local intentionalRefs = {}
|
||||
|
||||
-- Check if we've seen this table before
|
||||
if visited[tbl] then
|
||||
local ref = {
|
||||
path = path,
|
||||
originalPath = visited[tbl],
|
||||
}
|
||||
|
||||
-- Determine if this is an intentional circular reference
|
||||
if isIntentionalCircularReference(path, visited[tbl]) then
|
||||
table.insert(intentionalRefs, ref)
|
||||
else
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
-- Mark as visited
|
||||
visited[tbl] = path
|
||||
|
||||
-- Recursively check children
|
||||
for k, v in pairs(tbl) do
|
||||
if type(v) == "table" then
|
||||
local childPath = path .. "." .. tostring(k)
|
||||
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
|
||||
for _, ref in ipairs(childRefs) do
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
for _, ref in ipairs(childIntentionalRefs) do
|
||||
table.insert(intentionalRefs, ref)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
---Scan for circular references in immediate mode
|
||||
---@return table report Detailed report of circular references
|
||||
function MemoryScanner.scanCircularReferences()
|
||||
local report = {
|
||||
stateStoreCircularRefs = {},
|
||||
stateStoreIntentionalRefs = {},
|
||||
contextCircularRefs = {},
|
||||
contextIntentionalRefs = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if MemoryScanner._StateManager then
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs = detectCircularReferences(internal.stateStore, "stateStore")
|
||||
end
|
||||
|
||||
if MemoryScanner._Context then
|
||||
report.contextCircularRefs, report.contextIntentionalRefs = detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
|
||||
end
|
||||
|
||||
-- Report issues only for cross-module circular references
|
||||
if #report.stateStoreCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("Found %d cross-module circular references in StateManager", #report.stateStoreCircularRefs),
|
||||
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
if #report.contextCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
|
||||
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Run comprehensive memory scan
|
||||
---@return table report Complete memory analysis report
|
||||
function MemoryScanner.scan()
|
||||
local startMemory = collectgarbage("count")
|
||||
|
||||
local report = {
|
||||
timestamp = os.time(),
|
||||
startMemory = startMemory / 1024, -- MB
|
||||
stateManager = MemoryScanner.scanStateManager(),
|
||||
context = MemoryScanner.scanContext(),
|
||||
imageCache = MemoryScanner.scanImageCache(),
|
||||
circularRefs = MemoryScanner.scanCircularReferences(),
|
||||
summary = {
|
||||
totalIssues = 0,
|
||||
criticalIssues = 0,
|
||||
warnings = 0,
|
||||
info = 0,
|
||||
},
|
||||
}
|
||||
|
||||
-- Count issues by severity
|
||||
local function countIssues(subReport)
|
||||
for _, issue in ipairs(subReport.issues or {}) do
|
||||
report.summary.totalIssues = report.summary.totalIssues + 1
|
||||
if issue.severity == "error" then
|
||||
report.summary.criticalIssues = report.summary.criticalIssues + 1
|
||||
elseif issue.severity == "warning" then
|
||||
report.summary.warnings = report.summary.warnings + 1
|
||||
elseif issue.severity == "info" then
|
||||
report.summary.info = report.summary.info + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
countIssues(report.stateManager)
|
||||
countIssues(report.context)
|
||||
countIssues(report.imageCache)
|
||||
countIssues(report.circularRefs)
|
||||
|
||||
-- Force GC and measure freed memory
|
||||
local beforeGC = collectgarbage("count")
|
||||
collectgarbage("collect")
|
||||
collectgarbage("collect")
|
||||
local afterGC = collectgarbage("count")
|
||||
|
||||
report.gcAnalysis = {
|
||||
beforeGC = beforeGC / 1024, -- MB
|
||||
afterGC = afterGC / 1024, -- MB
|
||||
freed = (beforeGC - afterGC) / 1024, -- MB
|
||||
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
|
||||
}
|
||||
|
||||
-- Analyze GC effectiveness
|
||||
if report.gcAnalysis.freedPercent < 5 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "info",
|
||||
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Most memory is still referenced - this is normal if UI is active",
|
||||
})
|
||||
elseif report.gcAnalysis.freedPercent > 30 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Format report as human-readable string
|
||||
---@param report table Memory scan report
|
||||
---@return string formatted Formatted report
|
||||
function MemoryScanner.formatReport(report)
|
||||
local lines = {}
|
||||
|
||||
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
|
||||
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
|
||||
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Summary
|
||||
table.insert(lines, "--- Summary ---")
|
||||
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
|
||||
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
|
||||
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
|
||||
table.insert(lines, string.format(" Info: %d", report.summary.info))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- StateManager
|
||||
table.insert(lines, "--- StateManager ---")
|
||||
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
|
||||
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
|
||||
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
|
||||
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
|
||||
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
|
||||
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
|
||||
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
|
||||
|
||||
if #report.stateManager.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.stateManager.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Context
|
||||
table.insert(lines, "--- Context ---")
|
||||
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
|
||||
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
|
||||
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
|
||||
|
||||
if #report.context.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.context.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- ImageCache
|
||||
table.insert(lines, "--- ImageCache ---")
|
||||
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
|
||||
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
|
||||
|
||||
if #report.imageCache.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.imageCache.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Circular References
|
||||
table.insert(lines, "--- Circular References ---")
|
||||
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
|
||||
table.insert(lines, string.format("StateStore (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.stateStoreIntentionalRefs))
|
||||
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
|
||||
table.insert(lines, string.format("Context (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.contextIntentionalRefs))
|
||||
|
||||
if #report.circularRefs.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.circularRefs.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
else
|
||||
table.insert(lines, " ✓ No unexpected circular references detected")
|
||||
end
|
||||
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
|
||||
table.insert(lines, "")
|
||||
|
||||
-- GC Analysis
|
||||
table.insert(lines, "--- Garbage Collection Analysis ---")
|
||||
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
|
||||
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
|
||||
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
|
||||
table.insert(lines, "")
|
||||
|
||||
table.insert(lines, "=== End Report ===")
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
---Save report to file
|
||||
---@param report table Memory scan report
|
||||
---@param filename string? Output filename (default: memory_report.txt)
|
||||
function MemoryScanner.saveReport(report, filename)
|
||||
filename = filename or "memory_report.txt"
|
||||
local formatted = MemoryScanner.formatReport(report)
|
||||
|
||||
local file = io.open(filename, "w")
|
||||
if file then
|
||||
file:write(formatted)
|
||||
file:close()
|
||||
print(string.format("[MemoryScanner] Report saved to %s", filename))
|
||||
else
|
||||
print(string.format("[MemoryScanner] Failed to save report to %s", filename))
|
||||
end
|
||||
end
|
||||
|
||||
return MemoryScanner
|
||||
Reference in New Issue
Block a user