diff --git a/LocalLLMServerManager.Shared/Converters/StickerStudioConverters.cs b/LocalLLMServerManager.Shared/Converters/StickerStudioConverters.cs index 850b74e..3e13228 100644 --- a/LocalLLMServerManager.Shared/Converters/StickerStudioConverters.cs +++ b/LocalLLMServerManager.Shared/Converters/StickerStudioConverters.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Runtime.CompilerServices; using Avalonia.Data.Converters; using Avalonia.Media; using Avalonia.Media.Imaging; @@ -12,6 +13,7 @@ namespace LocalLLMServerManager.Shared.Views.Controls; public class ImageSourceConverter : IValueConverter { public static readonly ImageSourceConverter Instance = new(); + private static readonly ConditionalWeakTable BitmapCache = new(); public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { @@ -19,8 +21,11 @@ public class ImageSourceConverter : IValueConverter { if (value is byte[] bytes && bytes.Length > 0) { - using var ms = new MemoryStream(bytes); - return new Bitmap(ms); + return BitmapCache.GetValue(bytes, static b => + { + using var ms = new MemoryStream(b); + return new Bitmap(ms); + }); } if (value is string path && !string.IsNullOrWhiteSpace(path) && File.Exists(path)) { @@ -56,25 +61,35 @@ public class StageStatusBrushConverter : IValueConverter { public static readonly StageStatusBrushConverter Instance = new(); + [ThreadStatic] private static SolidColorBrush? _completedBrush; + [ThreadStatic] private static SolidColorBrush? _runningBrush; + [ThreadStatic] private static SolidColorBrush? _errorBrush; + [ThreadStatic] private static SolidColorBrush? _idleBrush; + + public static SolidColorBrush CompletedBrush => _completedBrush ??= new(Color.Parse("#238636")); + public static SolidColorBrush RunningBrush => _runningBrush ??= new(Color.Parse("#388bfd")); + public static SolidColorBrush ErrorBrush => _errorBrush ??= new(Color.Parse("#da3633")); + public static SolidColorBrush IdleBrush => _idleBrush ??= new(Color.Parse("#8b949e")); + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value is StickerPipelineStage stage && parameter is string stepStr && int.TryParse(stepStr, out int step)) { if (stage == StickerPipelineStage.Failed) - return new SolidColorBrush(Color.Parse("#da3633")); + return ErrorBrush; int current = (int)stage; if (stage != StickerPipelineStage.Idle) { if (stage == StickerPipelineStage.Ready) - return new SolidColorBrush(Color.Parse("#238636")); // Complete green + return CompletedBrush; // Complete green if (current == step) - return new SolidColorBrush(Color.Parse("#388bfd")); // Active blue + return RunningBrush; // Active blue if (current > step) - return new SolidColorBrush(Color.Parse("#238636")); // Complete green + return CompletedBrush; // Complete green } } - return new SolidColorBrush(Color.Parse("#8b949e")); // TextMutedBrush + return IdleBrush; // TextMutedBrush } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => diff --git a/LocalLLMServerManager.Shared/Services/HttpHelper.cs b/LocalLLMServerManager.Shared/Services/HttpHelper.cs index f70dc84..522e613 100644 --- a/LocalLLMServerManager.Shared/Services/HttpHelper.cs +++ b/LocalLLMServerManager.Shared/Services/HttpHelper.cs @@ -6,9 +6,13 @@ namespace LocalLLMServerManager.Shared.Services; public static class HttpHelper { + private static readonly HttpMessageHandler SharedHandler = OperatingSystem.IsBrowser() + ? new HttpClientHandler() + : new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }; + public static HttpClient CreateClient(string? apiBase = null) { - var client = new HttpClient(); + var client = new HttpClient(SharedHandler, disposeHandler: false); if (!string.IsNullOrWhiteSpace(apiBase) && Uri.TryCreate(apiBase, UriKind.Absolute, out var baseUri)) { client.BaseAddress = baseUri; diff --git a/LocalLLMServerManager.Shared/Services/OllamaModelService.cs b/LocalLLMServerManager.Shared/Services/OllamaModelService.cs index 191a699..2654228 100644 --- a/LocalLLMServerManager.Shared/Services/OllamaModelService.cs +++ b/LocalLLMServerManager.Shared/Services/OllamaModelService.cs @@ -16,45 +16,55 @@ public async Task> LoadInstalledModelsAsync(string apiBase var result = new List(); try { - HttpResponseMessage response; + HttpResponseMessage? response = null; try { - response = await http.GetAsync($"{apiBase}/api/models"); - if (!response.IsSuccessStatusCode && !OperatingSystem.IsBrowser()) + try { + response = await http.GetAsync($"{apiBase}/api/models"); + if (!response.IsSuccessStatusCode && !OperatingSystem.IsBrowser()) + { + response.Dispose(); + response = await http.GetAsync("http://127.0.0.1:11434/api/tags"); + } + } + catch + { + response?.Dispose(); + if (OperatingSystem.IsBrowser()) return result; response = await http.GetAsync("http://127.0.0.1:11434/api/tags"); } - } - catch - { - if (OperatingSystem.IsBrowser()) return result; - response = await http.GetAsync("http://127.0.0.1:11434/api/tags"); - } - - if (response.IsSuccessStatusCode) - { - var jsonStr = await response.Content.ReadAsStringAsync(); - var doc = JsonNode.Parse(jsonStr); - var models = doc?["models"]?.AsArray(); - if (models != null) + using var resp = response; + if (resp.IsSuccessStatusCode) { - foreach (var m in models) + var jsonStr = await resp.Content.ReadAsStringAsync(); + var doc = JsonNode.Parse(jsonStr); + var models = doc?["models"]?.AsArray(); + + if (models != null) { - string name = m?["name"]?.ToString() ?? "Unknown"; - long size = m?["size"]?.GetValue() ?? 0L; - double sizeGb = Math.Round(size / (1024.0 * 1024.0 * 1024.0), 2); - string formatSize = sizeGb > 0 ? $"{sizeGb} GB" : "N/A"; + foreach (var m in models) + { + string name = m?["name"]?.ToString() ?? "Unknown"; + long size = m?["size"]?.GetValue() ?? 0L; + double sizeGb = Math.Round(size / (1024.0 * 1024.0 * 1024.0), 2); + string formatSize = sizeGb > 0 ? $"{sizeGb} GB" : "N/A"; - string cap = "💻 Coding & General"; - string color = "#38BDF8"; - if (name.Contains("math", StringComparison.OrdinalIgnoreCase)) { cap = "🧮 Mathematics"; color = "#C084FC"; } - else if (name.Contains("r1", StringComparison.OrdinalIgnoreCase) || name.Contains("deepseek", StringComparison.OrdinalIgnoreCase)) { cap = "🧠 Reasoning profile"; color = "#A855F7"; } + string cap = "💻 Coding & General"; + string color = "#38BDF8"; + if (name.Contains("math", StringComparison.OrdinalIgnoreCase)) { cap = "🧮 Mathematics"; color = "#C084FC"; } + else if (name.Contains("r1", StringComparison.OrdinalIgnoreCase) || name.Contains("deepseek", StringComparison.OrdinalIgnoreCase)) { cap = "🧠 Reasoning profile"; color = "#A855F7"; } - result.Add(new OllamaModelItem(name, formatSize, cap, color, false, null, size)); + result.Add(new OllamaModelItem(name, formatSize, cap, color, false, null, size)); + } } } } + finally + { + response?.Dispose(); + } } catch { } @@ -65,39 +75,52 @@ public async Task UnloadAllVramAsync(string apiBase, HttpClient http) { try { - HttpResponseMessage psResp; + HttpResponseMessage? psResp = null; try { - psResp = await http.GetAsync($"{apiBase}/api/ollama/ps"); - if (!psResp.IsSuccessStatusCode) psResp = await http.GetAsync("http://127.0.0.1:11434/api/ps"); - } - catch - { - psResp = await http.GetAsync("http://127.0.0.1:11434/api/ps"); - } + try + { + psResp = await http.GetAsync($"{apiBase}/api/ollama/ps"); + if (!psResp.IsSuccessStatusCode) + { + psResp.Dispose(); + psResp = await http.GetAsync("http://127.0.0.1:11434/api/ps"); + } + } + catch + { + psResp?.Dispose(); + psResp = await http.GetAsync("http://127.0.0.1:11434/api/ps"); + } - if (psResp.IsSuccessStatusCode) - { - var doc = JsonNode.Parse(await psResp.Content.ReadAsStringAsync()); - var models = doc?["models"]?.AsArray(); - if (models != null) + using var resp = psResp; + if (resp.IsSuccessStatusCode) { - foreach (var m in models) + var doc = JsonNode.Parse(await resp.Content.ReadAsStringAsync()); + var models = doc?["models"]?.AsArray(); + if (models != null) { - string name = m?["name"]?.ToString() ?? ""; - if (!string.IsNullOrEmpty(name)) + foreach (var m in models) { - var content = new StringContent( - JsonSerializer.Serialize(new { model = name, keep_alive = 0 }), - System.Text.Encoding.UTF8, - "application/json" - ); - await http.PostAsync("http://127.0.0.1:11434/api/generate", content); + string name = m?["name"]?.ToString() ?? ""; + if (!string.IsNullOrEmpty(name)) + { + var content = new StringContent( + JsonSerializer.Serialize(new { model = name, keep_alive = 0 }), + System.Text.Encoding.UTF8, + "application/json" + ); + await http.PostAsync("http://127.0.0.1:11434/api/generate", content); + } } } } + return true; + } + finally + { + psResp?.Dispose(); } - return true; } catch { @@ -109,12 +132,12 @@ public async Task PreloadModelAsync(string apiBase, string modelName, Http { try { - var content = new StringContent( + using var content = new StringContent( JsonSerializer.Serialize(new { model = modelName, keep_alive = -1 }), System.Text.Encoding.UTF8, "application/json" ); - var resp = await http.PostAsync("http://127.0.0.1:11434/api/generate", content); + using var resp = await http.PostAsync("http://127.0.0.1:11434/api/generate", content); return resp.IsSuccessStatusCode; } catch @@ -135,12 +158,12 @@ public async Task PullModelAsync(string modelName) try { using var client = new HttpClient(); - var content = new StringContent( + using var content = new StringContent( JsonSerializer.Serialize(new { name = modelName, stream = false }), System.Text.Encoding.UTF8, "application/json" ); - var resp = await client.PostAsync("http://127.0.0.1:11434/api/pull", content); + using var resp = await client.PostAsync("http://127.0.0.1:11434/api/pull", content); return resp.IsSuccessStatusCode; } catch @@ -166,7 +189,7 @@ public async Task DeleteModelAsync(string apiBase, string modelName, HttpC try { using var req = new HttpRequestMessage(HttpMethod.Delete, endpointUrl); - var resp = await http.SendAsync(req); + using var resp = await http.SendAsync(req); if (resp.IsSuccessStatusCode) return true; } catch { } @@ -174,13 +197,13 @@ public async Task DeleteModelAsync(string apiBase, string modelName, HttpC // 2. Fallback to POST on proxy endpoint try { - var postContent = new StringContent( + using var postContent = new StringContent( JsonSerializer.Serialize(new { model = modelName, name = modelName, type = "ollama" }), System.Text.Encoding.UTF8, "application/json" ); using var postReq = new HttpRequestMessage(HttpMethod.Post, endpointUrl) { Content = postContent }; - var postResp = await http.SendAsync(postReq); + using var postResp = await http.SendAsync(postReq); if (postResp.IsSuccessStatusCode) return true; } catch { } @@ -190,36 +213,36 @@ public async Task DeleteModelAsync(string apiBase, string modelName, HttpC { try { - var unloadContent = new StringContent( + using var unloadContent = new StringContent( JsonSerializer.Serialize(new { model = modelName, keep_alive = 0 }), System.Text.Encoding.UTF8, "application/json" ); - await http.PostAsync("http://127.0.0.1:11434/api/generate", unloadContent); + using var unloadResp = await http.PostAsync("http://127.0.0.1:11434/api/generate", unloadContent); } catch { } try { - var directContent = new StringContent( + using var directContent = new StringContent( JsonSerializer.Serialize(new { model = modelName, name = modelName }), System.Text.Encoding.UTF8, "application/json" ); using var directReq = new HttpRequestMessage(HttpMethod.Delete, "http://127.0.0.1:11434/api/delete") { Content = directContent }; - var directResp = await http.SendAsync(directReq); + using var directResp = await http.SendAsync(directReq); if (directResp.IsSuccessStatusCode) return true; var altName = modelName.EndsWith(":latest", StringComparison.OrdinalIgnoreCase) ? modelName.Substring(0, modelName.Length - 7) : $"{modelName}:latest"; - var altContent = new StringContent( + using var altContent = new StringContent( JsonSerializer.Serialize(new { model = altName, name = altName }), System.Text.Encoding.UTF8, "application/json" ); using var altReq = new HttpRequestMessage(HttpMethod.Delete, "http://127.0.0.1:11434/api/delete") { Content = altContent }; - var altResp = await http.SendAsync(altReq); + using var altResp = await http.SendAsync(altReq); return altResp.IsSuccessStatusCode; } catch { } @@ -246,14 +269,14 @@ public async Task DeleteLocalModelFileAsync(string apiBase, string filePat ? $"/api/models/delete?file_path={Uri.EscapeDataString(filePath)}&type=file" : $"{baseAddress}/api/models/delete?file_path={Uri.EscapeDataString(filePath)}&type=file"; - var content = new StringContent( + using var content = new StringContent( JsonSerializer.Serialize(new { file_path = filePath, filePath, type = "file" }), System.Text.Encoding.UTF8, "application/json" ); using var req = new HttpRequestMessage(HttpMethod.Delete, endpointUrl) { Content = content }; - var resp = await http.SendAsync(req); + using var resp = await http.SendAsync(req); if (resp.IsSuccessStatusCode) return true; using var postContent = new StringContent( @@ -262,7 +285,7 @@ public async Task DeleteLocalModelFileAsync(string apiBase, string filePat "application/json" ); using var postReq = new HttpRequestMessage(HttpMethod.Post, endpointUrl) { Content = postContent }; - var postResp = await http.SendAsync(postReq); + using var postResp = await http.SendAsync(postReq); return postResp.IsSuccessStatusCode; } catch diff --git a/LocalLLMServerManager.Shared/Services/TelemetryService.cs b/LocalLLMServerManager.Shared/Services/TelemetryService.cs index 20efd92..8bf2a16 100644 --- a/LocalLLMServerManager.Shared/Services/TelemetryService.cs +++ b/LocalLLMServerManager.Shared/Services/TelemetryService.cs @@ -12,7 +12,7 @@ public async Task QueryGpuVramAsync(string apiBase, HttpClient { try { - var resp = await http.GetAsync($"{apiBase}/api/gpu/vram"); + using var resp = await http.GetAsync($"{apiBase}/api/gpu/vram"); if (resp.IsSuccessStatusCode) { var doc = JsonNode.Parse(await resp.Content.ReadAsStringAsync()); @@ -90,7 +90,7 @@ public async Task QueryGpuVramAsync(string apiBase, HttpClient { try { - var healthResp = await http.GetAsync($"{apiBase}/health"); + using var healthResp = await http.GetAsync($"{apiBase}/health"); if (healthResp.IsSuccessStatusCode) { var doc = JsonNode.Parse(await healthResp.Content.ReadAsStringAsync()); @@ -105,7 +105,7 @@ public async Task QueryGpuVramAsync(string apiBase, HttpClient try { - var healthResp = await http.GetAsync($"{apiBase}/health"); + using var healthResp = await http.GetAsync($"{apiBase}/health"); if (healthResp.IsSuccessStatusCode) { var doc = JsonNode.Parse(await healthResp.Content.ReadAsStringAsync()); @@ -117,9 +117,9 @@ public async Task QueryGpuVramAsync(string apiBase, HttpClient } catch { } - try { var r = await http.GetAsync("http://127.0.0.1:11434/"); ollama = r.IsSuccessStatusCode; } catch { } - try { var r = await http.GetAsync("http://127.0.0.1:7860/"); forge = r.IsSuccessStatusCode; } catch { } - try { var r = await http.GetAsync($"{comfyUrl.TrimEnd('/')}/system_stats"); comfy = r.IsSuccessStatusCode; } catch { } + try { using var r = await http.GetAsync("http://127.0.0.1:11434/"); ollama = r.IsSuccessStatusCode; } catch { } + try { using var r = await http.GetAsync("http://127.0.0.1:7860/"); forge = r.IsSuccessStatusCode; } catch { } + try { using var r = await http.GetAsync($"{comfyUrl.TrimEnd('/')}/system_stats"); comfy = r.IsSuccessStatusCode; } catch { } return (ollama, forge, comfy); } diff --git a/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs index e798318..9c57655 100644 --- a/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs @@ -277,7 +277,8 @@ public async Task GenerateAudioAsync(ParamContext? ctx = null) try { var apiBase = ctx?.ApiBase ?? (!string.IsNullOrWhiteSpace(ApiBase) ? ApiBase : (OperatingSystem.IsBrowser() ? "" : "http://127.0.0.1:5246")); - var http = ctx?.Http ?? HttpHelper.CreateClient(apiBase); + using var fallbackHttp = ctx?.Http == null ? HttpHelper.CreateClient(apiBase) : null; + var http = ctx?.Http ?? fallbackHttp!; var payload = new { @@ -297,7 +298,7 @@ public async Task GenerateAudioAsync(ParamContext? ctx = null) LiveLogOutput += "[Stage 2] Generating audio latents with conditioning...\n"; LogsText = LiveLogOutput; - var response = await http.PostAsJsonAsync($"{apiBase}/api/audio/generate", payload); + using var response = await http.PostAsJsonAsync($"{apiBase}/api/audio/generate", payload); if (response.IsSuccessStatusCode) { Stage2Status = "Complete"; diff --git a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs index 5ba2fd3..4e1c75e 100644 --- a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs @@ -472,7 +472,8 @@ public static string EvaluateDirectoryStatus(string? path) [RelayCommand] public async Task RefreshComponentStatusesAsync() { - await RefreshComponentStatusesAsync(EffectiveApiBase, HttpHelper.CreateClient(EffectiveApiBase)); + using var http = HttpHelper.CreateClient(EffectiveApiBase); + await RefreshComponentStatusesAsync(EffectiveApiBase, http); } public async Task RefreshComponentStatusesAsync(string apiBase, HttpClient http) @@ -480,7 +481,7 @@ public async Task RefreshComponentStatusesAsync(string apiBase, HttpClient http) try { var url = HttpHelper.FormatEndpoint(apiBase, "/api/components"); - var response = await http.GetAsync(url); + using var response = await http.GetAsync(url); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); @@ -514,14 +515,14 @@ public async Task ToggleAudioPackAsync() private async Task ToggleComponentAsync(string componentId, bool currentlyInstalled) { var apiBase = EffectiveApiBase; - var http = HttpHelper.CreateClient(apiBase); + using var http = HttpHelper.CreateClient(apiBase); try { if (currentlyInstalled) { - var content = new StringContent(JsonSerializer.Serialize(new { componentId }), System.Text.Encoding.UTF8, "application/json"); + using var content = new StringContent(JsonSerializer.Serialize(new { componentId }), System.Text.Encoding.UTF8, "application/json"); var url = HttpHelper.FormatEndpoint(apiBase, "/api/components/uninstall"); - var res = await http.PostAsync(url, content); + using var res = await http.PostAsync(url, content); if (res.IsSuccessStatusCode) { ToastService.Instance.Show($"Uninstalled component '{componentId}'.", ToastType.Info); @@ -529,9 +530,9 @@ private async Task ToggleComponentAsync(string componentId, bool currentlyInstal } else { - var content = new StringContent(JsonSerializer.Serialize(new { componentId }), System.Text.Encoding.UTF8, "application/json"); + using var content = new StringContent(JsonSerializer.Serialize(new { componentId }), System.Text.Encoding.UTF8, "application/json"); var url = HttpHelper.FormatEndpoint(apiBase, "/api/components/install"); - var res = await http.PostAsync(url, content); + using var res = await http.PostAsync(url, content); if (res.IsSuccessStatusCode) { ToastService.Instance.Show($"Installed component '{componentId}'.", ToastType.Success); @@ -565,7 +566,8 @@ public void SwitchThemeStyle(string style) [RelayCommand] public async Task AutoDetectToolsAsync() { - await AutoDetectToolsAsync(EffectiveApiBase, HttpHelper.CreateClient(EffectiveApiBase)); + using var http = HttpHelper.CreateClient(EffectiveApiBase); + await AutoDetectToolsAsync(EffectiveApiBase, http); } public async Task AutoDetectToolsAsync(string apiBase, HttpClient http) @@ -573,7 +575,7 @@ public async Task AutoDetectToolsAsync(string apiBase, HttpClient http) try { IsAutoDetecting = true; - var response = await http.GetAsync($"{apiBase}/api/system/tools/detect"); + using var response = await http.GetAsync($"{apiBase}/api/system/tools/detect"); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); @@ -771,7 +773,8 @@ public async Task BrowseAudioEngineExecutableAsync(IStorageProvider? provider = [RelayCommand] public async Task TestVoiceSynthesizerAsync() { - await TestVoiceSynthesizerAsync(EffectiveApiBase, HttpHelper.CreateClient(EffectiveApiBase)); + using var http = HttpHelper.CreateClient(EffectiveApiBase); + await TestVoiceSynthesizerAsync(EffectiveApiBase, http); } public async Task TestVoiceSynthesizerAsync(string apiBase, HttpClient http) @@ -786,9 +789,9 @@ public async Task TestVoiceSynthesizerAsync(string apiBase, HttpClient http) response_format = "mp3" }; - var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json"); + using var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json"); var url = HttpHelper.FormatEndpoint(apiBase, "/v1/audio/speech"); - var response = await http.PostAsync(url, content); + using var response = await http.PostAsync(url, content); if (response.IsSuccessStatusCode) { ToastService.Instance.Show("Audio synthesized successfully! Playing preview...", ToastType.Success); @@ -863,7 +866,7 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http) try { var url = HttpHelper.FormatEndpoint(apiBase, "/api/settings"); - var response = await http.GetAsync(url); + using var response = await http.GetAsync(url); if (response.IsSuccessStatusCode) { var jsonStr = await response.Content.ReadAsStringAsync(); @@ -929,7 +932,8 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http) [RelayCommand] public async Task SaveSettingsAsync() { - await SaveSettingsAsync(EffectiveApiBase, HttpHelper.CreateClient(EffectiveApiBase)); + using var http = HttpHelper.CreateClient(EffectiveApiBase); + await SaveSettingsAsync(EffectiveApiBase, http); } public async Task SaveSettingsAsync(string apiBase, HttpClient http) @@ -984,7 +988,7 @@ public async Task SaveSettingsAsync(string apiBase, HttpClient http) ); var url = HttpHelper.FormatEndpoint(apiBase, "/api/settings"); - var response = await http.PostAsync(url, content); + using var response = await http.PostAsync(url, content); if (response.IsSuccessStatusCode) { ToastService.Instance.Show("Settings saved successfully.", ToastType.Success); diff --git a/LocalLLMServerManager.Tests/HttpHelperTests.cs b/LocalLLMServerManager.Tests/HttpHelperTests.cs index 7b44ae5..7484ccb 100644 --- a/LocalLLMServerManager.Tests/HttpHelperTests.cs +++ b/LocalLLMServerManager.Tests/HttpHelperTests.cs @@ -1,5 +1,9 @@ using System; +using System.Net; using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; using LocalLLMServerManager.Shared.Services; using LocalLLMServerManager.Shared.ViewModels; using Xunit; @@ -20,11 +24,50 @@ public void FormatEndpoint_FormatsRelativeAndAbsoluteUrlsCorrectly() [Fact] public void CreateClient_WithAbsoluteBase_ConfiguresBaseAddress() { - var client = HttpHelper.CreateClient("https://localllms.wileyriley.com"); + using var client = HttpHelper.CreateClient("https://localllms.wileyriley.com"); Assert.NotNull(client.BaseAddress); Assert.Equal("https://localllms.wileyriley.com/", client.BaseAddress.ToString()); } + [Fact] + public async Task CreateClient_DisposingEarlierClients_DoesNotDisposeSharedHandler() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + string baseUrl = $"http://127.0.0.1:{port}"; + + var ct = TestContext.Current.CancellationToken; + var serverTask = Task.Run(async () => + { + for (int i = 0; i < 2; i++) + { + using var tcpClient = await listener.AcceptTcpClientAsync(ct); + await using var stream = tcpClient.GetStream(); + var buffer = new byte[1024]; + _ = await stream.ReadAsync(buffer, ct); + var responseBytes = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + await stream.WriteAsync(responseBytes, ct); + } + }, ct); + + using (var client1 = HttpHelper.CreateClient(baseUrl)) + { + using var resp1 = await client1.GetAsync("/test1", ct); + Assert.Equal(HttpStatusCode.OK, resp1.StatusCode); + } + + // Subsequent client created after client1 is disposed must still work without ObjectDisposedException + using (var client2 = HttpHelper.CreateClient(baseUrl)) + { + using var resp2 = await client2.GetAsync("/test2", ct); + Assert.Equal(HttpStatusCode.OK, resp2.StatusCode); + } + + await serverTask; + listener.Stop(); + } + [Fact] public void MainViewModel_PropagatesApiBaseToAllSubViewModels() { diff --git a/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs b/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs index 6732a00..29698ea 100644 --- a/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs +++ b/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs @@ -124,11 +124,21 @@ public void StickerStudioControl_InstantiatesAndVerifiesConverters() Assert.Equal("✓", iconConv.Convert(StickerPipelineStage.Ready, typeof(string), "4", System.Globalization.CultureInfo.InvariantCulture)); Assert.Equal("✕", iconConv.Convert(StickerPipelineStage.Failed, typeof(string), "5", System.Globalization.CultureInfo.InvariantCulture)); - // Verify stage status brush converter + // Verify stage status brush converter and static brush reuse var brushConv = StageStatusBrushConverter.Instance; var activeBrush = brushConv.Convert(StickerPipelineStage.IsolatingSubject, typeof(Avalonia.Media.IBrush), "2", System.Globalization.CultureInfo.InvariantCulture) as Avalonia.Media.SolidColorBrush; + var activeBrush2 = brushConv.Convert(StickerPipelineStage.GeneratingDiffusion, typeof(Avalonia.Media.IBrush), "1", System.Globalization.CultureInfo.InvariantCulture) as Avalonia.Media.SolidColorBrush; Assert.NotNull(activeBrush); Assert.Equal(Avalonia.Media.Color.Parse("#388bfd"), activeBrush.Color); + Assert.Same(activeBrush, activeBrush2); + + // Verify ImageSourceConverter reuses cached Bitmap for the same byte[] instance + byte[] pngBytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); + var bmp1 = ImageSourceConverter.Instance.Convert(pngBytes, typeof(Avalonia.Media.Imaging.Bitmap), null, System.Globalization.CultureInfo.InvariantCulture); + var bmp2 = ImageSourceConverter.Instance.Convert(pngBytes, typeof(Avalonia.Media.Imaging.Bitmap), null, System.Globalization.CultureInfo.InvariantCulture); + Assert.NotNull(bmp1); + Assert.Same(bmp1, bmp2); // Verify preset match converter var matchConv = PresetMatchConverter.Instance; diff --git a/Program.cs b/Program.cs index 7254719..f3b6b2b 100644 --- a/Program.cs +++ b/Program.cs @@ -188,7 +188,10 @@ public static WebApplication CreateWebApplication(string[] args, bool isServiceM // Register MCP Server builder.Services.AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => + { + options.Stateless = true; + }) .WithTools(); // Enable permissive CORS for Web UI and LAN browser clients diff --git a/Services/AiAppTools.cs b/Services/AiAppTools.cs index f9ce434..1eeedab 100644 --- a/Services/AiAppTools.cs +++ b/Services/AiAppTools.cs @@ -53,7 +53,7 @@ public async Task GetGpuVramTelemetryAsync(CancellationToken cancellatio try { var telemetry = await _telemetryProvider.GetTelemetryAsync(); - return JsonSerializer.Serialize(telemetry, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(telemetry, JsonOptions); } catch (Exception ex) { @@ -72,7 +72,7 @@ async Task CheckPort(string url) try { var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await client.GetAsync(url, cancellationToken); + using var resp = await client.GetAsync(url, cancellationToken); sw.Stop(); return new { online = resp.IsSuccessStatusCode, statusCode = (int)resp.StatusCode, latencyMs = sw.ElapsedMilliseconds }; } @@ -91,7 +91,7 @@ async Task CheckPort(string url) kokoroTts = await CheckPort(string.IsNullOrWhiteSpace(settings.AudioEngineUrl) ? "http://127.0.0.1:8880/" : settings.AudioEngineUrl) }; - return JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(results, JsonOptions); } [Description("List all installed Ollama LLM models, quantization formats, and memory footprint.")] @@ -100,7 +100,7 @@ public async Task ListInstalledModelsAsync(CancellationToken cancellatio try { var models = await _ollamaModelService.GetInstalledModelsAsync(); - return JsonSerializer.Serialize(models, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(models, JsonOptions); } catch (Exception ex) { @@ -114,7 +114,7 @@ public async Task StartAiEngineAsync(string engine, CancellationToken ca try { var result = await _engineManager.StartEngineAsync(engine); - return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(result, JsonOptions); } catch (Exception ex) { @@ -128,7 +128,7 @@ public async Task StopAiEngineAsync(string engine, CancellationToken can try { var result = await _engineManager.StopEngineAsync(engine); - return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(result, JsonOptions); } catch (Exception ex) { @@ -143,7 +143,7 @@ public async Task UnloadVramAsync(CancellationToken cancellationToken = { using var client = _httpClientFactory.CreateClient(); var payload = new StringContent("{\"model\":\"\",\"keep_alive\":0}", Encoding.UTF8, "application/json"); - var response = await client.PostAsync("http://127.0.0.1:11434/api/generate", payload, cancellationToken); + using var response = await client.PostAsync("http://127.0.0.1:11434/api/generate", payload, cancellationToken); return JsonSerializer.Serialize(new { success = response.IsSuccessStatusCode, status = (int)response.StatusCode, message = "VRAM unload requested" }); } catch (Exception ex) @@ -282,7 +282,7 @@ public async Task GenerateImageAsync( steps = 20 }; var content = new StringContent(JsonSerializer.Serialize(forgePayload), Encoding.UTF8, "application/json"); - var resp = await client.PostAsync("http://127.0.0.1:7860/sdapi/v1/txt2img", content, cancellationToken); + using var resp = await client.PostAsync("http://127.0.0.1:7860/sdapi/v1/txt2img", content, cancellationToken); return JsonSerializer.Serialize(new { success = resp.IsSuccessStatusCode, @@ -296,7 +296,7 @@ public async Task GenerateImageAsync( var comfyUrl = string.IsNullOrWhiteSpace(settings.ComfyUiUrl) ? "http://127.0.0.1:8188" : settings.ComfyUiUrl.TrimEnd('/'); var comfyPayload = new { prompt = new { } }; var content = new StringContent(JsonSerializer.Serialize(comfyPayload), Encoding.UTF8, "application/json"); - var resp = await client.PostAsync($"{comfyUrl}/prompt", content, cancellationToken); + using var resp = await client.PostAsync($"{comfyUrl}/prompt", content, cancellationToken); return JsonSerializer.Serialize(new { success = resp.IsSuccessStatusCode, @@ -330,7 +330,7 @@ public async Task SynthesizeSpeechAsync( var body = JsonSerializer.Serialize(new { text, voice, format }); var content = new StringContent(body, Encoding.UTF8, "application/json"); - var resp = await client.PostAsync($"{ttsUrl}/api/tts", content, cancellationToken); + using var resp = await client.PostAsync($"{ttsUrl}/api/tts", content, cancellationToken); var mediaId = Guid.NewGuid().ToString("N")[..8]; return JsonSerializer.Serialize(new @@ -379,7 +379,7 @@ public Task QueryAppDocumentationAsync(string query, CancellationToken c warnings = m.Warnings }); - return Task.FromResult(JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true })); + return Task.FromResult(JsonSerializer.Serialize(results, JsonOptions)); } catch (Exception ex) { diff --git a/docs/superpowers/specs/2026-10-27-memory-cache-lifetimes-and-ram-telemetry-design.md b/docs/superpowers/specs/2026-10-27-memory-cache-lifetimes-and-ram-telemetry-design.md index e1ec86a..94cb323 100644 --- a/docs/superpowers/specs/2026-10-27-memory-cache-lifetimes-and-ram-telemetry-design.md +++ b/docs/superpowers/specs/2026-10-27-memory-cache-lifetimes-and-ram-telemetry-design.md @@ -144,3 +144,25 @@ Add a **Memory & Cache Lifetimes** section card with three controls: 3. **Browser E2E & Visual Verification:** - Run Playwright tests across both Chromium and Firefox (`npx playwright test`), including `page.reload()`. - Capture and visually inspect screenshots of the updated titlebar Unified Hardware Pill and Settings Memory & Cache Lifetimes card. + +--- + +## 6. v4.1.1 Host Memory Retention & Leak Remediation (2026-11-01) + +Following multi-day production validation of v4.1.0, four additional host memory retention and leak vectors were resolved across PRs [#103](https://github.com/spelech/LocalLLMServerManager/pull/103), [#104](https://github.com/spelech/LocalLLMServerManager/pull/104), [#105](https://github.com/spelech/LocalLLMServerManager/pull/105), and [#106](https://github.com/spelech/LocalLLMServerManager/pull/106): + +1. **Workstation GC with DATAS & Large Object Heap (LOH) Compaction ([#104](https://github.com/spelech/LocalLLMServerManager/pull/104)):** + - Configured `false` and `1` in `LocalLLMServerManager.csproj` so .NET decommits freed heap segments back to the OS instead of retaining multi-gigabyte per-core Server GC heaps. + - `MemoryLifecycleWatchdog` and `POST /api/memory/purge` set `GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce` and trigger blocking Gen 2 compacting collections when host working set (`> 512 MB`), managed heap (`> 256 MB`), or system RAM pressure (`>= 75%`) thresholds are exceeded. + - Added a 3-second TTL cache in `GpuTelemetryProvider` so concurrent UI, health, and watchdog callers share `nvidia-smi` snapshots without spawning redundant child processes. +2. **UI Polling Churn Elimination & System Tray Lifecycle ([#103](https://github.com/spelech/LocalLLMServerManager/pull/103)):** + - Paused the 5-second UI background polling loop (`IsWindowVisible = false`) and released transient image byte buffers (`ReleaseTransientBuffers()`) when the desktop window is hidden to the system tray (`MainWindow.OnClosing`), resuming and refreshing immediately on `App.ShowMainWindow()`. + - Eliminated redundant 5-second `ObservableCollection` clear/rebuild cycles in `OllamaLibraryViewModel` and skipped hardware fit badge recalculations in `OllamaLibraryViewModel`, `HuggingFaceSearchViewModel`, and `CivitaiSearchViewModel` when VRAM/RAM metrics are unchanged. +3. **`VramOrchestrator` Eviction Latch Recovery & ComfyUI Health Guard ([#106](https://github.com/spelech/LocalLLMServerManager/pull/106)):** + - Guarded `FreeComfyUiVramAsync` with `IsComfyUiHealthyAsync` before posting to `:8188/free` to prevent `SocketException (10061)` errors when ComfyUI is offline. + - Fixed the one-way `_tier1Evicted` / `_tier2Hibernated` latch in `EvaluateCacheLifetimesAsync` by inspecting active engine processes (`GetActiveEngineKeys`), allowing recurring RAM pressure evictions and resetting hibernation flags when engines are restarted outside the proxy middleware. + - Added activity tracking in `WorkflowEndpoints` (`/api/video/generate`, `/api/3d/generate`, `/api/audio/generate`), `LocalLlmMcpTools`, and `AiEngineManager`, plus automatic disposal of exited `Process` handles. +4. **Unmanaged Skia `Bitmap` Cache, Shared `SocketsHttpHandler` & Stateless MCP Transport ([#105](https://github.com/spelech/LocalLLMServerManager/pull/105)):** + - Added a `ConditionalWeakTable` cache in `ImageSourceConverter` so repeated binding evaluations reuse the same unmanaged Skia `Bitmap` instance, and cached per-thread `SolidColorBrush` instances in `StageStatusBrushConverter`. + - Updated `HttpHelper.CreateClient` to reuse a static `SocketsHttpHandler` (`PooledConnectionLifetime = 2m`, `disposeHandler: false`) and enabled `options.Stateless = true` on `AddMcpServer().WithHttpTransport(...)` so external MCP gateway polling does not accumulate stateful sessions. +