Skip to content

Commit defaef8

Browse files
[fix] prevent memory leak in SubgraphNode event listeners (#1150)
1 parent 6b3de73 commit defaef8

File tree

2 files changed

+99
-7
lines changed

2 files changed

+99
-7
lines changed

src/subgraph/SubgraphNode.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
3939

4040
override widgets: IBaseWidget[] = []
4141

42+
/** Manages lifecycle of all subgraph event listeners */
43+
#eventAbortController = new AbortController()
44+
4245
constructor(
4346
/** The (sub)graph that contains this subgraph instance. */
4447
override readonly graph: GraphOrSubgraph,
@@ -50,45 +53,47 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
5053

5154
// Update this node when the subgraph input / output slots are changed
5255
const subgraphEvents = this.subgraph.events
56+
const { signal } = this.#eventAbortController
57+
5358
subgraphEvents.addEventListener("input-added", (e) => {
5459
const subgraphInput = e.detail.input
5560
const { name, type } = subgraphInput
5661
const input = this.addInput(name, type)
5762

5863
this.#addSubgraphInputListeners(subgraphInput, input)
59-
})
64+
}, { signal })
6065

6166
subgraphEvents.addEventListener("removing-input", (e) => {
6267
const widget = e.detail.input._widget
6368
if (widget) this.ensureWidgetRemoved(widget)
6469

6570
this.removeInput(e.detail.index)
66-
})
71+
}, { signal })
6772

6873
subgraphEvents.addEventListener("output-added", (e) => {
6974
const { name, type } = e.detail.output
7075
this.addOutput(name, type)
71-
})
76+
}, { signal })
7277

7378
subgraphEvents.addEventListener("removing-output", (e) => {
7479
this.removeOutput(e.detail.index)
75-
})
80+
}, { signal })
7681

7782
subgraphEvents.addEventListener("renaming-input", (e) => {
7883
const { index, newName } = e.detail
7984
const input = this.inputs.at(index)
8085
if (!input) throw new Error("Subgraph input not found")
8186

8287
input.label = newName
83-
})
88+
}, { signal })
8489

8590
subgraphEvents.addEventListener("renaming-output", (e) => {
8691
const { index, newName } = e.detail
8792
const output = this.outputs.at(index)
8893
if (!output) throw new Error("Subgraph output not found")
8994

9095
output.label = newName
91-
})
96+
}, { signal })
9297

9398
this.type = subgraph.id
9499
this.configure(instanceData)
@@ -327,6 +332,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
327332
}
328333

329334
override onRemoved(): void {
335+
// Clean up all subgraph event listeners
336+
this.#eventAbortController.abort()
337+
330338
// Clean up all promoted widgets
331339
for (const widget of this.widgets) {
332340
this.subgraph.events.dispatch("widget-demoted", { widget, subgraphNode: this })

test/subgraph/SubgraphNode.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* IO synchronization, and edge cases.
66
*/
77

8-
import { describe, expect, it } from "vitest"
8+
import { describe, expect, it, vi } from "vitest"
99

1010
import { LGraph, Subgraph } from "@/litegraph"
1111

@@ -492,3 +492,87 @@ describe("Foundation Test Utilities", () => {
492492
expect(parentGraph.nodes).toContain(subgraphNode)
493493
})
494494
})
495+
496+
describe("SubgraphNode Cleanup", () => {
497+
it("should clean up event listeners when removed", () => {
498+
const rootGraph = new LGraph()
499+
const subgraph = createTestSubgraph()
500+
501+
// Create and add two nodes
502+
const node1 = createTestSubgraphNode(subgraph)
503+
const node2 = createTestSubgraphNode(subgraph)
504+
rootGraph.add(node1)
505+
rootGraph.add(node2)
506+
507+
// Verify both nodes start with no inputs
508+
expect(node1.inputs.length).toBe(0)
509+
expect(node2.inputs.length).toBe(0)
510+
511+
// Remove node2
512+
rootGraph.remove(node2)
513+
514+
// Now trigger an event - only node1 should respond
515+
subgraph.events.dispatch("input-added", {
516+
input: { name: "test", type: "number", id: "test-id" } as any,
517+
})
518+
519+
// Only node1 should have added an input
520+
expect(node1.inputs.length).toBe(1) // node1 responds
521+
expect(node2.inputs.length).toBe(0) // node2 should NOT respond (but currently does)
522+
})
523+
524+
it("should not accumulate handlers over multiple add/remove cycles", () => {
525+
const rootGraph = new LGraph()
526+
const subgraph = createTestSubgraph()
527+
528+
// Add and remove nodes multiple times
529+
const removedNodes: SubgraphNode[] = []
530+
for (let i = 0; i < 3; i++) {
531+
const node = createTestSubgraphNode(subgraph)
532+
rootGraph.add(node)
533+
rootGraph.remove(node)
534+
removedNodes.push(node)
535+
}
536+
537+
// All nodes should have 0 inputs
538+
for (const node of removedNodes) {
539+
expect(node.inputs.length).toBe(0)
540+
}
541+
542+
// Trigger an event - no nodes should respond
543+
subgraph.events.dispatch("input-added", {
544+
input: { name: "test", type: "number", id: "test-id" } as any,
545+
})
546+
547+
// Without cleanup: all 3 removed nodes would have added an input
548+
// With cleanup: no nodes should have added an input
549+
for (const node of removedNodes) {
550+
expect(node.inputs.length).toBe(0) // Should stay 0 after cleanup
551+
}
552+
})
553+
554+
it("should clean up input listener controllers on removal", () => {
555+
const rootGraph = new LGraph()
556+
const subgraph = createTestSubgraph({
557+
inputs: [{ name: "in1", type: "number" }, { name: "in2", type: "string" }],
558+
})
559+
560+
const subgraphNode = createTestSubgraphNode(subgraph)
561+
rootGraph.add(subgraphNode)
562+
563+
// Verify listener controllers exist
564+
expect(subgraphNode.inputs[0]._listenerController).toBeDefined()
565+
expect(subgraphNode.inputs[1]._listenerController).toBeDefined()
566+
567+
// Track abort calls
568+
const abortSpy1 = vi.spyOn(subgraphNode.inputs[0]._listenerController!, "abort")
569+
const abortSpy2 = vi.spyOn(subgraphNode.inputs[1]._listenerController!, "abort")
570+
571+
// Remove node
572+
rootGraph.remove(subgraphNode)
573+
574+
// Verify abort was called on each controller
575+
expect(abortSpy1).toHaveBeenCalledTimes(1)
576+
expect(abortSpy2).toHaveBeenCalledTimes(1)
577+
})
578+
})

0 commit comments

Comments
 (0)