Enhance school management and protocol handling by introducing a new worker failure mechanism, ensuring proper cleanup of schools when a worker thread encounters an error. Update the map snapshot protocol to allow larger school data sizes, improving the handling of extensive map layouts. Revise documentation to reflect changes in save intervals and introduce a minimum save interval for better performance. Update tests to validate the new functionalities and ensure robustness in handling large data scenarios.
This commit is contained in:
@@ -35,4 +35,10 @@ internal abstract record GameCommand
|
||||
|
||||
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
|
||||
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
||||
|
||||
/// <summary>
|
||||
/// A worker thread ended on an exception. The supervisor owns the table, so the worker asks
|
||||
/// it to drop the school instead of removing itself.
|
||||
/// </summary>
|
||||
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
|
||||
}
|
||||
|
||||
@@ -144,9 +144,39 @@ internal sealed class GameLoopService(
|
||||
case GameCommand.ReloadSaves reload:
|
||||
await HandleReloadAsync(reload).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case GameCommand.WorkerFailed failed:
|
||||
HandleWorkerFailed(failed.SchoolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock
|
||||
/// never moves again, and tell anybody watching it to go back to the menu. The save file stays
|
||||
/// where it is — a restart or <c>reload-schools</c> is the way back.
|
||||
/// </summary>
|
||||
private void HandleWorkerFailed(int schoolId)
|
||||
{
|
||||
if (!_workers.ContainsKey(schoolId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Untrack(schoolId);
|
||||
|
||||
foreach (var client in clients.All)
|
||||
{
|
||||
if (client.OpenSchoolId == schoolId)
|
||||
{
|
||||
client.OpenSchoolId = null;
|
||||
SendSchoolGone(client, schoolId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogError("School {SchoolId} stopped after a worker failure; its save file is unchanged.", schoolId);
|
||||
}
|
||||
|
||||
private async Task HandleCreateAsync(GameCommand.CreateSchool command)
|
||||
{
|
||||
try
|
||||
@@ -373,6 +403,11 @@ internal sealed class GameLoopService(
|
||||
await worker.StopAsync(persist: false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopAllWorkersAsync(bool persist)
|
||||
@@ -386,11 +421,6 @@ internal sealed class GameLoopService(
|
||||
{
|
||||
await Task.WhenAll(stopping).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private SchoolWorker SpawnWorker(
|
||||
@@ -416,6 +446,7 @@ internal sealed class GameLoopService(
|
||||
metrics,
|
||||
store,
|
||||
mods,
|
||||
onFailed: schoolId => commands.Enqueue(new GameCommand.WorkerFailed(schoolId)),
|
||||
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
|
||||
|
||||
private void Track(SchoolWorker worker)
|
||||
|
||||
@@ -92,6 +92,11 @@ internal sealed class SchoolStore
|
||||
{
|
||||
var saves = new List<SchoolSave>();
|
||||
|
||||
// The id inside the file decides which school this is and which file it is saved back to,
|
||||
// so two files claiming the same id would give the menu two cards over one worker and then
|
||||
// overwrite each other on the next save.
|
||||
var claimed = new Dictionary<int, string>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(DirectoryPath, "*.json"))
|
||||
{
|
||||
if (string.Equals(Path.GetFileName(path), IndexFileName, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -109,6 +114,15 @@ internal sealed class SchoolStore
|
||||
continue;
|
||||
}
|
||||
|
||||
if (save.Id <= 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Save {Path} has id {Id}; ids start at 1. Leaving the file in place.",
|
||||
path,
|
||||
save.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!GameClock.IsValidStartDate(save.GameTime))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -117,6 +131,17 @@ internal sealed class SchoolStore
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claimed last, so a file rejected above does not reserve an id a good file needs.
|
||||
if (!claimed.TryAdd(save.Id, path))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Save {Path} claims id {Id}, already taken by {Owner}; leaving the file in place.",
|
||||
path,
|
||||
save.Id,
|
||||
claimed[save.Id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
saves.Add(new SchoolSave
|
||||
{
|
||||
Format = save.Format,
|
||||
|
||||
@@ -29,6 +29,7 @@ internal sealed class SchoolWorker
|
||||
private readonly bool _isNew;
|
||||
private readonly IReadOnlyList<string>? _modIds;
|
||||
private readonly MapLayout? _savedMap;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
private readonly int _id;
|
||||
private readonly string _name;
|
||||
@@ -40,6 +41,8 @@ internal sealed class SchoolWorker
|
||||
private School? _school;
|
||||
private Task? _run;
|
||||
private bool _persistOnStop = true;
|
||||
private bool _settingsDirty;
|
||||
private long _lastSettingsSave;
|
||||
|
||||
public SchoolWorker(
|
||||
int id,
|
||||
@@ -55,6 +58,7 @@ internal sealed class SchoolWorker
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
Action<int> onFailed,
|
||||
ILogger logger)
|
||||
{
|
||||
_id = id;
|
||||
@@ -70,6 +74,7 @@ internal sealed class SchoolWorker
|
||||
_metrics = metrics;
|
||||
_store = store;
|
||||
_mods = mods;
|
||||
_onFailed = onFailed;
|
||||
_logger = logger;
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||
}
|
||||
@@ -127,11 +132,35 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
_logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id);
|
||||
_started.TrySetException(ex);
|
||||
ReportFailure();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
||||
_started.TrySetException(ex);
|
||||
ReportFailure();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and
|
||||
/// the menu kept drawing its card with a frozen clock, as if the school were alive.
|
||||
/// </summary>
|
||||
private void ReportFailure()
|
||||
{
|
||||
if (_stopping.IsCancellationRequested)
|
||||
{
|
||||
// Already being torn down on purpose; the supervisor knows.
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_onFailed(_id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +248,8 @@ internal sealed class SchoolWorker
|
||||
BroadcastClock();
|
||||
}
|
||||
|
||||
FlushSettings();
|
||||
|
||||
if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval)
|
||||
{
|
||||
Persist();
|
||||
@@ -272,32 +303,45 @@ internal sealed class SchoolWorker
|
||||
|
||||
while (_mailbox.Reader.TryRead(out var command))
|
||||
{
|
||||
switch (command)
|
||||
// Every command here was triggered by a browser. One of them failing — an oversized
|
||||
// snapshot, a client that vanished mid-send — must cost that command, not the school.
|
||||
try
|
||||
{
|
||||
case WorkerCommand.Open open:
|
||||
open.Client.OpenSchoolId = _id;
|
||||
SendMapSnapshot(open.Client, school);
|
||||
BroadcastClockTo(open.Client, school);
|
||||
break;
|
||||
switch (command)
|
||||
{
|
||||
case WorkerCommand.Open open:
|
||||
open.Client.OpenSchoolId = _id;
|
||||
SendMapSnapshot(open.Client, school);
|
||||
BroadcastClockTo(open.Client, school);
|
||||
break;
|
||||
|
||||
case WorkerCommand.Close close:
|
||||
var leaving = _clients.Find(close.PlayerId);
|
||||
if (leaving?.OpenSchoolId == _id)
|
||||
{
|
||||
leaving.OpenSchoolId = null;
|
||||
}
|
||||
case WorkerCommand.Close close:
|
||||
var leaving = _clients.Find(close.PlayerId);
|
||||
if (leaving?.OpenSchoolId == _id)
|
||||
{
|
||||
leaving.OpenSchoolId = null;
|
||||
}
|
||||
|
||||
break;
|
||||
break;
|
||||
|
||||
case WorkerCommand.SetRunning setRunning:
|
||||
school.Clock.IsRunning = setRunning.Running;
|
||||
dirty = true;
|
||||
break;
|
||||
case WorkerCommand.SetRunning setRunning:
|
||||
school.Clock.IsRunning = setRunning.Running;
|
||||
dirty = true;
|
||||
break;
|
||||
|
||||
case WorkerCommand.SetSpeed setSpeed:
|
||||
school.Clock.SpeedIndex = setSpeed.SpeedIndex;
|
||||
dirty = true;
|
||||
break;
|
||||
case WorkerCommand.SetSpeed setSpeed:
|
||||
school.Clock.SpeedIndex = setSpeed.SpeedIndex;
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Command {Command} failed for school {SchoolId}; the school keeps running.",
|
||||
command.GetType().Name,
|
||||
_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +349,29 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
PublishSnapshot();
|
||||
BroadcastClock();
|
||||
Persist();
|
||||
|
||||
// Not written here: a client can send SetSpeed as fast as the socket allows, and each
|
||||
// one used to be a synchronous file write on this thread. FlushSettings coalesces them.
|
||||
_settingsDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
|
||||
/// A single click still lands within that window; a burst collapses into one write.
|
||||
/// </summary>
|
||||
private void FlushSettings()
|
||||
{
|
||||
if (!_settingsDirty || Stopwatch.GetElapsedTime(_lastSettingsSave) < _options.MinSaveInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Persist();
|
||||
_settingsDirty = false;
|
||||
_lastSettingsSave = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
private void PublishSnapshot()
|
||||
{
|
||||
var school = _school;
|
||||
@@ -335,17 +398,25 @@ internal sealed class SchoolWorker
|
||||
return;
|
||||
}
|
||||
|
||||
_store.Save(new SchoolSave
|
||||
// A full disk or a locked file must not end the school; the next save will try again.
|
||||
try
|
||||
{
|
||||
Format = SchoolStore.CurrentFormat,
|
||||
Id = school.Id,
|
||||
Name = school.Name,
|
||||
GameTime = school.Clock.Time,
|
||||
Running = school.Clock.IsRunning,
|
||||
SpeedIndex = school.Clock.SpeedIndex,
|
||||
ModIds = school.Catalog?.PackIds,
|
||||
Map = school.Map,
|
||||
});
|
||||
_store.Save(new SchoolSave
|
||||
{
|
||||
Format = SchoolStore.CurrentFormat,
|
||||
Id = school.Id,
|
||||
Name = school.Name,
|
||||
GameTime = school.Clock.Time,
|
||||
Running = school.Clock.IsRunning,
|
||||
SpeedIndex = school.Clock.SpeedIndex,
|
||||
ModIds = school.Catalog?.PackIds,
|
||||
Map = school.Map,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not save school {SchoolId}; it keeps running unsaved.", _id);
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastClock()
|
||||
@@ -387,8 +458,11 @@ internal sealed class SchoolWorker
|
||||
node.Positions);
|
||||
}
|
||||
|
||||
var frame = new byte[ProtocolConstants.MaxMessageSize];
|
||||
var length = ProtocolCodec.WriteMapSnapshot(frame, new ServerMapSnapshotMessage(school.Id, nodes));
|
||||
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
|
||||
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
|
||||
var message = new ServerMapSnapshotMessage(school.Id, nodes);
|
||||
var frame = new byte[ProtocolCodec.MapSnapshotSize(message)];
|
||||
var length = ProtocolCodec.WriteMapSnapshot(frame, message);
|
||||
client.TrySendReliable(frame.AsMemory(0, length));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user