Files
ComfyUI_frontend/test/subgraph/SubgraphMemory.test.ts
2025-07-15 10:11:08 -07:00

713 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from "vitest"
import { LGraph } from "@/litegraph"
import { SubgraphNode } from "@/subgraph/SubgraphNode"
// Note: Avoiding createMemoryLeakTest as it relies on non-deterministic GC behavior
import { subgraphTest } from "./fixtures/subgraphFixtures"
import {
createEventCapture,
createTestSubgraph,
createTestSubgraphNode,
} from "./fixtures/subgraphHelpers"
// Mock WeakRef to detect if objects can be garbage collected
declare global {
const gc: (() => void) | undefined
}
describe("SubgraphNode Memory Management", () => {
describe("Event Listener Cleanup", () => {
it("should register event listeners on construction", () => {
const subgraph = createTestSubgraph()
// Spy on addEventListener to track listener registration
const addEventSpy = vi.spyOn(subgraph.events, "addEventListener")
const initialCalls = addEventSpy.mock.calls.length
createTestSubgraphNode(subgraph)
// Should have registered listeners for subgraph events
expect(addEventSpy.mock.calls.length).toBeGreaterThan(initialCalls)
// Should have registered listeners for all major events
const eventTypes = addEventSpy.mock.calls.map(call => call[0])
expect(eventTypes).toContain("input-added")
expect(eventTypes).toContain("removing-input")
expect(eventTypes).toContain("output-added")
expect(eventTypes).toContain("removing-output")
expect(eventTypes).toContain("renaming-input")
expect(eventTypes).toContain("renaming-output")
})
it("CRITICAL: should clean up input listeners on removal", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "input1", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Add input should have created listeners
expect(subgraphNode.inputs[0]._listenerController).toBeDefined()
expect(subgraphNode.inputs[0]._listenerController?.signal.aborted).toBe(false)
// Call onRemoved to simulate node removal
subgraphNode.onRemoved()
// Input listeners should be aborted
expect(subgraphNode.inputs[0]._listenerController?.signal.aborted).toBe(true)
})
it("CRITICAL: main subgraph event listeners are NOT cleaned up (MEMORY LEAK)", () => {
const subgraph = createTestSubgraph()
// Track listener registration
const removeEventSpy = vi.spyOn(subgraph.events, "removeEventListener")
const addEventSpy = vi.spyOn(subgraph.events, "addEventListener")
const initialAddCalls = addEventSpy.mock.calls.length
const subgraphNode = createTestSubgraphNode(subgraph)
const addCallsAfterCreate = addEventSpy.mock.calls.length
// Verify listeners were added
expect(addCallsAfterCreate).toBeGreaterThan(initialAddCalls)
// Call onRemoved
subgraphNode.onRemoved()
// CRITICAL BUG: Main subgraph event listeners are NOT removed
// onRemoved only cleans up input-specific listeners, not the main ones
expect(removeEventSpy).not.toHaveBeenCalled()
// This proves the memory leak exists
console.warn("MEMORY LEAK CONFIRMED: SubgraphNode event listeners not cleaned up on removal")
})
it("should not accumulate listeners during reconfiguration", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "input1", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
const addEventSpy = vi.spyOn(subgraph.events, "addEventListener")
const initialCalls = addEventSpy.mock.calls.length
// Reconfigure multiple times
for (let i = 0; i < 5; i++) {
subgraphNode.configure({
id: subgraphNode.id,
type: subgraph.id,
pos: [100 * i, 100 * i],
size: [200, 100],
inputs: [],
outputs: [],
properties: {},
flags: {},
mode: 0,
})
}
// Should not add new main subgraph listeners
// (Only input-specific listeners might be reconfigured)
const finalCalls = addEventSpy.mock.calls.length
expect(finalCalls).toBe(initialCalls) // Main listeners not re-added
})
it("should demonstrate memory leak with multiple instances (limited scope)", () => {
const subgraph = createTestSubgraph()
// Track total listener count
const addEventSpy = vi.spyOn(subgraph.events, "addEventListener")
let totalListenersAdded = 0
// Create and "remove" multiple instances (limited to 3 for test performance)
const instances: SubgraphNode[] = []
for (let i = 0; i < 3; i++) {
const initialCalls = addEventSpy.mock.calls.length
const instance = createTestSubgraphNode(subgraph, { id: i })
instances.push(instance)
const callsAfterCreate = addEventSpy.mock.calls.length
totalListenersAdded += (callsAfterCreate - initialCalls)
// Simulate removal (but listeners won't be cleaned up)
instance.onRemoved()
}
// All listeners are still registered even though nodes are "removed"
expect(totalListenersAdded).toBeGreaterThan(0)
// Document the leak without excessive accumulation
console.warn(`Memory leak demonstrated: ${totalListenersAdded} listeners accumulated from ${instances.length} instances`)
// IMPORTANT: This test intentionally creates a small memory leak to demonstrate the bug.
// In production, this would accumulate over time and cause performance issues.
// The leak is limited to 3 instances to minimize test suite impact.
})
})
describe("Memory Leak Prevention Tests", () => {
it("verifies proper cleanup workflow exists", () => {
// This test documents the expected cleanup workflow without relying on GC
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
// Track cleanup state
const cleanupState = {
inputListenersCleanedUp: false,
mainListenersCleanedUp: false,
widgetReferencesCleared: false,
}
// Check if input listeners exist and are set up
const input = subgraphNode.inputs[0]
// Call cleanup
subgraphNode.onRemoved()
// Verify what gets cleaned up
if (input && "_listenerController" in input && (input as any)._listenerController) {
cleanupState.inputListenersCleanedUp = (input as any)._listenerController.signal.aborted === true
expect(cleanupState.inputListenersCleanedUp).toBe(true) // This works when input exists
} else {
// If no input or no listener controller, that's also valid
cleanupState.inputListenersCleanedUp = true
expect(cleanupState.inputListenersCleanedUp).toBe(true)
}
// TODO: These should be true after proper implementation
// expect(cleanupState.mainListenersCleanedUp).toBe(true)
// expect(cleanupState.widgetReferencesCleared).toBe(true)
// This test serves as documentation for what needs to be implemented
})
it("should clean up widget references properly", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "input1", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate widget promotion
const input = subgraphNode.inputs[0]
input._widget = {
type: "number",
name: "test_widget",
value: 42,
draw: vi.fn(),
mouse: vi.fn(),
computeSize: vi.fn(),
createCopyForNode: vi.fn(),
}
input.widget = { name: "test_widget" }
expect(input._widget).toBeDefined()
expect(input.widget).toBeDefined()
// Removal should clean up widget references
subgraphNode.onRemoved()
// Input-specific listeners are cleaned up, but widget refs may remain
// This tests the current behavior
expect(input._listenerController?.signal.aborted).toBe(true)
})
it("should handle rapid creation/destruction cycles", () => {
const subgraph = createTestSubgraph()
// Simulate rapid creation/destruction that might happen in UI
const instances: SubgraphNode[] = []
for (let cycle = 0; cycle < 10; cycle++) {
// Create instance
const instance = createTestSubgraphNode(subgraph, { id: cycle })
instances.push(instance)
// Add to graph
const parentGraph = new LGraph()
parentGraph.add(instance)
// Remove from graph
parentGraph.remove(instance)
instance.onRemoved()
}
// All instances have been "removed" but event listeners remain
// This demonstrates the accumulation problem
expect(instances).toHaveLength(10)
// Each instance still holds references through event listeners
// In a real app, this would cause memory growth over time
})
})
describe("Widget Promotion Memory Management", () => {
it("should clean up promoted widget references", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "testInput", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate widget promotion scenario
const input = subgraphNode.inputs[0]
const mockWidget = {
type: "number",
name: "promoted_widget",
value: 123,
draw: vi.fn(),
mouse: vi.fn(),
computeSize: vi.fn(),
createCopyForNode: vi.fn().mockReturnValue({
type: "number",
name: "promoted_widget",
value: 123,
}),
}
// Simulate widget promotion
input._widget = mockWidget
input.widget = { name: "promoted_widget" }
subgraphNode.widgets.push(mockWidget)
expect(input._widget).toBe(mockWidget)
expect(input.widget).toBeDefined()
expect(subgraphNode.widgets).toContain(mockWidget)
// Remove widget (this should clean up references)
subgraphNode.removeWidget(mockWidget)
// Widget should be removed from array
expect(subgraphNode.widgets).not.toContain(mockWidget)
// References should be cleaned up (testing current implementation)
// Note: The PR #1107 fix should clean these up
})
it("should not leak widgets during reconfiguration", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "input1", type: "number" }],
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Track widget count before and after reconfigurations
const initialWidgetCount = subgraphNode.widgets.length
// Reconfigure multiple times
for (let i = 0; i < 3; i++) {
subgraphNode.configure({
id: subgraphNode.id,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {},
flags: {},
mode: 0,
})
}
// Widget count should not accumulate
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
})
})
describe("Memory Leak Documentation", () => {
it("documents the known memory leak pattern", () => {
// This test documents the exact memory leak pattern for future fixes
const subgraph = createTestSubgraph()
const eventCapture = createEventCapture(subgraph.events, [
"input-added",
"output-added",
"removing-input",
"removing-output",
"renaming-input",
"renaming-output",
])
// Create SubgraphNode - this registers 6 event listeners
const subgraphNode = createTestSubgraphNode(subgraph)
// The memory leak occurs here:
// 1. SubgraphNode constructor (lines 52-92) adds event listeners
// 2. These create strong references: subgraph.events -> SubgraphNode
// 3. onRemoved() method (lines 302-306) only cleans input listeners
// 4. Main subgraph event listeners are NEVER removed
expect(subgraphNode).toBeDefined()
// Cleanup test resources
eventCapture.cleanup()
console.log("Memory leak pattern documented: SubgraphNode event listeners persist after removal")
})
it("provides solution blueprint for memory leak fix", () => {
// This test shows what the fix should look like
const subgraph = createTestSubgraph()
// SOLUTION: SubgraphNode should use AbortController pattern
//
// In constructor:
// this.eventAbortController = new AbortController()
// const signal = this.eventAbortController.signal
// subgraphEvents.addEventListener("input-added", handler, { signal })
//
// In onRemoved():
// this.eventAbortController.abort() // Removes ALL listeners
const subgraphNode = createTestSubgraphNode(subgraph)
// Current implementation only cleans input listeners
subgraphNode.onRemoved()
// TODO: Implement AbortController pattern to fix memory leak
expect(true).toBe(true) // Placeholder - documents intended solution
})
})
})
describe("Performance Impact of Memory Leak", () => {
it("measures event handler overhead with multiple instances", () => {
const subgraph = createTestSubgraph()
// Create multiple instances (reduced from 50 to 20 for test efficiency)
const instances: SubgraphNode[] = []
const startTime = performance.now()
for (let i = 0; i < 20; i++) {
instances.push(createTestSubgraphNode(subgraph, { id: i }))
}
const creationTime = performance.now() - startTime
// Trigger an event that will call ALL accumulated listeners
const eventStartTime = performance.now()
subgraph.addInput("performance_test", "number")
const eventTime = performance.now() - eventStartTime
console.log(`Created ${instances.length} instances in ${creationTime.toFixed(2)}ms`)
console.log(`Event dispatch took ${eventTime.toFixed(2)}ms (${instances.length} listeners)`)
// More instances = more event listeners = slower event handling
// This demonstrates the performance impact of the memory leak
expect(eventTime).toBeGreaterThan(0)
})
it("demonstrates listener accumulation impact", () => {
// Test with different numbers of instances (reduced scale for efficiency)
const testCases = [5, 10, 15]
for (const instanceCount of testCases) {
// Clean test - create fresh subgraph
const testSubgraph = createTestSubgraph()
// Create instances
for (let i = 0; i < instanceCount; i++) {
createTestSubgraphNode(testSubgraph, { id: i })
}
// Measure event dispatch time
const startTime = performance.now()
testSubgraph.addInput(`test_${instanceCount}`, "number")
const dispatchTime = performance.now() - startTime
console.log(`${instanceCount} instances: ${dispatchTime.toFixed(3)}ms event dispatch`)
}
// This test documents that more instances = more listeners = slower events
expect(true).toBe(true)
})
})
describe("SubgraphMemory - Event Listener Management", () => {
subgraphTest("event handlers still work after node creation", ({ emptySubgraph }) => {
const rootGraph = new LGraph()
const subgraphNode = createTestSubgraphNode(emptySubgraph)
rootGraph.add(subgraphNode)
const handler = vi.fn()
emptySubgraph.events.addEventListener("input-added", handler)
emptySubgraph.addInput("test", "number")
expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
type: "input-added",
}))
})
subgraphTest("can add and remove multiple nodes without errors", ({ emptySubgraph }) => {
const rootGraph = new LGraph()
const nodes: ReturnType<typeof createTestSubgraphNode>[] = []
// Should be able to create multiple nodes without issues
for (let i = 0; i < 5; i++) {
const subgraphNode = createTestSubgraphNode(emptySubgraph)
rootGraph.add(subgraphNode)
nodes.push(subgraphNode)
}
expect(rootGraph.nodes.length).toBe(5)
// Should be able to remove them all without issues
for (const node of nodes) {
rootGraph.remove(node)
}
expect(rootGraph.nodes.length).toBe(0)
})
subgraphTest("supports AbortController cleanup patterns", ({ emptySubgraph }) => {
const abortController = new AbortController()
const { signal } = abortController
const handler = vi.fn()
emptySubgraph.events.addEventListener("input-added", handler, { signal })
emptySubgraph.addInput("test1", "number")
expect(handler).toHaveBeenCalledTimes(1)
abortController.abort()
emptySubgraph.addInput("test2", "number")
expect(handler).toHaveBeenCalledTimes(1)
})
subgraphTest("handles multiple creation/deletion cycles", ({ emptySubgraph }) => {
const rootGraph = new LGraph()
for (let cycle = 0; cycle < 3; cycle++) {
const nodes = []
for (let i = 0; i < 5; i++) {
const subgraphNode = createTestSubgraphNode(emptySubgraph)
rootGraph.add(subgraphNode)
nodes.push(subgraphNode)
}
expect(rootGraph.nodes.length).toBe(5)
for (const node of nodes) {
rootGraph.remove(node)
}
expect(rootGraph.nodes.length).toBe(0)
}
})
})
describe("SubgraphMemory - Reference Management", () => {
it("properly manages subgraph references in root graph", () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
const subgraphId = subgraph.id
// Add subgraph to root graph registry
rootGraph.subgraphs.set(subgraphId, subgraph)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
expect(rootGraph.subgraphs.get(subgraphId)).toBe(subgraph)
// Remove subgraph from registry
rootGraph.subgraphs.delete(subgraphId)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
})
it("maintains proper parent-child references", () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ nodeCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Before adding to graph, node might already have a graph reference
// (This depends on how createTestSubgraphNode works)
// Add to graph
rootGraph.add(subgraphNode)
expect(subgraphNode.graph).toBe(rootGraph)
expect(rootGraph.nodes).toContain(subgraphNode)
// Remove from graph
rootGraph.remove(subgraphNode)
expect(rootGraph.nodes).not.toContain(subgraphNode)
// After removal, graph reference behavior may vary by implementation
// The important thing is that it's removed from the graph's nodes array
})
it("prevents circular reference creation", () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Subgraph should not contain its own instance node
expect(subgraph.nodes).not.toContain(subgraphNode)
// If circular references were attempted, they should be detected
// (This documents the expected behavior - implementation may vary)
expect(subgraphNode.subgraph).toBe(subgraph)
expect(subgraph.nodes.includes(subgraphNode)).toBe(false)
})
})
describe("SubgraphMemory - Widget Reference Management", () => {
subgraphTest("properly sets and clears widget references", ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const input = subgraphNode.inputs[0]
// Mock widget for testing
const mockWidget = {
type: "number",
value: 42,
name: "test_widget",
}
// Set widget reference
if (input && "_widget" in input) {
;(input as any)._widget = mockWidget
expect((input as any)._widget).toBe(mockWidget)
}
// Clear widget reference
if (input && "_widget" in input) {
;(input as any)._widget = undefined
expect((input as any)._widget).toBeUndefined()
}
})
subgraphTest("maintains widget count consistency", ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const initialWidgetCount = subgraphNode.widgets?.length || 0
// Add mock widgets
const widget1 = { type: "number", value: 1, name: "widget1" }
const widget2 = { type: "string", value: "test", name: "widget2" }
if (subgraphNode.widgets) {
subgraphNode.widgets.push(widget1, widget2)
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
}
// Remove widgets
if (subgraphNode.widgets) {
subgraphNode.widgets.length = initialWidgetCount
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
}
})
subgraphTest("cleans up references during node removal", ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const input = subgraphNode.inputs[0]
const output = subgraphNode.outputs[0]
// Set up references that should be cleaned up
const mockReferences = {
widget: { type: "number", value: 42 },
connection: { id: 1, type: "number" },
listener: vi.fn(),
}
// Set references
if (input) {
;(input as any)._widget = mockReferences.widget
;(input as any)._connection = mockReferences.connection
}
if (output) {
;(input as any)._connection = mockReferences.connection
}
// Verify references are set
expect((input as any)?._widget).toBe(mockReferences.widget)
expect((input as any)?._connection).toBe(mockReferences.connection)
// Simulate proper cleanup (what onRemoved should do)
subgraphNode.onRemoved()
// Input-specific listeners should be cleaned up (this works)
if (input && "_listenerController" in input) {
expect((input as any)._listenerController?.signal.aborted).toBe(true)
}
// Note: Other references may still exist - this documents current behavior
// In a proper implementation, onRemoved should clean these up too
})
})
describe("SubgraphMemory - Performance and Scale", () => {
subgraphTest("handles multiple subgraphs in same graph", ({ subgraphWithNode }) => {
const { parentGraph } = subgraphWithNode
const subgraphA = createTestSubgraph({ name: "Subgraph A" })
const subgraphB = createTestSubgraph({ name: "Subgraph B" })
const nodeA = createTestSubgraphNode(subgraphA)
const nodeB = createTestSubgraphNode(subgraphB)
parentGraph.add(nodeA)
parentGraph.add(nodeB)
expect(nodeA.graph).toBe(parentGraph)
expect(nodeB.graph).toBe(parentGraph)
expect(parentGraph.nodes.length).toBe(3) // Original + nodeA + nodeB
parentGraph.remove(nodeA)
parentGraph.remove(nodeB)
expect(parentGraph.nodes.length).toBe(1) // Only the original subgraphNode remains
})
it("handles many instances without issues", () => {
const subgraph = createTestSubgraph({
inputs: [{ name: "stress_input", type: "number" }],
outputs: [{ name: "stress_output", type: "number" }],
})
const rootGraph = new LGraph()
const instances = []
// Create instances (reduced from 50 to 25 for test efficiency)
for (let i = 0; i < 25; i++) {
const instance = createTestSubgraphNode(subgraph)
rootGraph.add(instance)
instances.push(instance)
}
expect(instances.length).toBe(25)
expect(rootGraph.nodes.length).toBe(25)
// Remove all instances (proper cleanup)
for (const instance of instances) {
rootGraph.remove(instance)
}
expect(rootGraph.nodes.length).toBe(0)
})
it("maintains consistent behavior across multiple cycles", () => {
const subgraph = createTestSubgraph()
const rootGraph = new LGraph()
for (let cycle = 0; cycle < 10; cycle++) {
const instances = []
// Create instances
for (let i = 0; i < 10; i++) {
const instance = createTestSubgraphNode(subgraph)
rootGraph.add(instance)
instances.push(instance)
}
expect(rootGraph.nodes.length).toBe(10)
// Remove instances
for (const instance of instances) {
rootGraph.remove(instance)
}
expect(rootGraph.nodes.length).toBe(0)
}
})
})