From 07844cc9c5c23877dde2dce137ad2f19afe10c24 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Wed, 4 Feb 2026 22:50:35 -0500 Subject: [PATCH] Add GC hints to prevent memory leaks from large byte arrays --- allstarr/Controllers/AdminController.cs | 85 +++++++++++++++++-- .../Services/Jellyfin/JellyfinProxyService.cs | 27 ++++++ .../Services/Subsonic/SubsonicProxyService.cs | 6 ++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index ace53d4..918f463 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -1911,23 +1911,36 @@ public class AdminController : ControllerBase { try { + // Get memory stats BEFORE GC + var memoryBeforeGC = GC.GetTotalMemory(false); + var gen0Before = GC.CollectionCount(0); + var gen1Before = GC.CollectionCount(1); + var gen2Before = GC.CollectionCount(2); + // Force garbage collection to get accurate numbers GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); - var memoryUsage = GC.GetTotalMemory(false); - var gen0 = GC.CollectionCount(0); - var gen1 = GC.CollectionCount(1); - var gen2 = GC.CollectionCount(2); + var memoryAfterGC = GC.GetTotalMemory(false); + var gen0After = GC.CollectionCount(0); + var gen1After = GC.CollectionCount(1); + var gen2After = GC.CollectionCount(2); // Get process memory info var process = System.Diagnostics.Process.GetCurrentProcess(); return Ok(new { Timestamp = DateTime.UtcNow, - GCMemoryBytes = memoryUsage, - GCMemoryMB = Math.Round(memoryUsage / (1024.0 * 1024.0), 2), + BeforeGC = new { + GCMemoryBytes = memoryBeforeGC, + GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2) + }, + AfterGC = new { + GCMemoryBytes = memoryAfterGC, + GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2) + }, + MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2), ProcessWorkingSetBytes = process.WorkingSet64, ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2), ProcessPrivateMemoryBytes = process.PrivateMemorySize64, @@ -1935,9 +1948,15 @@ public class AdminController : ControllerBase ProcessVirtualMemoryBytes = process.VirtualMemorySize64, ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2), GCCollections = new { - Gen0 = gen0, - Gen1 = gen1, - Gen2 = gen2 + Gen0Before = gen0Before, + Gen0After = gen0After, + Gen0Triggered = gen0After - gen0Before, + Gen1Before = gen1Before, + Gen1After = gen1After, + Gen1Triggered = gen1After - gen1Before, + Gen2Before = gen2Before, + Gen2After = gen2After, + Gen2Triggered = gen2After - gen2Before }, GCMode = GCSettings.IsServerGC ? "Server" : "Workstation", GCLatencyMode = GCSettings.LatencyMode.ToString() @@ -1948,6 +1967,54 @@ public class AdminController : ControllerBase return BadRequest(new { error = ex.Message }); } } + + /// + /// Forces garbage collection to free up memory (emergency use only). + /// + [HttpPost("force-gc")] + public IActionResult ForceGarbageCollection() + { + try + { + var memoryBefore = GC.GetTotalMemory(false); + var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; + + // Force full garbage collection + GC.Collect(2, GCCollectionMode.Forced); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced); + + var memoryAfter = GC.GetTotalMemory(false); + var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; + + return Ok(new { + Timestamp = DateTime.UtcNow, + MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2), + ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2), + BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2), + AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2), + BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2), + AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2) + }); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Helper method to trigger GC after large file operations to prevent memory leaks. + /// + private static void TriggerGCAfterLargeOperation(int sizeInBytes) + { + // Only trigger GC for files larger than 1MB to avoid performance impact + if (sizeInBytes > 1024 * 1024) + { + // Suggest GC collection for large objects (they go to LOH and aren't collected as frequently) + GC.Collect(2, GCCollectionMode.Optimized, blocking: false); + } + } } public class ConfigUpdateRequest diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 8547d95..187a7a8 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -409,6 +409,7 @@ public class JellyfinProxyService /// /// Sends a GET request and returns raw bytes (for images, audio streams). + /// WARNING: This loads entire response into memory - use StreamAsync for large files! /// public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary? queryParams = null) { @@ -423,9 +424,35 @@ public class JellyfinProxyService var body = await response.Content.ReadAsByteArrayAsync(); var contentType = response.Content.Headers.ContentType?.ToString(); + // Trigger GC for large files to prevent memory leaks + if (body.Length > 1024 * 1024) // 1MB threshold + { + GC.Collect(2, GCCollectionMode.Optimized, blocking: false); + } + return (body, contentType); } + /// + /// Streams content directly without loading into memory (for large files like audio). + /// + public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary? queryParams = null) + { + var url = BuildUrl(endpoint, queryParams); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("Authorization", GetAuthorizationHeader()); + + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var stream = await response.Content.ReadAsStreamAsync(); + var contentType = response.Content.Headers.ContentType?.ToString(); + var contentLength = response.Content.Headers.ContentLength; + + return (stream, contentType, contentLength); + } + /// /// Sends a DELETE request to the Jellyfin server. /// Forwards client headers for authentication passthrough. diff --git a/allstarr/Services/Subsonic/SubsonicProxyService.cs b/allstarr/Services/Subsonic/SubsonicProxyService.cs index 34a9a31..3bd3cba 100644 --- a/allstarr/Services/Subsonic/SubsonicProxyService.cs +++ b/allstarr/Services/Subsonic/SubsonicProxyService.cs @@ -39,6 +39,12 @@ public class SubsonicProxyService var body = await response.Content.ReadAsByteArrayAsync(); var contentType = response.Content.Headers.ContentType?.ToString(); + // Trigger GC for large files to prevent memory leaks + if (body.Length > 1024 * 1024) // 1MB threshold + { + GC.Collect(2, GCCollectionMode.Optimized, blocking: false); + } + return (body, contentType); }