Skip to content

Commit c2afe11

Browse files
luanpotterspydon
andauthored
fix: Fix priority rebalancing causing concurrent mutation of component ordered_set (#3530)
Fix priority rebalancing causing concurrent mutation of component ordered_set. This fixes #3363 Basically priority changes and rebalancing was not using the component lifecycle event queue, which caused concurrent mutations and iterations of the component set, and due to race conditions, loss of components as they are removed and re-added for rebalancing. The issue is not with ordered_set itself, but rather how it is being used. It was never designed to support concurrent mutations. A few notes for the future: * I think we should consider changing the whole event (input) system so that every event is queued and only processed predictably within the game loop, avoiding a whole class of concurrent modification issues * we should consider more efficient ways of rebalancing than literally removing every single component + re-adding (which is also error prone if there is concurrent access). --------- Co-authored-by: Lukas Klingsbo <[email protected]>
1 parent f346e3f commit c2afe11

File tree

7 files changed

+129
-17
lines changed

7 files changed

+129
-17
lines changed

.github/.cspell/words_dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ prerendered
2323
pressable
2424
ptero # short for pterodactyl
2525
rebalance
26+
rebalancing
2627
refreshable
2728
renderable
2829
rescan

packages/flame/lib/src/components/core/component.dart

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -819,10 +819,12 @@ class Component {
819819
int _priority;
820820
set priority(int newPriority) {
821821
if (_priority != newPriority) {
822-
_priority = newPriority;
822+
final parent = _parent;
823823
final game = findGame();
824-
if (game != null && _parent != null) {
825-
game.enqueueRebalance(_parent!);
824+
if (game != null && parent != null) {
825+
game.enqueuePriorityChange(parent, this, newPriority);
826+
} else {
827+
_priority = newPriority;
826828
}
827829
}
828830
}
@@ -877,6 +879,16 @@ class Component {
877879
return LifecycleEventStatus.done;
878880
}
879881

882+
/// NOTE: this method will intentionally not reorder the parent,
883+
/// leaving the ordered set in an inconsistent state.
884+
/// It is left to the caller to optimally reorder the parent.
885+
@internal
886+
void handleLifecycleEventRebalanceUncleanly(int newPriority) {
887+
if (_priority != newPriority) {
888+
_priority = newPriority;
889+
}
890+
}
891+
880892
@mustCallSuper
881893
@internal
882894
void handleResize(Vector2 size) {

packages/flame/lib/src/components/core/component_tree_root.dart

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@ class ComponentTreeRoot extends Component {
1515
super.children,
1616
super.key,
1717
}) : _queue = RecycledQueue(_LifecycleEvent.new),
18-
_blocked = <int>{},
19-
_componentsToRebalance = <Component>{};
18+
_blocked = <int>{};
2019

2120
final RecycledQueue<_LifecycleEvent> _queue;
2221
final Set<int> _blocked;
23-
final Set<Component> _componentsToRebalance;
2422
late final Map<ComponentKey, Component> _index = {};
2523
Completer<void>? _lifecycleEventsCompleter;
2624

@@ -73,8 +71,16 @@ class ComponentTreeRoot extends Component {
7371
}
7472

7573
@internal
76-
void enqueueRebalance(Component parent) {
77-
_componentsToRebalance.add(parent);
74+
void enqueuePriorityChange(
75+
Component parent,
76+
Component child,
77+
int newPriority,
78+
) {
79+
_queue.addLast()
80+
..kind = _LifecycleEventKind.rebalance
81+
..child = child
82+
..parent = parent
83+
..newPriority = newPriority;
7884
}
7985

8086
bool get hasLifecycleEvents => _queue.isNotEmpty;
@@ -107,6 +113,17 @@ class ComponentTreeRoot extends Component {
107113
}
108114

109115
void processLifecycleEvents() {
116+
// reorder events to process later grouped by parent
117+
final reorderEventsByParent = <Component, List<(Component, int)>>{};
118+
LifecycleEventStatus handleReorderEvent(
119+
Component parent,
120+
Component child,
121+
int newPriority,
122+
) {
123+
(reorderEventsByParent[parent] ??= []).add((child, newPriority));
124+
return LifecycleEventStatus.done;
125+
}
126+
110127
assert(_blocked.isEmpty);
111128
var repeatLoop = true;
112129
while (repeatLoop) {
@@ -124,6 +141,8 @@ class ComponentTreeRoot extends Component {
124141
_LifecycleEventKind.remove =>
125142
child.handleLifecycleEventRemove(parent),
126143
_LifecycleEventKind.move => child.handleLifecycleEventMove(parent),
144+
_LifecycleEventKind.rebalance =>
145+
handleReorderEvent(parent, child, event.newPriority!),
127146
_LifecycleEventKind.unknown => LifecycleEventStatus.done,
128147
};
129148

@@ -140,19 +159,20 @@ class ComponentTreeRoot extends Component {
140159
_blocked.clear();
141160
}
142161

162+
for (final MapEntry(key: parent, value: events)
163+
in reorderEventsByParent.entries) {
164+
for (final (child, newPriority) in events) {
165+
child.handleLifecycleEventRebalanceUncleanly(newPriority);
166+
}
167+
parent.children.reorder();
168+
}
169+
143170
if (!hasLifecycleEvents && _lifecycleEventsCompleter != null) {
144171
_lifecycleEventsCompleter!.complete();
145172
_lifecycleEventsCompleter = null;
146173
}
147174
}
148175

149-
void processRebalanceEvents() {
150-
for (final component in _componentsToRebalance) {
151-
component.children.reorder();
152-
}
153-
_componentsToRebalance.clear();
154-
}
155-
156176
@mustCallSuper
157177
@override
158178
@internal
@@ -207,18 +227,21 @@ enum _LifecycleEventKind {
207227
add,
208228
remove,
209229
move,
230+
rebalance,
210231
}
211232

212233
class _LifecycleEvent implements Disposable {
213234
_LifecycleEventKind kind = _LifecycleEventKind.unknown;
214235
Component? child;
215236
Component? parent;
237+
int? newPriority;
216238

217239
@override
218240
void dispose() {
219241
kind = _LifecycleEventKind.unknown;
220242
child = null;
221243
parent = null;
244+
newPriority = null;
222245
}
223246

224247
@override

packages/flame/lib/src/game/flame_game.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ class FlameGame<W extends World> extends ComponentTreeRoot
162162
for (final component in children) {
163163
component.updateTree(dt);
164164
}
165-
processRebalanceEvents();
166165
}
167166

168167
/// This passes the new size along to every component in the tree via their

packages/flame/test/components/component_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:math';
2+
13
import 'package:flame/components.dart';
24
import 'package:flame/game.dart';
35
import 'package:flame_test/flame_test.dart';
@@ -1015,6 +1017,72 @@ void main() {
10151017
});
10161018
});
10171019

1020+
group('Rebalancing components', () {
1021+
testWithFlameGame(
1022+
'rebalance is queued',
1023+
(game) async {
1024+
final c = Component();
1025+
await game.world.add(c);
1026+
1027+
c.priority = 10;
1028+
expect(c.priority, 0);
1029+
1030+
await game.ready();
1031+
expect(c.priority, 10);
1032+
},
1033+
);
1034+
1035+
testWithFlameGame(
1036+
'async rebalance, add and remove have no race condition',
1037+
(game) async {
1038+
final r = Random(69420);
1039+
for (var i = 0; i < 10; i++) {
1040+
game.world.add(Component());
1041+
}
1042+
1043+
Future<void> add() async {
1044+
final white = Component();
1045+
game.world.add(white);
1046+
await Future.delayed(
1047+
const Duration(milliseconds: 300),
1048+
white.removeFromParent,
1049+
);
1050+
}
1051+
1052+
Future<void> rebalance() async {
1053+
game.world.children.forEach((it) {
1054+
it.priority = r.nextInt(1_000);
1055+
});
1056+
}
1057+
1058+
var completed = 0;
1059+
var total = 0;
1060+
void waitFor(int milliseconds, Future<void> Function() fn) {
1061+
total++;
1062+
Future.delayed(Duration(milliseconds: milliseconds), fn)
1063+
.whenComplete(() => completed++);
1064+
}
1065+
1066+
Future<void> start() async {
1067+
for (var i = 0; i < 100; i++) {
1068+
waitFor(17 * i, rebalance);
1069+
waitFor(31 * i, add);
1070+
}
1071+
}
1072+
1073+
waitFor(0, start);
1074+
1075+
while (completed < total || game.hasLifecycleEvents) {
1076+
game.update(0);
1077+
await Future.delayed(const Duration(milliseconds: 1));
1078+
}
1079+
1080+
await game.ready();
1081+
expect(game.world.children.length, 10);
1082+
},
1083+
);
1084+
});
1085+
10181086
group('descendants()', () {
10191087
testWithFlameGame(
10201088
'descendants in a deep component tree',

packages/flame/test/components/priority_test.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ void main() {
149149

150150
a.priority = 10;
151151
game.update(0);
152+
await game.ready();
152153

153154
componentsSorted(game.children);
154155
componentsSorted(a.children);
@@ -163,6 +164,7 @@ void main() {
163164
c1.priority = 10;
164165
a2.priority = 0;
165166
game.update(0);
167+
await game.ready();
166168

167169
a.assertCalled(1);
168170
b.assertCalled(0);
@@ -177,6 +179,7 @@ void main() {
177179
b1.priority = 2;
178180
a1.priority = 1; // no-op!
179181
game.update(0);
182+
await game.ready();
180183

181184
a.assertCalled(0);
182185
b.assertCalled(1);
@@ -217,7 +220,10 @@ void main() {
217220

218221
expect(parent.priority, 0);
219222
expect(child.priority, 0);
223+
220224
game.update(0.1);
225+
await game.ready();
226+
221227
expect(parent.priority, 10);
222228
expect(child.priority, 0);
223229
expect(renderEvents, isEmpty);

packages/flame/test/components/router_component_test.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,18 @@ void main() {
251251
expect(router.stack.length, 1);
252252

253253
router.pushNamed('B');
254+
await game.ready();
254255
router.pushNamed('C');
256+
await game.ready();
255257
expect(router.stack.length, 3);
256258

257259
router.pushNamed('B');
260+
await game.ready();
258261
expect(router.stack.length, 3);
259262
router.pushNamed('A');
263+
await game.ready();
260264
expect(router.stack.length, 3);
261265

262-
await game.ready();
263266
expect(router.children.length, 3);
264267
expect((router.children.elementAt(0) as Route).name, 'C');
265268
expect((router.children.elementAt(1) as Route).name, 'B');

0 commit comments

Comments
 (0)