Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 95 additions & 25 deletions src/FiveStack.Services/GameDemos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,69 @@ public async Task UploadDemos()
_logger.LogInformation("Uploaded all demos");
}

// Scans the demos directory for leftover .dem files and attempts to upload
// them. Demos are normally uploaded by the GameEnd timer chain, but that
// lives entirely in memory — a server crash/restart during the post-match
// window drops the pending upload and orphans the file on disk. Running this
// on startup self-heals those cases. Safe to retry: the API rejects demos
// for maps that aren't finished (409) and cleans up ones already uploaded
// (406) or whose map is gone (410).
public async Task UploadOrphanedDemos()
{
if (_environmentService.IsOfflineMode())
{
return;
}

string demosRoot = $"{_rootDir}/demos";
if (!Directory.Exists(demosRoot))
{
return;
}

string[] files;
try
{
files = Directory.GetFiles(demosRoot, "*.dem", SearchOption.AllDirectories);
}
catch (Exception ex)
{
_logger.LogError($"Failed to scan for orphaned demos: {ex.Message}");
return;
}
Comment on lines +169 to +173

if (files.Length == 0)
{
return;
}

_logger.LogInformation(
$"Found {files.Length} demo(s) on disk, attempting to recover uploads"
);

foreach (string file in files)
{
// Skip a demo that belongs to a match that is actively recording right
// now, so we never upload a partially-written file.
string? matchId = Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(file)));
if (
Guid.TryParse(matchId, out Guid parsedMatchId)
&& File.Exists(GetLockFilePath(parsedMatchId))
)
{
_logger.LogInformation(
$"Skipping demo for actively recording match {matchId}: {file}"
);
continue;
}

_logger.LogInformation($"Recovering orphaned demo {file}");
await UploadDemo(file);
}

_logger.LogInformation("Finished recovering orphaned demos");
}

public async Task UploadDemo(string filePath)
{
try
Expand All @@ -150,27 +213,40 @@ public async Task UploadDemo(string filePath)
return;
}

MatchData? match = _matchService.GetCurrentMatch()?.GetMatchData();

string? serverId = _environmentService.GetServerId();
string? apiPassword = _environmentService.GetServerApiPassword();

if (serverId == null || apiPassword == null || match == null)
if (serverId == null || apiPassword == null)
{
return;
}

// Demos live at {_rootDir}/demos/{matchId}/{mapId}/{demo}.dem — derive
// the ids from the path rather than GetCurrentMatch() so uploads work
// even when the demo belongs to a match that is no longer current
// (e.g. recovered on startup after a crash/restart).
string demoName = Path.GetFileName(filePath);
string? mapId = Path.GetFileName(Path.GetDirectoryName(filePath));
string? matchId = Path.GetFileName(
Path.GetDirectoryName(Path.GetDirectoryName(filePath))
);

string? presignedUrl = await GetPresignedUrl(filePath);
if (string.IsNullOrEmpty(presignedUrl))
if (!Guid.TryParse(matchId, out _) || !Guid.TryParse(mapId, out _))
{
_logger.LogCritical(
$"Failed to get presigned URL (match {match.id} map {match.current_match_map_id} demo {demoName})"
_logger.LogWarning(
$"Skipping demo with unexpected path (cannot derive match/map ids): {filePath}"
);
return;
}

string? presignedUrl = await GetPresignedUrl(matchId, mapId, filePath);
if (string.IsNullOrEmpty(presignedUrl))
{
// GetPresignedUrl already logs the reason (and cleans up the file
// when the map is already uploaded or gone).
return;
}

using var httpClient = new HttpClient();
httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan;

Expand All @@ -185,25 +261,25 @@ public async Task UploadDemo(string filePath)
request.Content.Headers.ContentLength = fileInfo.Length;

_logger.LogInformation(
$"PUT demo {demoName} ({fileInfo.Length} bytes) for match {match.id}"
$"PUT demo {demoName} ({fileInfo.Length} bytes) for match {matchId}"
);

var response = await httpClient.SendAsync(request);

_logger.LogInformation(
$"demo PUT response {(int)response.StatusCode} {response.StatusCode} (match {match.id} demo {demoName})"
$"demo PUT response {(int)response.StatusCode} {response.StatusCode} (match {matchId} demo {demoName})"
);

if (response.IsSuccessStatusCode)
{
_logger.LogInformation($"demo uploaded (match {match.id} demo {demoName})");
_logger.LogInformation($"demo uploaded (match {matchId} demo {demoName})");

var notifyEndpoint =
$"{_environmentService.GetDemosUrl()}/demos/{match.id}/uploaded";
$"{_environmentService.GetDemosUrl()}/demos/{matchId}/uploaded";
var notifyRequest = new
{
demo = demoName,
mapId = match.current_match_map_id,
mapId = mapId,
size = fileInfo.Length,
};

Expand All @@ -216,7 +292,7 @@ public async Task UploadDemo(string filePath)
);

_logger.LogInformation(
$"demo uploaded notify response {(int)notifyResponse.StatusCode} {notifyResponse.StatusCode} (match {match.id} demo {demoName})"
$"demo uploaded notify response {(int)notifyResponse.StatusCode} {notifyResponse.StatusCode} (match {matchId} demo {demoName})"
);

if (notifyResponse.IsSuccessStatusCode)
Expand All @@ -242,22 +318,16 @@ public async Task UploadDemo(string filePath)
}
}

private async Task<string?> GetPresignedUrl(string filePath)
private async Task<string?> GetPresignedUrl(string matchId, string mapId, string filePath)
{
MatchData? match = _matchService.GetCurrentMatch()?.GetMatchData();

if (match == null)
{
return null;
}

string? apiPassword = _environmentService.GetServerApiPassword();
if (apiPassword == null)
{
return null;
}

string endpoint = $"{_environmentService.GetDemosUrl()}/demos/{match.id}/pre-signed";
string demoName = Path.GetFileName(filePath);
string endpoint = $"{_environmentService.GetDemosUrl()}/demos/{matchId}/pre-signed";

using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
Expand All @@ -267,14 +337,14 @@ public async Task UploadDemo(string filePath)

var requestBody = new
{
demo = Path.GetFileName(filePath),
mapId = _matchService.GetCurrentMatch()?.GetMatchData()?.current_match_map_id,
demo = demoName,
mapId = mapId,
};

var response = await httpClient.PostAsJsonAsync(endpoint, requestBody);

_logger.LogInformation(
$"presigned url response {(int)response.StatusCode} {response.StatusCode} (match {match.id} map {match.current_match_map_id} demo {Path.GetFileName(filePath)})"
$"presigned url response {(int)response.StatusCode} {response.StatusCode} (match {matchId} map {mapId} demo {demoName})"
);

switch (response.StatusCode)
Expand Down
16 changes: 16 additions & 0 deletions src/FiveStackPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ public override void Load(bool hotReload)
{
_matchService.GetMatchFromOffline();
}

// Recover any demos left on disk by a previous crash/restart. Deferred so
// env + API connectivity have settled, and off the main thread since it is
// pure file IO + HTTP and never touches game state.
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(15));
try
{
await _gameDemos.UploadOrphanedDemos();
}
catch (Exception ex)
{
_logger.LogError($"Failed to recover orphaned demos: {ex.Message}");
}
Comment on lines +117 to +120
});
}

public override void Unload(bool hotReload)
Expand Down