From aabbb5c2c7e456d8ac4c5b6858953e1a47a3d014 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 18 Jan 2026 19:10:44 +0100 Subject: [PATCH] Prevent race conditions in chat storage with semaphores --- .../Tools/WorkspaceBehaviour.cs | 90 ++++++++++++++----- .../wwwroot/changelog/v26.1.2.md | 3 +- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 1eab21bc..05cfe35d 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -12,7 +13,20 @@ namespace AIStudio.Tools; public static class WorkspaceBehaviour { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour)); - + + /// + /// Semaphores for synchronizing chat storage operations per chat. + /// This prevents race conditions when multiple threads try to write + /// the same chat file simultaneously. + /// + private static readonly ConcurrentDictionary CHAT_STORAGE_SEMAPHORES = new(); + + private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) + { + var key = $"{workspaceId}_{chatId}"; + return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + } + public static readonly JsonSerializerOptions JSON_OPTIONS = new() { WriteIndented = true, @@ -37,35 +51,50 @@ public static class WorkspaceBehaviour public static async Task StoreChat(ChatThread chat) { - string chatDirectory; - if (chat.WorkspaceId == Guid.Empty) - chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); - else - chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + // Acquire the semaphore for this specific chat to prevent concurrent writes to the same file: + var semaphore = GetChatSemaphore(chat.WorkspaceId, chat.ChatId); + await semaphore.WaitAsync(); - // Ensure the directory exists: - Directory.CreateDirectory(chatDirectory); - - // Save the chat name: - var chatNamePath = Path.Join(chatDirectory, "name"); - await File.WriteAllTextAsync(chatNamePath, chat.Name); - - // Save the thread as thread.json: - var chatPath = Path.Join(chatDirectory, "thread.json"); - await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + try + { + string chatDirectory; + if (chat.WorkspaceId == Guid.Empty) + chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); + else + chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + + // Ensure the directory exists: + Directory.CreateDirectory(chatDirectory); + + // Save the chat name: + var chatNamePath = Path.Join(chatDirectory, "name"); + await File.WriteAllTextAsync(chatNamePath, chat.Name); + + // Save the thread as thread.json: + var chatPath = Path.Join(chatDirectory, "thread.json"); + await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + } + finally + { + semaphore.Release(); + } } public static async Task LoadChat(LoadChat loadChat) { - var chatPath = loadChat.WorkspaceId == Guid.Empty - ? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString()) - : Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString()); - - if(!Directory.Exists(chatPath)) - return null; + // Acquire the semaphore for this specific chat to prevent concurrent read/writes to the same file: + var semaphore = GetChatSemaphore(loadChat.WorkspaceId, loadChat.ChatId); + await semaphore.WaitAsync(); try { + var chatPath = loadChat.WorkspaceId == Guid.Empty + ? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString()) + : Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString()); + + if(!Directory.Exists(chatPath)) + return null; + var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chat = JsonSerializer.Deserialize(chatData, JSON_OPTIONS); return chat; @@ -74,6 +103,10 @@ public static class WorkspaceBehaviour { return null; } + finally + { + semaphore.Release(); + } } public static async Task LoadWorkspaceName(Guid workspaceId) @@ -144,7 +177,18 @@ public static class WorkspaceBehaviour else chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - Directory.Delete(chatDirectory, true); + // Acquire the semaphore to prevent deleting while another thread is writing: + var semaphore = GetChatSemaphore(workspaceId, chatId); + await semaphore.WaitAsync(); + + try + { + Directory.Delete(chatDirectory, true); + } + finally + { + semaphore.Release(); + } } private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md index bdf0faa9..53c86b84 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md @@ -7,4 +7,5 @@ - Fixed a bug that allowed adding a provider (LLM, embedding, or transcription) without selecting a model. - Fixed a bug with local transcription providers by handling errors correctly when the local provider is unavailable. - Fixed a bug with local transcription providers by correctly handling empty model IDs. -- Fixed a bug affecting the transcription preview: previously, when you stopped music or other media, recorded or dictated text, and then tried to resume playback, the media wouldn’t resume as expected. This behavior is now fixed. \ No newline at end of file +- Fixed a bug affecting the transcription preview: previously, when you stopped music or other media, recorded or dictated text, and then tried to resume playback, the media wouldn’t resume as expected. This behavior is now fixed. +- Fixed a rare bug that occurred when multiple threads tried to manage the same chat thread. \ No newline at end of file