• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 30060975494

24 Jul 2026 02:11AM UTC coverage: 64.152% (+0.04%) from 64.109%
30060975494

push

github

tyevco
fix(core): Harden MessageManager dispatch against reentrancy and cross-handler unsubscribe (#1108, #1109, #1113)

Snapshot the handler list before dispatch in Send, ProcessQueuedMessages<T>,
and MessageQueueWrapper.Process so a handler can unsubscribe itself or any
other handler without shifting the set of handlers seen by the current
dispatch. This fixes the double-invoke where reverse-iterating the live list
re-ran a just-run handler after it removed an earlier-indexed one (#1109).

Snapshot the queue set in ProcessQueuedMessages before dispatch so a handler
queuing a never-before-seen message type (adding a dictionary key) no longer
throws 'Collection was modified'; newly-queued types are delivered on the next
drain. Drop the trailing clear-all pass that could silently discard a message
a handler just queued (#1108).

Iterate forward in both EventBus and MessageManager to honor the documented
'registration order' contract instead of reverse (#1113).

Closes #1108
Closes #1109
Closes #1113

8463 of 12523 branches covered (67.58%)

Branch coverage included in aggregate %.

13 of 13 new or added lines in 2 files covered. (100.0%)

72 existing lines in 4 files now uncovered.

50647 of 79617 relevant lines covered (63.61%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.67
/src/KeenEyes.Core/Systems/AutoSaveSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Serialization;
3

4
namespace KeenEyes.Systems;
5

6
/// <summary>
7
/// A system that automatically saves the world state at configurable intervals.
8
/// </summary>
9
/// <remarks>
10
/// <para>
11
/// The AutoSaveSystem monitors elapsed time and optionally entity changes to trigger
12
/// automatic saves. It supports both full snapshots and delta (incremental) saves.
13
/// </para>
14
/// <para>
15
/// Delta saves are significantly smaller than full saves when few entities change
16
/// between saves. The system automatically creates a new baseline after a configurable
17
/// number of deltas to prevent long restoration chains.
18
/// </para>
19
/// <para>
20
/// This system requires the world to implement <see cref="ISaveLoadCapability"/>.
21
/// The standard <see cref="World"/> class provides this capability.
22
/// </para>
23
/// </remarks>
24
/// <example>
25
/// <code>
26
/// // Create auto-save system with default config
27
/// var autoSave = new AutoSaveSystem&lt;MySerializer&gt;(serializer);
28
/// world.AddSystem(autoSave, SystemPhase.PostRender);
29
///
30
/// // Configure for more frequent saves
31
/// autoSave.Config = AutoSaveConfig.Frequent;
32
///
33
/// // Manually trigger a save
34
/// autoSave.SaveNow();
35
/// </code>
36
/// </example>
37
/// <typeparam name="TSerializer">The serializer type that implements both serialization interfaces.</typeparam>
38
public sealed class AutoSaveSystem<TSerializer> : SystemBase
39
    where TSerializer : IComponentSerializer, IBinaryComponentSerializer
40
{
41
    private readonly TSerializer serializer;
42
    private AutoSaveConfig config;
43

44
    private float timeSinceLastSave;
45
    private int currentDeltaSequence;
46
    private WorldSnapshot? baselineSnapshot;
47
    private bool isInitialized;
48

49
    /// <summary>
50
    /// Creates a new auto-save system with the specified serializer.
51
    /// </summary>
52
    /// <param name="serializer">The component serializer for AOT-compatible serialization.</param>
53
    /// <param name="config">Optional configuration. Uses default if not specified.</param>
54
    public AutoSaveSystem(TSerializer serializer, AutoSaveConfig? config = null)
1✔
55
    {
56
        ArgumentNullException.ThrowIfNull(serializer);
1✔
57
        this.serializer = serializer;
1✔
58
        this.config = config ?? AutoSaveConfig.Default;
1✔
59
    }
1✔
60

61
    /// <summary>
62
    /// Gets or sets the auto-save configuration.
63
    /// </summary>
64
    public AutoSaveConfig Config
65
    {
66
        get => config;
1✔
67
        set
68
        {
69
            ArgumentNullException.ThrowIfNull(value);
1✔
70
            config = value;
1✔
71
        }
1✔
72
    }
73

74
    /// <summary>
75
    /// Gets the time elapsed since the last save.
76
    /// </summary>
77
    public float TimeSinceLastSave => timeSinceLastSave;
1✔
78

79
    /// <summary>
80
    /// Gets the current delta sequence number (0 if no saves have occurred).
81
    /// </summary>
82
    public int CurrentDeltaSequence => currentDeltaSequence;
1✔
83

84
    /// <summary>
85
    /// Gets whether a baseline snapshot exists.
86
    /// </summary>
87
    public bool HasBaseline => baselineSnapshot is not null;
1✔
88

89
    /// <summary>
90
    /// Event raised when an auto-save occurs.
91
    /// </summary>
92
    public event Action<SaveSlotInfo>? OnAutoSave;
93

94
    /// <summary>
95
    /// Event raised when an auto-save fails.
96
    /// </summary>
97
    public event Action<Exception>? OnAutoSaveError;
98

99
    /// <inheritdoc />
100
    protected override void OnInitialize()
101
    {
102
        isInitialized = true;
1✔
103

104
        // Try to load existing baseline if it exists
105
        if (World is ISaveLoadCapability saveLoad && saveLoad.SaveSlotExists(config.BaselineSlotName))
1✔
106
        {
107
            try
108
            {
109
                var info = saveLoad.GetSaveSlotInfo(config.BaselineSlotName);
1✔
110
                if (info is not null)
1✔
111
                {
112
                    // Read the baseline snapshot for delta comparison WITHOUT mutating the
113
                    // live world. Using LoadFromSlot here would clear the current scene and
114
                    // replace it with the baseline's entities (see issue #1131).
115
                    baselineSnapshot = saveLoad.ReadSnapshotFromSlot(config.BaselineSlotName, serializer);
1✔
116

117
                    // Find the highest existing delta sequence
118
                    currentDeltaSequence = FindHighestDeltaSequence(saveLoad);
1✔
119
                }
120
            }
1✔
UNCOV
121
            catch
×
122
            {
123
                // If loading fails, we'll create a new baseline on first save
124
                baselineSnapshot = null;
×
125
                currentDeltaSequence = 0;
×
UNCOV
126
            }
×
127
        }
128
    }
1✔
129

130
    /// <inheritdoc />
131
    public override void Update(float deltaTime)
132
    {
133
        if (!config.Enabled || !isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
134
        {
135
            return;
1✔
136
        }
137

138
        timeSinceLastSave += deltaTime;
1✔
139

140
        // Check if we should save
141
        bool shouldSave = false;
1✔
142

143
        // Time-based trigger
144
        if (config.AutoSaveIntervalSeconds > 0 && timeSinceLastSave >= config.AutoSaveIntervalSeconds)
1✔
145
        {
146
            shouldSave = true;
1✔
147
        }
148

149
        if (shouldSave)
1✔
150
        {
151
            PerformAutoSave(saveLoad);
1✔
152
        }
153
    }
1✔
154

155
    /// <summary>
156
    /// Manually triggers an auto-save immediately.
157
    /// </summary>
158
    /// <returns>The save slot info, or null if save failed.</returns>
159
    public SaveSlotInfo? SaveNow()
160
    {
161
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
162
        {
163
            return null;
1✔
164
        }
165

166
        return PerformAutoSave(saveLoad);
1✔
167
    }
168

169
    /// <summary>
170
    /// Creates a new baseline snapshot, resetting the delta chain.
171
    /// </summary>
172
    /// <returns>The save slot info for the new baseline.</returns>
173
    public SaveSlotInfo? CreateNewBaseline()
174
    {
175
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
176
        {
177
            return null;
1✔
178
        }
179

180
        return SaveBaseline(saveLoad);
1✔
181
    }
182

183
    /// <summary>
184
    /// Resets the auto-save state, clearing any existing baseline.
185
    /// </summary>
186
    public void Reset()
187
    {
188
        timeSinceLastSave = 0;
1✔
189
        currentDeltaSequence = 0;
1✔
190
        baselineSnapshot = null;
1✔
191
    }
1✔
192

193
    private SaveSlotInfo? PerformAutoSave(ISaveLoadCapability saveLoad)
194
    {
195
        try
196
        {
197
            SaveSlotInfo info;
198

199
            if (config.UseDeltaSaves && baselineSnapshot is not null)
1✔
200
            {
201
                // Check if we should create a new baseline
202
                if (currentDeltaSequence >= config.MaxDeltasBeforeBaseline)
1✔
203
                {
204
                    info = SaveBaseline(saveLoad);
1✔
205
                }
206
                else
207
                {
208
                    info = SaveDelta(saveLoad);
1✔
209
                }
210
            }
211
            else
212
            {
213
                // Create baseline (either first save or full saves mode)
214
                info = SaveBaseline(saveLoad);
1✔
215
            }
216

217
            // Reset timer
218
            timeSinceLastSave = 0;
1✔
219

220
            // Clear dirty flags if configured
221
            if (config.ClearDirtyFlagsAfterSave)
1✔
222
            {
223
                saveLoad.ClearAllDirtyFlags();
1✔
224
            }
225

226
            // Raise event
227
            OnAutoSave?.Invoke(info);
1✔
228

229
            return info;
1✔
230
        }
UNCOV
231
        catch (Exception ex)
×
232
        {
233
            OnAutoSaveError?.Invoke(ex);
×
UNCOV
234
            return null;
×
235
        }
236
    }
1✔
237

238
    private SaveSlotInfo SaveBaseline(ISaveLoadCapability saveLoad)
239
    {
240
        // Create snapshot
241
        var snapshot = saveLoad.CreateSnapshot(serializer);
1✔
242

243
        // Save as baseline
244
        var info = saveLoad.SaveToSlot(config.BaselineSlotName, serializer, config.SaveOptions);
1✔
245

246
        // Update state
247
        baselineSnapshot = snapshot;
1✔
248
        currentDeltaSequence = 0;
1✔
249

250
        // Clean up old deltas
251
        CleanupOldDeltas(saveLoad);
1✔
252

253
        return info;
1✔
254
    }
255

256
    private SaveSlotInfo SaveDelta(ISaveLoadCapability saveLoad)
257
    {
258
        // Increment sequence
259
        currentDeltaSequence++;
1✔
260

261
        // Create true delta by comparing current state to baseline
262
        var delta = saveLoad.CreateDelta(
1✔
263
            baselineSnapshot!,
1✔
264
            serializer,
1✔
265
            config.BaselineSlotName,
1✔
266
            currentDeltaSequence);
1✔
267

268
        // If delta is empty, skip saving
269
        if (delta.IsEmpty)
1✔
270
        {
271
            currentDeltaSequence--;
1✔
272
            // Return a placeholder info for the baseline
273
            return saveLoad.GetSaveSlotInfo(config.BaselineSlotName)
1✔
274
                ?? throw new InvalidOperationException("Baseline slot not found");
1✔
275
        }
276

277
        // Save delta to slot
278
        var slotName = config.GetDeltaSlotName(currentDeltaSequence);
1✔
279
        var info = saveLoad.SaveDeltaToSlot(slotName, delta, serializer, config.SaveOptions with
1✔
280
        {
1✔
281
            DisplayName = $"Auto-save (Delta #{currentDeltaSequence})"
1✔
282
        });
1✔
283

284
        return info;
1✔
285
    }
286

287
    private void CleanupOldDeltas(ISaveLoadCapability saveLoad)
288
    {
289
        // Remove old delta files when a new baseline is created
290
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + 5; i++)
1✔
291
        {
292
            var slotName = config.GetDeltaSlotName(i);
1✔
293
            if (saveLoad.SaveSlotExists(slotName))
1✔
294
            {
295
                saveLoad.DeleteSaveSlot(slotName);
1✔
296
            }
297
        }
298
    }
1✔
299

300
    private int FindHighestDeltaSequence(ISaveLoadCapability saveLoad)
301
    {
302
        int highest = 0;
1✔
303
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + 5; i++)
1✔
304
        {
305
            if (saveLoad.SaveSlotExists(config.GetDeltaSlotName(i)))
1✔
306
            {
UNCOV
307
                highest = i;
×
308
            }
309
        }
310
        return highest;
1✔
311
    }
312
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc