using System.Collections.Concurrent;
using System.Linq.Expressions;
using LANCommander.SDK.Enums;
using LANCommander.Server.Services.Abstractions;
using LANCommander.Server.Services.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Server.Services;
///
/// Singleton coordinator for the full lifetime of game servers. Owns engine routing (which
/// manages a given server), tracking refresh, autostart (including
/// the boot-time pass), and debounced autostop. Server lifecycle is process-wide state, so it
/// can't live in the request-scoped .
///
public sealed class ServerManager(
ILogger logger,
IServiceScopeFactory scopeFactory,
IEnumerable serverEngines,
SettingsProvider settingsProvider)
{
// Pending debounced stops keyed by gameId. Held here (singleton) because the request-scoped
// ServerService can't keep pending-stop state across requests.
private readonly ConcurrentDictionary _pendingStops = new();
#region Initialization & tracking
///
/// Initializes every engine and primes its tracking. Called once at application start.
///
public async Task InitializeAsync()
{
foreach (var engine in serverEngines)
{
await engine.InitializeAsync();
await engine.RefreshTrackingAsync();
}
}
///
/// Re-syncs each engine's tracked-server set with the database. Called after a server is
/// added or edited so freshly added/edited servers become immediately manageable.
///
public async Task RefreshTrackingAsync()
{
foreach (var engine in serverEngines)
await engine.RefreshTrackingAsync();
}
public bool IsManaging(Guid serverId) => serverEngines.Any(engine => engine.IsManaging(serverId));
#endregion
#region Routing
public async Task StartAsync(Guid serverId)
{
foreach (var engine in serverEngines)
{
if (engine.IsManaging(serverId))
{
await engine.StartAsync(serverId);
return;
}
}
}
public async Task StopAsync(Guid serverId)
{
foreach (var engine in serverEngines)
{
if (engine.IsManaging(serverId))
{
await engine.StopAsync(serverId);
return;
}
}
}
public async Task GetStatusAsync(Guid serverId)
{
foreach (var engine in serverEngines)
{
if (engine.IsManaging(serverId))
return await engine.GetStatusAsync(serverId);
}
return ServerProcessStatus.Stopped;
}
#endregion
#region Autostart
///
/// Starts a game's autostart servers matching the given method. Each start is fire-and-forget
/// because blocks for the server process's lifetime.
/// Cancels any pending debounced stop for the game first, so a returning player keeps the
/// servers running.
///
public async Task AutostartAsync(Guid gameId, ServerAutostartMethod method)
{
CancelPendingStop(gameId);
try
{
var servers = await GetServersAsync(s =>
s.GameId == gameId && s.Autostart && s.AutostartMethod == method);
foreach (var engine in serverEngines)
{
foreach (var server in servers)
{
try
{
var status = await engine.GetStatusAsync(server.Id);
if (engine.IsManaging(server.Id) && status == ServerProcessStatus.Stopped)
_ = engine.StartAsync(server.Id);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to start server {ServerName} ({ServerId})", server.Name, server.Id);
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Servers could not be autostarted");
}
}
///
/// Autostarts every server configured for ,
/// honoring each server's configured startup delay. Called once at boot.
///
public async Task AutostartApplicationServersAsync()
{
var servers = await GetServersAsync(s =>
s.Autostart && s.AutostartMethod == ServerAutostartMethod.OnApplicationStart);
foreach (var server in servers)
{
var serverId = server.Id;
var serverName = server.Name;
var delaySeconds = server.AutostartDelay;
logger.LogDebug("Autostarting server {ServerName} with a delay of {AutostartDelay} seconds", serverName, delaySeconds);
_ = Task.Run(async () =>
{
try
{
if (delaySeconds > 0)
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
await StartAsync(serverId);
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred while trying to autostart the server {ServerName}", serverName);
}
});
}
}
#endregion
#region Autostop
///
/// Immediately stops a game's autostart servers matching the given method.
///
public async Task AutostopAsync(Guid gameId, ServerAutostartMethod method)
{
try
{
var servers = await GetServersAsync(s =>
s.GameId == gameId && s.Autostart && s.AutostartMethod == method);
foreach (var engine in serverEngines)
{
foreach (var server in servers)
{
try
{
var status = await engine.GetStatusAsync(server.Id);
if (engine.IsManaging(server.Id) && status != ServerProcessStatus.Stopped)
await engine.StopAsync(server.Id);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to stop server {ServerName} ({ServerId})", server.Name, server.Id);
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Servers could not be autostopped");
}
}
///
/// Stops every tracked server across all engines. Used when the application needs to bring
/// everything down (e.g. before applying a server update).
///
public async Task StopAllAsync()
{
var servers = await GetServersAsync(_ => true);
foreach (var engine in serverEngines)
{
foreach (var server in servers)
{
if (engine.IsManaging(server.Id))
await engine.StopAsync(server.Id);
}
}
}
///
/// Cancels any pending autostop for the game (e.g. a player relaunched within the delay).
///
public void CancelPendingStop(Guid gameId)
{
if (_pendingStops.TryRemove(gameId, out var cts))
{
cts.Cancel();
cts.Dispose();
}
}
///
/// Schedules the game's on-player-activity servers to stop after the configured delay,
/// resetting any already-pending stop. Debounces stop/start thrash when players relaunch.
///
public void ScheduleStop(Guid gameId)
{
var delay = TimeSpan.FromSeconds(Math.Max(0, settingsProvider.CurrentValue.Server.GameServers.AutostopDelay));
CancelPendingStop(gameId);
var cts = new CancellationTokenSource();
_pendingStops[gameId] = cts;
_ = RunDelayedStopAsync(gameId, delay, cts.Token);
}
private async Task RunDelayedStopAsync(Guid gameId, TimeSpan delay, CancellationToken cancellationToken)
{
try
{
await Task.Delay(delay, cancellationToken);
using var scope = scopeFactory.CreateScope();
var playSessionService = scope.ServiceProvider.GetRequiredService();
// Re-check after the delay: a player may have started again without us having observed
// a cancellation, so the authoritative source is the session table.
var activeSessions = await playSessionService.GetAsync(ps => ps.GameId == gameId && ps.End == null);
if (!activeSessions.Any())
await AutostopAsync(gameId, ServerAutostartMethod.OnPlayerActivity);
}
catch (OperationCanceledException)
{
// A player relaunched within the debounce window; leave the servers running.
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to autostop servers for game {GameId}", gameId);
}
finally
{
_pendingStops.TryRemove(gameId, out _);
}
}
#endregion
private async Task> GetServersAsync(Expression> predicate)
{
using var scope = scopeFactory.CreateScope();
var serverService = scope.ServiceProvider.GetRequiredService();
return await serverService.GetAsync(predicate);
}
}