Skip to content

Commit 329456c

Browse files
DrJKLampagent
andcommitted
fix(layout): harden CRDT ownership teardown
Amp-Thread-ID: https://ampcode.com/threads/T-019fd658-758e-76ca-90bd-9f920dd41d59 Co-authored-by: Amp <amp@ampcode.com>
1 parent 307f262 commit 329456c

8 files changed

Lines changed: 402 additions & 141 deletions

File tree

src/lib/litegraph/src/LGraph.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,12 @@ describe('node layout registration', () => {
17671767

17681768
it('preserves released subgraph registrations across failed removals', () => {
17691769
const graph = new LGraph()
1770+
const source = new LGraphNode('source')
1771+
source.addOutput('out', '*')
1772+
const target = new LGraphNode('target')
1773+
target.addInput('in', '*')
1774+
graph.add(source)
1775+
graph.add(target)
17701776
const subgraph = graph.createSubgraph(createTestSubgraphData())
17711777
const interior = new LGraphNode('interior')
17721778
interior.pos = [10, 20]
@@ -1787,7 +1793,15 @@ describe('node layout registration', () => {
17871793
const nestedNode = createTestSubgraphNode(nested, { parentGraph: subgraph })
17881794
subgraph.add(nestedNode)
17891795
const subgraphNode = createTestSubgraphNode(subgraph)
1796+
subgraphNode.addInput('in', '*')
1797+
subgraphNode.addOutput('out', '*')
17901798
graph.add(subgraphNode)
1799+
source.connect(0, subgraphNode, 0)
1800+
subgraphNode.connect(0, target, 0)
1801+
const onConnectionChange = vi.fn()
1802+
graph.onConnectionChange = onConnectionChange
1803+
const onNodeRemoved = vi.fn()
1804+
graph.onNodeRemoved = onNodeRemoved
17911805
subgraphNode.onRemoved = () => {
17921806
throw new Error('outer removal failed')
17931807
}
@@ -1835,14 +1849,22 @@ describe('node layout registration', () => {
18351849
layoutStore.getNodeLayoutRef(graph.id, nestedInterior.id).value?.position
18361850
).toEqual({ x: 70, y: 80 })
18371851

1852+
const inputLink = source.connect(0, subgraphNode, 0)!
1853+
const outputLink = subgraphNode.connect(0, target, 0)!
1854+
onConnectionChange.mockClear()
1855+
onNodeRemoved.mockClear()
18381856
subgraphNode.onRemoved = () => {}
18391857
vi.spyOn(layoutStore, 'applyOperations').mockReturnValueOnce('rejected')
18401858
expect(() => graph.remove(subgraphNode)).not.toThrow()
18411859

18421860
expect(graph.nodes).toContain(subgraphNode)
1861+
expect(graph.links.get(inputLink.id)).toBe(inputLink)
1862+
expect(graph.links.get(outputLink.id)).toBe(outputLink)
1863+
expect(onConnectionChange).not.toHaveBeenCalled()
1864+
expect(onNodeRemoved).not.toHaveBeenCalled()
18431865
expect(subgraphNode.graph).toBe(graph)
18441866
expect(graph.subgraphs.get(subgraph.id)).toBe(subgraph)
1845-
expect([...useLinkStore().graphTopologies(graph.id)]).toHaveLength(1)
1867+
expect([...useLinkStore().graphTopologies(graph.id)]).toHaveLength(3)
18461868
expect(useRerouteStore().getReroute(graph.id, reroute.id)).toBeDefined()
18471869
expect(
18481870
useNodeDataStore().getGraphNodesFor(graph.id, subgraph.id)

src/lib/litegraph/src/LGraph.ts

Lines changed: 87 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ export interface GraphAddOptions {
206206
}
207207

208208
const nodesAdoptingLayout = new WeakSet<LGraphNode>()
209+
const rerouteDataAdoptingLayout = new WeakSet<object>()
209210

210211
export interface LGraphExtra extends Dictionary<unknown> {
211212
reroutes?: SerialisableReroute[]
@@ -503,12 +504,11 @@ export class LGraph
503504
}
504505

505506
private clearWithResult(): LayoutOperationResult {
506-
this.stop()
507-
this.status = LGraph.STATUS_STOPPED
508-
509507
const graphId = this.id
510508
const layoutResult = unregisterAllGraphLayout(this)
511509
if (layoutResult === 'rejected') return 'rejected'
510+
this.stop()
511+
this.status = LGraph.STATUS_STOPPED
512512
if (this.isRootGraph && graphId !== zeroUuid) {
513513
usePreviewExposureStore().clearGraph(graphId)
514514
useWidgetValueStore().clearGraph(graphId)
@@ -1194,8 +1194,7 @@ export class LGraph
11941194
restoreNodeIdentity()
11951195
throw error
11961196
}
1197-
// A no-op means the entry already exists; re-registering is not an error.
1198-
if (registrationResult === 'rejected') {
1197+
if (registrationResult !== 'applied') {
11991198
restoreNodeIdentity()
12001199
return
12011200
}
@@ -1293,6 +1292,16 @@ export class LGraph
12931292
// sure? - almost sure is wrong
12941293
try {
12951294
if (layoutDetach.result === 'rejected') return
1295+
const releasedSubgraphs = node.isSubgraphNode()
1296+
? findReleasableSubgraphs(this.rootGraph, node)
1297+
: []
1298+
for (const subgraph of releasedSubgraphs) {
1299+
layoutDetach.includeGraph(subgraph)
1300+
if (unregisterAllGraphLayout(subgraph) === 'rejected') {
1301+
layoutDetach.restore(undefined)
1302+
return
1303+
}
1304+
}
12961305
this.beforeChange()
12971306

12981307
this.events.dispatch('node:before-removed', { node })
@@ -1320,14 +1329,8 @@ export class LGraph
13201329
}
13211330
}
13221331

1323-
const initiallyReleasedSubgraphs = node.isSubgraphNode()
1324-
? findReleasableSubgraphs(this.rootGraph, node)
1325-
: []
13261332
if (node.isSubgraphNode()) {
1327-
for (const subgraph of initiallyReleasedSubgraphs) {
1328-
layoutDetach.includeGraph(subgraph)
1329-
}
1330-
for (const subgraph of initiallyReleasedSubgraphs) {
1333+
for (const subgraph of releasedSubgraphs) {
13311334
visitGraphNodes(subgraph, fireNodeRemovalLifecycle)
13321335
}
13331336
}
@@ -1348,18 +1351,6 @@ export class LGraph
13481351
}
13491352
}
13501353

1351-
const releasedSubgraphs = node.isSubgraphNode()
1352-
? findReleasableSubgraphs(this.rootGraph, node).filter((subgraph) =>
1353-
initiallyReleasedSubgraphs.includes(subgraph)
1354-
)
1355-
: []
1356-
for (const subgraph of releasedSubgraphs) {
1357-
if (unregisterAllGraphLayout(subgraph) === 'rejected') {
1358-
node.graph = this
1359-
layoutDetach.restore(undefined)
1360-
return
1361-
}
1362-
}
13631354
for (const subgraph of releasedSubgraphs) {
13641355
unregisterAllLinkTopologies(subgraph)
13651356
unregisterAllRerouteChains(subgraph)
@@ -1715,7 +1706,7 @@ export class LGraph
17151706
* populating {@link reroutes}; routing every add through here keeps the
17161707
* store from silently desyncing.
17171708
*/
1718-
_addReroute(reroute: Reroute): void {
1709+
_addReroute(reroute: Reroute, adoptExisting = false): void {
17191710
if (reroute.network.deref() !== this) {
17201711
throw new Error(
17211712
`Reroute ${reroute.id} may only attach to its constructor graph`
@@ -1731,7 +1722,15 @@ export class LGraph
17311722
if (existing && existing !== reroute) {
17321723
throw new Error(`Reroute ${reroute.id} is already owned by this graph`)
17331724
}
1734-
if (existing) return
1725+
if (existing) {
1726+
if (adoptExisting) {
1727+
const result = materializeRerouteLayout(this, reroute)
1728+
if (result !== 'applied') {
1729+
throw new Error(`Reroute layout registration ${result}`)
1730+
}
1731+
}
1732+
return
1733+
}
17351734

17361735
const position = { x: reroute.pos[0], y: reroute.pos[1] }
17371736
registerRerouteChain(this, reroute)
@@ -1742,10 +1741,12 @@ export class LGraph
17421741
unregisterRerouteChain(reroute)
17431742
throw error
17441743
}
1745-
// A no-op means the entry already exists; re-registering is not an error.
1746-
if (registrationResult === 'rejected') {
1744+
if (registrationResult === 'no-op' && adoptExisting) {
1745+
registrationResult = materializeRerouteLayout(this, reroute)
1746+
}
1747+
if (registrationResult !== 'applied') {
17471748
unregisterRerouteChain(reroute)
1748-
return
1749+
throw new Error(`Reroute layout registration ${registrationResult}`)
17491750
}
17501751
this.reroutesInternal.set(reroute.id, reroute)
17511752
reroute._attachedGraph = new WeakRef(this)
@@ -1769,32 +1770,24 @@ export class LGraph
17691770
* Creates the object if it does not exist.
17701771
* @param serialisedReroute See {@link SerialisableReroute}
17711772
*/
1772-
setReroute({
1773-
id,
1774-
parentId,
1775-
pos,
1776-
floating
1777-
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
1778-
const originalLastRerouteId = this.state.lastRerouteId
1773+
setReroute(
1774+
serialisedReroute: OptionalProps<SerialisableReroute, 'id'>
1775+
): Reroute {
1776+
const { id, parentId, pos, floating } = serialisedReroute
17791777
const rerouteId =
17801778
id === undefined
17811779
? toRerouteId(Number(this.state.lastRerouteId) + 1)
17821780
: toRerouteId(id)
1783-
if (rerouteId > this.state.lastRerouteId) {
1784-
this.state.lastRerouteId = rerouteId
1785-
}
17861781

17871782
const existingReroute = this.reroutes.get(rerouteId)
17881783
const reroute = existingReroute ?? new Reroute(rerouteId, this, pos)
1784+
this._addReroute(reroute, rerouteDataAdoptingLayout.has(serialisedReroute))
1785+
if (pos && existingReroute) reroute.pos = pos
17891786
reroute.parentId =
17901787
parentId === undefined ? undefined : toRerouteId(parentId)
1791-
if (pos && existingReroute) reroute.pos = pos
17921788
reroute.floating = floating
1793-
try {
1794-
this._addReroute(reroute)
1795-
} catch (error) {
1796-
this.state.lastRerouteId = originalLastRerouteId
1797-
throw error
1789+
if (rerouteId > this.state.lastRerouteId) {
1790+
this.state.lastRerouteId = rerouteId
17981791
}
17991792
return reroute
18001793
}
@@ -2727,29 +2720,58 @@ export class LGraph
27272720
const targetGraphId = this.isRootGraph
27282721
? (serializedRootId ?? (keepsOldState ? this.id : undefined))
27292722
: this.rootGraph.id
2730-
const graphs = [
2731-
{
2732-
graph: this,
2733-
reroutes: data.version === 0.4 ? data.extra?.reroutes : data.reroutes
2734-
},
2735-
...(data.definitions?.subgraphs ?? []).map((subgraph) => ({
2736-
graph: this.subgraphs.get(subgraph.id),
2737-
reroutes: subgraph.reroutes
2738-
}))
2723+
const serializedGraphs = [
2724+
data.version === 0.4 ? data.extra?.reroutes : data.reroutes,
2725+
...(data.definitions?.subgraphs ?? []).map(
2726+
(subgraph) => subgraph.reroutes
2727+
)
27392728
]
2740-
const incomingRerouteIds = new Set<RerouteId>()
2741-
for (const { reroutes } of graphs) {
2729+
for (const reroutes of serializedGraphs) {
2730+
const graphRerouteIds = new Set<RerouteId>()
27422731
for (const { id } of reroutes ?? []) {
27432732
const rerouteId = toRerouteId(id)
2744-
if (incomingRerouteIds.has(rerouteId)) {
2733+
if (graphRerouteIds.has(rerouteId)) {
27452734
throw new Error(
27462735
`Reroute ${rerouteId} appears more than once in the configuration`
27472736
)
27482737
}
2749-
incomingRerouteIds.add(rerouteId)
2738+
graphRerouteIds.add(rerouteId)
27502739
}
27512740
}
27522741

2742+
const temporaryState = { ...this.state }
2743+
let subgraphs = data.definitions?.subgraphs
2744+
if (subgraphs) {
2745+
const clonedSubgraphs = structuredClone(subgraphs)
2746+
const reservedRerouteIds = keepsOldState
2747+
? collectReservedRerouteIds(this)
2748+
: new Set<RerouteId>()
2749+
for (const { id } of (data.version === 0.4
2750+
? data.extra?.reroutes
2751+
: data.reroutes) ?? []) {
2752+
reservedRerouteIds.add(id)
2753+
}
2754+
deduplicateSubgraphRerouteIds(
2755+
clonedSubgraphs,
2756+
reservedRerouteIds,
2757+
temporaryState
2758+
)
2759+
data = {
2760+
...data,
2761+
definitions: { ...data.definitions, subgraphs: clonedSubgraphs }
2762+
}
2763+
subgraphs = clonedSubgraphs
2764+
}
2765+
const graphs = [
2766+
{
2767+
graph: this,
2768+
reroutes: data.version === 0.4 ? data.extra?.reroutes : data.reroutes
2769+
},
2770+
...(data.definitions?.subgraphs ?? []).map((subgraph) => ({
2771+
graph: this.subgraphs.get(subgraph.id),
2772+
reroutes: subgraph.reroutes
2773+
}))
2774+
]
27532775
if (targetGraphId && targetGraphId !== zeroUuid) {
27542776
const store = useRerouteStore()
27552777
const replaceableOwners = keepsOldState
@@ -2787,6 +2809,7 @@ export class LGraph
27872809
? this.clearWithResult()
27882810
: unregisterAllGraphLayout(this)
27892811
if (layoutResult === 'rejected') return
2812+
this.state.lastRerouteId = temporaryState.lastRerouteId
27902813

27912814
this._configureBase(data)
27922815

@@ -2850,8 +2873,12 @@ export class LGraph
28502873
// Reroutes
28512874
if (Array.isArray(reroutes)) {
28522875
for (const rerouteData of reroutes) {
2853-
const reroute = this.setReroute(rerouteData)
2854-
materializeRerouteLayout(this, reroute)
2876+
rerouteDataAdoptingLayout.add(rerouteData)
2877+
try {
2878+
this.setReroute(rerouteData)
2879+
} finally {
2880+
rerouteDataAdoptingLayout.delete(rerouteData)
2881+
}
28552882
}
28562883
}
28572884

@@ -2868,7 +2895,6 @@ export class LGraph
28682895
// Subgraph definitions — deduplicate node IDs before configuring.
28692896
// deduplicateSubgraphNodeIds clones internally to avoid mutating
28702897
// the caller's data (e.g. reactive Pinia state).
2871-
const subgraphs = data.definitions?.subgraphs
28722898
let effectiveNodesData = nodesData
28732899
if (subgraphs) {
28742900
const reservedNodeIds = new Set<number>()
@@ -2901,11 +2927,6 @@ export class LGraph
29012927
collectReservedGroupIds(this, data.groups),
29022928
this.state
29032929
)
2904-
deduplicateSubgraphRerouteIds(
2905-
deduplicated.subgraphs,
2906-
collectReservedRerouteIds(this),
2907-
this.state
2908-
)
29092930
}
29102931

29112932
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs

src/lib/litegraph/src/LGraphGroup.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { toGroupId } from '@/types/groupId'
22
import type { GroupId } from '@/types/groupId'
3+
import { toNodeId } from '@/types/nodeId'
34
import { createTestingPinia } from '@pinia/testing'
45
import { setActivePinia } from 'pinia'
56
import { beforeEach, describe, expect, onTestFinished, vi } from 'vitest'
@@ -304,10 +305,20 @@ describe('group layout in layoutStore', () => {
304305
})
305306

306307
test('keeps node layout registration when reentrant clear is rejected', () => {
308+
vi.useFakeTimers()
307309
const graph = new LGraph()
308-
const node = new LGraphNode('node')
310+
class StoppableNode extends LGraphNode {
311+
onStop = vi.fn()
312+
}
313+
const node = new StoppableNode('node')
309314
graph.add(node)
310315
const group = addedGroup(graph, toGroupId(808))
316+
graph.start(10)
317+
const executionTimer = graph.execution_timer_id
318+
onTestFinished(() => {
319+
graph.stop()
320+
vi.useRealTimers()
321+
})
311322
const ydoc = getLayoutStoreYDoc()
312323
function attemptClear(): void {
313324
ydoc.off('beforeTransaction', attemptClear)
@@ -319,6 +330,9 @@ describe('group layout in layoutStore', () => {
319330

320331
expect(graph.nodes).toContain(node)
321332
expect(graph.groups).toContain(group)
333+
expect(graph.status).toBe(LGraph.STATUS_RUNNING)
334+
expect(graph.execution_timer_id).toBe(executionTimer)
335+
expect(node.onStop).not.toHaveBeenCalled()
322336
expect(group.graph).toBe(graph)
323337
node.pos = [20, 30]
324338
expect(
@@ -408,6 +422,17 @@ describe('group layout in layoutStore', () => {
408422
expect(layoutStore.getNodeLayoutRef(graph.id, node.id).value).toBeNull()
409423
})
410424

425+
test('rejects ordinary node attachment to an existing layout', () => {
426+
const graph = new LGraph()
427+
const node = new LGraphNode('node')
428+
node.id = toNodeId(812)
429+
useLayoutMutations().createNode(graph.id, node.id, {})
430+
431+
expect(graph.add(node)).toBeUndefined()
432+
expect(node.graph).toBeNull()
433+
expect(graph.nodes).not.toContain(node)
434+
})
435+
411436
test('restores node identity when registration compensation throws', () => {
412437
const graph = new LGraph()
413438
const node = new LGraphNode('node')

0 commit comments

Comments
 (0)