diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 9621c81b..961cd5ef 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -170,10 +170,15 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } this.formChangeTimer.AutoReset = false; - this.formChangeTimer.Elapsed += async (_, _) => + // + // Mind the missing async here: a timer hands its elapsed event to a thread pool thread, where an + // async handler has nobody to hand its exception to. Such an exception is not merely unobserved, + // it is unhandled, and it takes the app down with it. Observing the task keeps it contained. + // + this.formChangeTimer.Elapsed += (_, _) => { this.formChangeTimer.Stop(); - await this.OnFormChange(); + this.OnFormChange().Observe($"{nameof(AssistantBase)}: handling a form change"); }; this.MightPreselectValues(); @@ -327,7 +332,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1); this.InputIssues[^1] = issue; this.InputIsValid = false; - _ = this.RefreshAssistantUIAsync(); + this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase)}: rendering an added input issue"); } /// @@ -337,7 +342,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher { this.InputIssues = []; this.InputIsValid = true; - _ = this.RefreshAssistantUIAsync(); + this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase)}: rendering cleared input issues"); } protected void CreateChatThread() @@ -733,11 +738,11 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.CurrentMediaImportOwner) - _ = this.InvokeAsync(async () => + this.InvokeAsync(async () => { await this.ConsumeMediaOutcomeAsync(); this.StateHasChanged(); - }); + }).Observe($"{nameof(AssistantBase)}: consuming a media import outcome"); } /// Consumes a terminal media notification when this assistant is visible. diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs index cc4805f6..98321b65 100644 --- a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs @@ -90,7 +90,7 @@ public partial class AssistantI18N : AssistantBaseCore this.customTargetLanguage = string.Empty; } - _ = this.OnChangedLanguage(); + this.OnChangedLanguage().Observe($"{nameof(AssistantI18N)}: applying a language change"); } protected override bool MightPreselectValues() diff --git a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs index fc746006..0ef2ec7b 100644 --- a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs +++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs @@ -460,7 +460,7 @@ public partial class AssistantLogViewer : MSGComponentBase { this.StopAutoRefresh(); this.autoRefreshCancellationTokenSource = new CancellationTokenSource(); - _ = this.AutoRefreshLoopAsync(this.autoRefreshCancellationTokenSource.Token); + this.AutoRefreshLoopAsync(this.autoRefreshCancellationTokenSource.Token).Observe($"{nameof(AssistantLogViewer)}: refreshing the log automatically"); } private void StopAutoRefresh() diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs index 3f7a2a43..a861de89 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs @@ -246,14 +246,14 @@ public partial class VisualBriefingAssistant if (this.selectedBriefing?.BriefingId != briefingId) return; - _ = this.InvokeAsync(() => + this.InvokeAsync(() => { if (this.selectedBriefing?.BriefingId != briefingId) return; this.latestBuild = this.BuildProgressService.GetLatest(briefingId); this.StateHasChanged(); - }); + }).Observe($"{nameof(VisualBriefingAssistant)}: rendering the build progress"); } /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs index 1182d0c0..a6cff5f3 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs @@ -265,7 +265,7 @@ public partial class VisualBriefingAssistant : briefing.Versions.OrderByDescending(version => version.VersionNumber).FirstOrDefault()?.RevisionId ?? Guid.Empty; if (revisionId != Guid.Empty) - _ = this.SelectRevisionAsync(revisionId); + this.SelectRevisionAsync(revisionId).Observe($"{nameof(VisualBriefingAssistant)}: selecting a revision"); else { this.selectedRevisionId = Guid.Empty; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs index 5db37296..9dc02bdc 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs @@ -136,7 +136,7 @@ public partial class VisualBriefingAssistant !Guid.TryParse(owner.Id, out var briefingId)) return; - _ = this.InvokeAsync(async () => + this.InvokeAsync(async () => { await this.ConsumeMediaOutcomeAsync(owner); if (!this.MediaTranscriptionService.IsBusy(owner)) @@ -152,7 +152,7 @@ public partial class VisualBriefingAssistant } this.StateHasChanged(); - }); + }).Observe($"{nameof(VisualBriefingAssistant)}: consuming a media import outcome"); } /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs index 435a9bef..708f3593 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs @@ -157,7 +157,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase this.BuildProgressService.Changed += this.BuildProgressChanged; await this.ReloadListAsync(); await this.ConsumePendingMediaOutcomesAsync(); - _ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token); + this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token).Observe($"{nameof(VisualBriefingAssistant)}: monitoring the source status"); var deferredInstruction = this.MessageBus.TakeDeferredMessages(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).LastOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInstruction)) diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgress.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgress.razor.cs index 0480d41b..a7b33493 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgress.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgress.razor.cs @@ -56,7 +56,7 @@ public partial class VisualBriefingBuildProgress : MSGComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _ = this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token); + this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token).Observe($"{nameof(VisualBriefingBuildProgress)}: monitoring the build duration"); } protected override void OnParametersSet() diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index fcacc3cb..869057b3 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -245,7 +245,13 @@ public partial class ContentBlockComponent : MSGComponentBase if (string.Equals(this.lastMathRenderSignature, mathRenderSignature, StringComparison.Ordinal)) return; - await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature); + // + // Remember what the browser shows only when it really got the call: otherwise, a call which was + // lost while the connection was down would make us skip the math rendering after the reconnect. + // + if (!await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature)) + return; + this.lastMathRenderSignature = mathRenderSignature; this.hasActiveMathContainer = true; } @@ -258,16 +264,7 @@ public partial class ContentBlockComponent : MSGComponentBase return; } - try - { - await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer); - } - catch (JSDisconnectedException) - { - } - catch (ObjectDisposedException) - { - } + await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer); this.hasActiveMathContainer = false; this.lastMathRenderSignature = string.Empty; diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index 4486ae6c..8b7ef937 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -167,7 +167,7 @@ public partial class AssistantBlock : MSGComponentBase, IAssistantCat private void OnMediaImportStateChanged(MediaImportOwner owner) { if (this.OwnedByThisBlock(owner)) - _ = this.InvokeAsync(this.StateHasChanged); + this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock)}: rendering a media import change"); } protected override void DisposeResources() diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index 9849d102..e3e3035a 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -140,12 +140,12 @@ public partial class AttachDocuments : MSGComponentBase private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.EffectiveImportOwner) - _ = this.InvokeAsync(async () => + this.InvokeAsync(async () => { await this.SyncCompletedMediaAttachmentsAsync(); await this.ConsumeStandaloneMediaOutcomeAsync(); this.StateHasChanged(); - }); + }).Observe($"{nameof(AttachDocuments)}: syncing media attachments"); } /// Consumes outcomes for dialog-local controls that have no chat or assistant owner surface. @@ -225,7 +225,7 @@ public partial class AttachDocuments : MSGComponentBase // Release the drop area. Without this, drop areas below this one would count this component // forever and would stop catching dropped files: - _ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer); + this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(AttachDocuments)}: releasing the drop area"); base.DisposeResources(); } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index e9cefa6d..85bc1cf5 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -261,11 +261,11 @@ public partial class ChatComponent : MSGComponentBase private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.CurrentMediaImportOwner) - _ = this.InvokeAsync(async () => + this.InvokeAsync(async () => { await this.ConsumeMediaOutcomeAsync(); this.StateHasChanged(); - }); + }).Observe($"{nameof(ChatComponent)}: consuming a media import outcome"); } /// Consumes a terminal media notification when its chat is visible. diff --git a/app/MindWork AI Studio/Components/CodeEditor.razor.cs b/app/MindWork AI Studio/Components/CodeEditor.razor.cs index 08de3997..f56048ad 100644 --- a/app/MindWork AI Studio/Components/CodeEditor.razor.cs +++ b/app/MindWork AI Studio/Components/CodeEditor.razor.cs @@ -80,9 +80,10 @@ public partial class CodeEditor : ComponentBase, IAsyncDisposable if (this.module is null) return; + await this.module.TryInvokeVoidAsync("destroy", this.editorId); + try { - await this.module.InvokeVoidAsync("destroy", this.editorId); await this.module.DisposeAsync(); } catch (JSDisconnectedException) diff --git a/app/MindWork AI Studio/Components/ConfigurationDirectory.razor.cs b/app/MindWork AI Studio/Components/ConfigurationDirectory.razor.cs index 2863c197..052752c3 100644 --- a/app/MindWork AI Studio/Components/ConfigurationDirectory.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationDirectory.razor.cs @@ -64,7 +64,7 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore protected override async Task OnInitializedAsync() { - this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText)); + this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationDirectory)}: applying the changed directory"); await base.OnInitializedAsync(); } diff --git a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs index b9042586..e89e7528 100644 --- a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs @@ -70,7 +70,7 @@ public partial class ConfigurationFile : ConfigurationBaseCore protected override async Task OnInitializedAsync() { - this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText)); + this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationFile)}: applying the changed file"); await base.OnInitializedAsync(); } diff --git a/app/MindWork AI Studio/Components/ConfigurationText.razor.cs b/app/MindWork AI Studio/Components/ConfigurationText.razor.cs index 9610731e..3a1f88b1 100644 --- a/app/MindWork AI Studio/Components/ConfigurationText.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationText.razor.cs @@ -77,7 +77,7 @@ public partial class ConfigurationText : ConfigurationBaseCore protected override async Task OnInitializedAsync() { - this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText)); + this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationText)}: applying the changed text"); await base.OnInitializedAsync(); } diff --git a/app/MindWork AI Studio/Components/DebouncedTextField.razor.cs b/app/MindWork AI Studio/Components/DebouncedTextField.razor.cs index 3ad55c6a..e41ba6ed 100644 --- a/app/MindWork AI Studio/Components/DebouncedTextField.razor.cs +++ b/app/MindWork AI Studio/Components/DebouncedTextField.razor.cs @@ -64,9 +64,9 @@ public partial class DebouncedTextField : MudComponentBase, IDisposable this.debounceTimer.Elapsed += (_, _) => { this.debounceTimer.Stop(); - this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)); - this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)); - this.InvokeAsync(() => this.WhenTextCanged(this.text)); + this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: notifying about changed text"); + this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text asynchronously"); + this.InvokeAsync(() => this.WhenTextCanged(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text"); }; this.isInitialized = true; diff --git a/app/MindWork AI Studio/Components/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index 3c2e8ed8..c98ecf57 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -1,5 +1,6 @@ using AIStudio.Settings; using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -13,6 +14,13 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo [Inject] protected MessageBus MessageBus { get; init; } = null!; + /// + /// The circuit this component lives in. Use it before any JS interop: while its connection is down, + /// the browser is unreachable, although the component itself keeps working. + /// + [Inject] + protected CircuitStateService CircuitState { get; init; } = null!; + private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; #region Overrides of ComponentBase @@ -21,7 +29,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo { this.Lang = await this.SettingsManager.GetActiveLanguagePlugin(); - this.MessageBus.RegisterComponent(this); + this.MessageBus.RegisterComponent(this, this.CircuitState); await base.OnInitializedAsync(); } diff --git a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs index 1a048d61..bfec89f2 100644 --- a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs +++ b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs @@ -61,7 +61,7 @@ public partial class MediaTranscriptionStatus private void OnStateChanged(MediaImportOwner owner) { if (owner == this.Owner) - _ = this.InvokeAsync(this.StateHasChanged); + this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(MediaTranscriptionStatus)}: rendering an import state transition"); } /// Unsubscribes from singleton import state changes. diff --git a/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs b/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs index e90ea1cf..30d4eece 100644 --- a/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs +++ b/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs @@ -164,6 +164,6 @@ public partial class PluginDeleteAction : MSGComponentBase private void OnMediaTranscriptionStateChanged(MediaImportOwner owner) { if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal)) - _ = this.InvokeAsync(this.StateHasChanged); + this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(PluginDeleteAction)}: rendering a transcription state change"); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 52c8907f..3606ba4a 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -132,12 +132,12 @@ public partial class ReadFileContent : MSGComponentBase private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.EffectiveImportOwner) - _ = this.InvokeAsync(async () => + this.InvokeAsync(async () => { await this.SyncCompletedMediaTextAsync(); await this.ConsumeStandaloneMediaOutcomeAsync(); this.StateHasChanged(); - }); + }).Observe($"{nameof(ReadFileContent)}: syncing transcribed text"); } /// Consumes outcomes for dialog-local controls that have no assistant owner surface. @@ -195,7 +195,7 @@ public partial class ReadFileContent : MSGComponentBase // Release the drop area. Without this, drop areas below this one would count this component // forever and would stop catching dropped files: if (this.EnableDragDrop) - _ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer); + this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(ReadFileContent)}: releasing the drop area"); base.DisposeResources(); } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 071b9cbc..dfb9c434 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -5,10 +5,10 @@ - + @if (this.SettingsManager.ConfigurationData.App.LanguageBehavior is LangBehavior.MANUAL) { - + } diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 975055e3..8c5e6407 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -152,35 +152,10 @@ public partial class VoiceRecorder : MSGComponentBase return; } - try - { - if (runtimeState.Backend is ShortcutBackend.LOCAL - && !runtimeState.IsSuspended - && !string.IsNullOrWhiteSpace(runtimeState.Shortcut)) - { - await this.JsRuntime.InvokeVoidAsync( - "localShortcut.register", - "voice-recording-toggle", - runtimeState.Shortcut, - this.localShortcutDotNetReference); - } - else - { - await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle"); - } - } - catch (JSDisconnectedException) - { - this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected."); - } - catch (OperationCanceledException) - { - this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled."); - } - catch (JSException ex) - { - this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener."); - } + if (runtimeState.Backend is ShortcutBackend.LOCAL && !runtimeState.IsSuspended && !string.IsNullOrWhiteSpace(runtimeState.Shortcut)) + await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.register", "voice-recording-toggle", runtimeState.Shortcut, this.localShortcutDotNetReference); + else + await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle"); } private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager) @@ -561,13 +536,27 @@ public partial class VoiceRecorder : MSGComponentBase #region Overrides of MSGComponentBase + /// + /// Hands the focused-window shortcut back to the browser before this component goes away. + /// + /// + /// This belongs into the asynchronous part of the disposal: the base class runs it before + /// DisposeResources, and only here we can await the call. Discarding it instead left the + /// unregistration unfinished, and its failure on an already-disconnected circuit surfaced as an + /// unobserved task exception once the finalizer got to it. + /// + protected override async ValueTask DisposeResourcesAsync() + { + if (this.localShortcutInteropReady) + await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle"); + + await base.DisposeResourcesAsync(); + } + protected override void DisposeResources() { this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged; - if (this.localShortcutInteropReady) - _ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle"); - this.localShortcutDotNetReference?.Dispose(); this.localShortcutDotNetReference = null; this.localShortcutInteropReady = false; diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 8ec4165a..05e9c3d9 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -63,7 +63,7 @@ public partial class Workspaces : MSGComponentBase this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; await base.OnInitializedAsync(); this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]); - _ = this.LoadTreeItemsAsync(startPrefetch: true); + this.LoadTreeItemsAsync(startPrefetch: true).Observe($"{nameof(Workspaces)}: loading the workspace tree"); } #endregion @@ -445,7 +445,7 @@ public partial class Workspaces : MSGComponentBase private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner.Kind is MediaImportOwnerKind.CHAT) - _ = this.SafeStateHasChanged(); + this.SafeStateHasChanged().Observe($"{nameof(Workspaces)}: rendering a media import change"); } private async Task SafeStateHasChanged() diff --git a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs index 46cf6ea6..afb8d83b 100644 --- a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs @@ -179,17 +179,23 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase #region Overrides of MSGComponentBase + /// + /// Removes the escape key handler from the browser before this dialog goes away. + /// + /// + /// The base class runs this before DisposeResources, which is what lets us await the call. The + /// previous attempt discarded it inside a try/catch: a failing JS call reports itself on the task, + /// not to the caller, so that catch never ran and the fault ended up as an unobserved task + /// exception whenever the circuit was already gone. + /// + protected override async ValueTask DisposeResourcesAsync() + { + await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "unregisterEscapeHandler", this.escapeHandlerId); + await base.DisposeResourcesAsync(); + } + protected override void DisposeResources() { - try - { - _ = this.JsRuntime.InvokeVoidAsync("unregisterEscapeHandler", this.escapeHandlerId).AsTask(); - } - catch - { - // Ignore JS cleanup errors while the dialog is being disposed. - } - this.dotNetReference?.Dispose(); this.dotNetReference = null; diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 43596965..c66f174c 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -54,6 +54,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [Inject] private MudTheme ColorTheme { get; init; } = null!; + + [Inject] + private CircuitStateService CircuitState { get; init; } = null!; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; @@ -110,7 +113,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await this.SettingsManager.LoadSettings(); // Register this component with the message bus: - this.MessageBus.RegisterComponent(this); + this.MessageBus.RegisterComponent(this, this.CircuitState); this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR, @@ -234,7 +237,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan this.LoadNavItems(); this.StateHasChanged(); if (this.startupCompleted) - _ = this.EnsureMandatoryInfosAcceptedAsync(); + this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after a configuration change"); break; case Event.COLOR_THEME_CHANGED: @@ -281,7 +284,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan break; case Event.STARTUP_PLUGIN_SYSTEM: - _ = Task.Run(async () => + Task.Run(async () => { // Set up the plugin system: if (PluginFactory.Setup()) @@ -336,7 +339,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan PluginFactory.SetUpHotReloading(); await this.MessageBus.SendMessage(this, Event.STARTUP_COMPLETED); } - }); + }).Observe($"{nameof(MainLayout)}: setting up the plugin system"); break; case Event.PLUGINS_RELOADED: @@ -347,12 +350,12 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await this.InvokeAsync(this.StateHasChanged); if (this.startupCompleted) - _ = this.EnsureMandatoryInfosAcceptedAsync(); + this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after a plugin reload"); break; case Event.STARTUP_COMPLETED: this.startupCompleted = true; - _ = this.EnsureMandatoryInfosAcceptedAsync(); + this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after the startup"); break; } }); @@ -399,11 +402,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan /// Refreshes navigation activity colors when a media import changes state. private void OnMediaImportStateChanged(MediaImportOwner owner) { - _ = this.InvokeAsync(() => + this.InvokeAsync(() => { this.LoadNavItems(); this.StateHasChanged(); - }); + }).Observe($"{nameof(MainLayout)}: refreshing the navigation after a media import change"); } private IEnumerable GetNavItems() diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 6f3d2fbd..0ab09d15 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -39,10 +39,10 @@ public partial class Chat : MSGComponentBase this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition; this.splitterSaveTimer.AutoReset = false; - this.splitterSaveTimer.Elapsed += async (_, _) => + this.splitterSaveTimer.Elapsed += (_, _) => { this.SettingsManager.ConfigurationData.Workspace.SplitterPosition = this.splitterPosition; - await this.SettingsManager.StoreSettings(); + this.SettingsManager.StoreSettings().Observe($"{nameof(Chat)}: storing the splitter position"); }; await base.OnInitializedAsync(); diff --git a/app/MindWork AI Studio/Pages/Home.razor.cs b/app/MindWork AI Studio/Pages/Home.razor.cs index 3072e57b..2814a3e8 100644 --- a/app/MindWork AI Studio/Pages/Home.razor.cs +++ b/app/MindWork AI Studio/Pages/Home.razor.cs @@ -42,7 +42,7 @@ public partial class Home : MSGComponentBase // Read the last change content asynchronously // without blocking the UI thread: - _ = this.ReadLastChangeAsync(); + this.ReadLastChangeAsync().Observe($"{nameof(Home)}: reading the last change"); } protected override Task OnAfterRenderAsync(bool firstRender) diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 7ac6c9bf..a1614c8b 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -197,7 +197,7 @@ public partial class Information : MSGComponentBase // Determine the Pandoc version may take some time, so we start it here // without waiting for the result: - _ = this.DeterminePandocVersion(); + this.DeterminePandocVersion().Observe($"{nameof(Information)}: determining the Pandoc version"); } #endregion @@ -340,7 +340,7 @@ public partial class Information : MSGComponentBase this.vectorStoreRefreshCancellationTokenSource = new CancellationTokenSource(); var cancellationToken = this.vectorStoreRefreshCancellationTokenSource.Token; - _ = Task.Run(async () => + Task.Run(async () => { const int MAX_TRIES = 12; for (var attempt = 0; attempt < MAX_TRIES; attempt++) @@ -366,7 +366,7 @@ public partial class Information : MSGComponentBase return; } } - }, cancellationToken); + }, cancellationToken).Observe($"{nameof(Information)}: refreshing the vector store info"); } private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId) diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 4fca17fd..e7e09265 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -93,7 +93,7 @@ public partial class Plugins : MSGComponentBase protected override void DisposeResources() { // Release the drop area again, so lower layers can catch dropped files: - _ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES); + this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES).Observe($"{nameof(Plugins)}: releasing the drop area"); base.DisposeResources(); } diff --git a/app/MindWork AI Studio/Pages/Writer.razor.cs b/app/MindWork AI Studio/Pages/Writer.razor.cs index a2a70ea3..c6cbb021 100644 --- a/app/MindWork AI Studio/Pages/Writer.razor.cs +++ b/app/MindWork AI Studio/Pages/Writer.razor.cs @@ -27,7 +27,7 @@ public partial class Writer : MSGComponentBase protected override async Task OnInitializedAsync() { this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); - this.typeTimer.Elapsed += async (_, _) => await this.InvokeAsync(this.GetSuggestions); + this.typeTimer.Elapsed += (_, _) => this.InvokeAsync(this.GetSuggestions).Observe($"{nameof(Writer)}: getting writing suggestions"); this.typeTimer.AutoReset = false; await base.OnInitializedAsync(); diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 46cf1592..c07cb185 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -6241,7 +6241,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2646845972"] = "Hinzufügen UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2728244552"] = "Zusätzliche API-Parameter" -- Invalid JSON: Add the parameters in proper JSON formatting, e.g., "temperature": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2765821959"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. "temperature": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden." +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2765821959"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. \"temperature\": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden." -- No models loaded or available. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2810182573"] = "Keine Modelle geladen oder verfügbar." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index dd112cef..bc8ce052 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -12,6 +12,7 @@ using AIStudio.Tools.Rust; using AIStudio.Tools.Security; using AIStudio.Tools.Services; +using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Logging.Console; @@ -195,6 +196,13 @@ internal sealed class Program builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); + + // + // One circuit state per circuit, and the handler which keeps it up to date. Both are scoped, + // because the circuit is the scope: every browser window gets its own pair. + // + builder.Services.AddScoped(); + builder.Services.AddScoped(); // ReSharper disable AccessToDisposedClosure builder.Services.AddHostedService(_ => rust); @@ -243,7 +251,22 @@ internal sealed class Program // Get a program logger: var programLogger = app.Services.GetRequiredService>(); programLogger.LogInformation("Starting the AI Studio server."); - + + // + // Observe tasks whose exceptions nobody awaited. We register this before the server starts: + // otherwise, everything the startup does — the plugin system, the first message bus traffic — + // would fault outside of this handler. The sender of such a task says nothing about where it + // came from, which is why we log each inner exception with its own stack trace. + // + TaskScheduler.UnobservedTaskException += (sender, taskArgs) => + { + programLogger.LogError(taskArgs.Exception, $"Unobserved task exception by sender '{sender ?? "n/a"}'."); + foreach (var innerException in taskArgs.Exception.Flatten().InnerExceptions) + programLogger.LogError(innerException, $"Unobserved task exception detail: {innerException.GetType().FullName}."); + + taskArgs.SetObserved(); + }; + // Store the service provider (DI). We need it later for some classes, // which are not part of the request pipeline: SERVICE_PROVIDER = app.Services; @@ -295,13 +318,7 @@ internal sealed class Program await encryptionInitializer; await rust.AppIsReady(); programLogger.LogInformation("The AI Studio server is ready."); - - TaskScheduler.UnobservedTaskException += (sender, taskArgs) => - { - programLogger.LogError(taskArgs.Exception, $"Unobserved task exception by sender '{sender ?? "n/a"}'."); - taskArgs.SetObserved(); - }; - + await serverTask; RUST_SERVICE.Dispose(); diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index be8966e4..76c76454 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -131,7 +131,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes await CheckpointChatAsync(state, force: true); await this.NotifyChangedAsync(state); - _ = Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning); + // + // Unwrap matters here: StartNew with an async delegate hands back a task which completes as soon + // as the generation started, wrapping the task which does the actual work. Watching the outer one + // would tell us nothing about how the generation itself ended. + // + Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning).Unwrap().Observe($"{nameof(AIJobService)}: running a chat generation"); return state.Snapshot; } diff --git a/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs b/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs index 702d2732..4011046a 100644 --- a/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs +++ b/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs @@ -1,16 +1,121 @@ using AIStudio.Assistants; +using AIStudio.Tools.Services; namespace AIStudio.Tools; public static class JsRuntimeExtensions { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(JsRuntimeExtensions)); + public static async Task GenerateAndShowDiff(this IJSRuntime jsRuntime, string text1, string text2) { await jsRuntime.InvokeVoidAsync("generateDiff", text1, text2, AssistantLowerBase.RESULT_DIV_ID, AssistantLowerBase.BEFORE_RESULT_DIV_ID); } - + public static async Task ClearDiv(this IJSRuntime jsRuntime, string divId) { await jsRuntime.InvokeVoidAsync("clearDiv", divId); } + + /// + /// Calls a JavaScript function which returns nothing, and tolerates a circuit which is already gone. + /// + /// + /// Blazor cannot issue JS interop calls once the browser connection of a circuit is gone. That happens + /// during every reload and while a component gets disposed, so the failure is expected rather than + /// exceptional. Discarding such a call is not an option, though: the discarded task keeps the fault + /// until the finalizer reports it as an unobserved task exception, without any hint at its origin. + /// This method is the one place which knows how to await such a call and what to do with its failure. + /// + /// The JS runtime to call. + /// The name of the JavaScript function. + /// The arguments for the JavaScript function. + /// True when the browser ran the function. Callers which remember what they told the browser + /// must check this: a call which never arrived leaves the browser in its previous state. + public static async ValueTask TryInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, params object?[]? args) + { + try + { + await jsRuntime.InvokeVoidAsync(identifier, args); + return true; + } + catch (Exception exception) + { + LogInvocationFailure(exception, identifier); + return false; + } + } + + /// + /// Calls a JavaScript function which returns nothing, unless the circuit is known to be disconnected. + /// + /// + /// Prefer this over the variant without a circuit state wherever the caller knows its circuit. While a + /// browser connection is gone, every single call would otherwise throw, which is needless work for + /// something we already know cannot succeed — a component of a disconnected circuit which keeps + /// rendering would produce one such exception per render. + /// + /// The JS runtime to call. + /// The circuit of the caller. + /// The name of the JavaScript function. + /// The arguments for the JavaScript function. + /// True when the browser ran the function, false when it was skipped or failed. + public static async ValueTask TryInvokeVoidAsync(this IJSRuntime jsRuntime, CircuitStateService circuitState, string identifier, params object?[]? args) + { + if (!circuitState.IsConnected) + { + LOGGER.LogDebug("The JS call '{Identifier}' was skipped because the browser connection of the circuit '{CircuitId}' is down.", identifier, circuitState.CircuitId); + return false; + } + + return await jsRuntime.TryInvokeVoidAsync(identifier, args); + } + + /// + /// Calls a function of a JavaScript module which returns nothing, and tolerates a circuit which is + /// already gone. See the remarks on the JS runtime variant of this method. + /// + /// The JavaScript module to call. + /// The name of the function inside the module. + /// The arguments for the function. + /// True when the browser ran the function, false when it failed. + public static async ValueTask TryInvokeVoidAsync(this IJSObjectReference module, string identifier, params object?[]? args) + { + try + { + await module.InvokeVoidAsync(identifier, args); + return true; + } + catch (Exception exception) + { + LogInvocationFailure(exception, identifier); + return false; + } + } + + private static void LogInvocationFailure(Exception exception, string identifier) + { + switch (exception) + { + // + // The circuit is disconnected or disposed, or the call was canceled while it was on its way. + // None of this is a defect: it is what a reload, a lost connection, or a disposed component + // looks like from here. + // + case JSDisconnectedException: + case ObjectDisposedException: + case OperationCanceledException: + LOGGER.LogDebug("The JS call '{Identifier}' was not completed because the browser connection was gone: {Reason}", identifier, exception.Message); + break; + + // The call reached the browser, but failed there. That is worth knowing about: + case JSException: + LOGGER.LogWarning(exception, "The JS call '{Identifier}' failed in the browser.", identifier); + break; + + default: + LOGGER.LogError(exception, "The JS call '{Identifier}' failed unexpectedly.", identifier); + break; + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index 93835ede..c92785ee 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; +using AIStudio.Tools.Services; + using Microsoft.AspNetCore.Components; // ReSharper disable RedundantRecordClassKeyword @@ -11,6 +13,7 @@ public sealed class MessageBus private readonly ConcurrentDictionary componentFilters = new(); private readonly ConcurrentDictionary componentEvents = new(); + private readonly ConcurrentDictionary receiverCircuits = new(); private readonly ConcurrentDictionary> deferredMessages = new(); private readonly ConcurrentQueue messageQueue = new(); private readonly SemaphoreSlim sendingSemaphore = new(1, 1); @@ -39,16 +42,53 @@ public sealed class MessageBus this.componentEvents[receiver] = events.ToArray(); } - public void RegisterComponent(IMessageBusReceiver receiver) + /// + /// Registers a receiver at the bus. + /// + /// That's you, the receiver. + /// The circuit this receiver belongs to. Components hand over their circuit + /// so the bus can let them go when that circuit ends. Services which live longer than any circuit, + /// such as hosted services, hand over nothing. + public void RegisterComponent(IMessageBusReceiver receiver, CircuitStateService? circuitState = null) { this.componentFilters.TryAdd(receiver, []); this.componentEvents.TryAdd(receiver, []); + + if (circuitState is not null) + this.receiverCircuits[receiver] = circuitState; } - + public void Unregister(IMessageBusReceiver receiver) { this.componentFilters.TryRemove(receiver, out _); this.componentEvents.TryRemove(receiver, out _); + this.receiverCircuits.TryRemove(receiver, out _); + } + + /// + /// Removes all receivers which belong to one circuit. + /// + /// + /// The circuit handler calls this when a circuit ends. Components deregister themselves when they get + /// disposed, but a circuit which was retained and then dropped does not give all of them that chance. + /// Since the bus holds a strong reference to every receiver, those leftovers would stay and would be + /// served forever. + /// + /// The circuit whose receivers must go. + /// The number of removed receivers. + public int UnregisterCircuit(CircuitStateService circuitState) + { + var numRemovedReceivers = 0; + foreach (var (receiver, receiverCircuit) in this.receiverCircuits) + { + if (!ReferenceEquals(receiverCircuit, circuitState)) + continue; + + this.Unregister(receiver); + numRemovedReceivers++; + } + + return numRemovedReceivers; } private record class Message(ComponentBase? SendingComponent, Event TriggeredEvent, object? Data); @@ -71,7 +111,7 @@ public sealed class MessageBus if (eventFilter.Length == 0 || eventFilter.Contains(message.TriggeredEvent)) // We don't await the task here because we don't want to block the message bus: - _ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); + _ = DeliverMessage(receiver, message); } } } @@ -85,6 +125,38 @@ public sealed class MessageBus } } + /// + /// Hands one message to one receiver and observes how that went. + /// + /// + /// The bus must not wait for a receiver, since one slow receiver would hold up everybody else. Not + /// waiting is not the same as not caring, though: a receiver whose circuit is gone fails with a + /// disconnect or disposal exception, and nobody would ever see where it came from. Such a task + /// carries its fault until the finalizer reports it as an unobserved task exception — naming a task + /// type instead of the receiver and the event. This is where we give those failures a name. + /// + /// The receiver of the message. + /// The message to deliver. + private static async Task DeliverMessage(IMessageBusReceiver receiver, Message message) + { + try + { + await receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); + } + catch (Exception exception) when (exception is JSDisconnectedException or ObjectDisposedException or OperationCanceledException) + { + // + // Expected whenever the browser connection of a receiver is gone: the app keeps circuits + // of reloaded or sleeping windows around, and their components still receive events. + // + LOG?.LogDebug("The receiver '{ReceiverName}' did not process the event '{Event}' because its circuit was gone: {Reason}", receiver.GetType().Name, message.TriggeredEvent, exception.Message); + } + catch (Exception exception) + { + LOG?.LogError(exception, "The receiver '{ReceiverName}' failed while processing the event '{Event}'.", receiver.GetType().Name, message.TriggeredEvent); + } + } + public Task SendError(DataErrorMessage dataErrorMessage) => this.SendMessage(null, Event.SHOW_ERROR, dataErrorMessage); public Task SendWarning(DataWarningMessage dataWarningMessage) => this.SendMessage(null, Event.SHOW_WARNING, dataWarningMessage); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs index 3b5d4ce8..b66d5c76 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs @@ -50,7 +50,7 @@ public static partial class PluginFactory LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'."); try { - HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => _ = ReloadPluginsAsync(); + HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => ReloadPluginsAsync().Observe($"{nameof(PluginFactory)}: hot reloading plugins"); HOT_RELOAD_WATCHER.IncludeSubdirectories = true; diff --git a/app/MindWork AI Studio/Tools/Services/AIStudioCircuitHandler.cs b/app/MindWork AI Studio/Tools/Services/AIStudioCircuitHandler.cs new file mode 100644 index 00000000..ffec87ec --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/AIStudioCircuitHandler.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Components.Server.Circuits; + +namespace AIStudio.Tools.Services; + +/// +/// Follows the life of one circuit, so the rest of the app knows when its browser is unreachable. +/// +/// +/// The app keeps disconnected circuits for a long time on purpose, cf. the retention settings in +/// Program.cs. That is what lets a user return to a working app after the machine woke up — but it also +/// means that the components of reloaded or sleeping windows stay alive and keep receiving events. They +/// may keep working: everything they do on the server is fine. Only JavaScript interop is impossible +/// while the connection is gone. So this handler does two things, and deliberately nothing more: +/// it publishes the connection state, and it cleans up once a circuit is truly over. +/// +public sealed class AIStudioCircuitHandler(CircuitStateService circuitState, MessageBus messageBus, ILogger logger) + : CircuitHandler +{ + #region Overrides of CircuitHandler + + public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken) + { + circuitState.AssignCircuit(circuit.Id); + logger.LogInformation("The circuit '{CircuitId}' was opened.", circuit.Id); + + return Task.CompletedTask; + } + + public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken) + { + circuitState.MarkAsConnected(); + logger.LogInformation("The browser connection of the circuit '{CircuitId}' is up.", circuit.Id); + + return Task.CompletedTask; + } + + public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) + { + circuitState.MarkAsDisconnected(); + logger.LogInformation("The browser connection of the circuit '{CircuitId}' is down. Its JavaScript interop is paused until it returns.", circuit.Id); + + return Task.CompletedTask; + } + + public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken) + { + circuitState.MarkAsDisconnected(); + + // + // The components of this circuit will not come back, so nobody would ever deregister them: + // Blazor disposes components of a retained circuit without giving them a chance to run their + // disposal in every case. Without this, the message bus would keep and serve them forever. + // + var numRemovedReceivers = messageBus.UnregisterCircuit(circuitState); + logger.LogInformation("The circuit '{CircuitId}' was closed. Removed {NumReceivers} message bus receiver(s) of that circuit.", circuit.Id, numRemovedReceivers); + + return Task.CompletedTask; + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/CircuitStateService.cs b/app/MindWork AI Studio/Tools/Services/CircuitStateService.cs new file mode 100644 index 00000000..631d1dc8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/CircuitStateService.cs @@ -0,0 +1,46 @@ +namespace AIStudio.Tools.Services; + +/// +/// Knows whether the browser connection of one circuit is currently up. +/// +/// +/// There is one instance of this service per circuit, i.e. per browser window. It exists because the app +/// keeps disconnected circuits around for a long time, cf. the retention settings in Program.cs: after a +/// reload or while the machine sleeps, the components of the old circuit are still alive and still receive +/// events. They may do their work as before — only JavaScript interop is impossible while the connection +/// is gone. This is what tells them apart. Only the circuit handler changes this state. +/// +public sealed class CircuitStateService +{ + private volatile bool isConnected = true; + + /// + /// True, as long as the browser of this circuit is reachable, and thus JS interop is possible. + /// + /// + /// This starts as true: a circuit is created for a connected browser, and the handler reports the + /// first connection only afterwards. Starting as false would block the interop of the first render. + /// + public bool IsConnected => this.isConnected; + + /// + /// The ID of this circuit, for logging purposes. It is "n/a" until the circuit was opened. + /// + public string CircuitId { get; private set; } = "n/a"; + + /// + /// Called by the circuit handler when the circuit was opened. + /// + /// The ID of the opened circuit. + public void AssignCircuit(string circuitId) => this.CircuitId = circuitId; + + /// + /// Called by the circuit handler when the browser connection was established or restored. + /// + public void MarkAsConnected() => this.isConnected = true; + + /// + /// Called by the circuit handler when the browser connection was lost or the circuit ended. + /// + public void MarkAsDisconnected() => this.isConnected = false; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs index 6da9290f..5f586684 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs +++ b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs @@ -173,7 +173,7 @@ public sealed class MediaTranscriptionService( } this.UpdateImportState(target, Path.GetFileName(mediaPaths[0]), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); - _ = Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat)); + Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat)).Observe($"{nameof(MediaTranscriptionService)}: running an attachment batch"); return true; } @@ -191,7 +191,7 @@ public sealed class MediaTranscriptionService( } this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); - _ = Task.Run(() => this.RunTextImportAsync(mediaPath, target)); + Task.Run(() => this.RunTextImportAsync(mediaPath, target)).Observe($"{nameof(MediaTranscriptionService)}: running a text import"); return true; } diff --git a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs index e4026fd3..caaf987f 100644 --- a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs @@ -72,8 +72,8 @@ public sealed class RustAvailabilityMonitorService : BackgroundService, IMessage // be a transient issue. // - _ = this.VerifyRustAvailability(); - _ = this.VerifyRustAvailability(); + this.VerifyRustAvailability().Observe($"{nameof(RustAvailabilityMonitorService)}: verifying the Rust availability"); + this.VerifyRustAvailability().Observe($"{nameof(RustAvailabilityMonitorService)}: verifying the Rust availability"); } if (numEvents <= UNAVAILABLE_EVENT_THRESHOLD) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs index c43f0ff9..4898e3b1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs @@ -26,7 +26,12 @@ public sealed partial class RustService { try { - // Fire-and-forget the log event to avoid blocking: + // + // Fire-and-forget the log event to avoid blocking. This is the one place which deliberately + // discards its task instead of observing it: observing means logging the failure, and logging + // means sending another log event through this very method. A broken connection to Rust would + // feed itself. The unobserved task exception handler in Program.cs remains the safety net here. + // var request = new LogEventRequest(timestamp, level, category, message, exception, stackTrace); _ = this.http.PostAsJsonAsync("/log/event", request, this.jsonRustSerializerOptions); } diff --git a/app/MindWork AI Studio/Tools/TaskExtensions.cs b/app/MindWork AI Studio/Tools/TaskExtensions.cs new file mode 100644 index 00000000..e8e98d0a --- /dev/null +++ b/app/MindWork AI Studio/Tools/TaskExtensions.cs @@ -0,0 +1,53 @@ +namespace AIStudio.Tools; + +public static class TaskExtensions +{ + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(TaskExtensions)); + + /// + /// Lets a task run on its own, but keeps an eye on how it ends. + /// + /// + /// Use this wherever a task is started without awaiting it. Discarding one instead means that nobody + /// ever looks at its outcome: the task carries its exception until the garbage collector finalizes it, + /// and only then does it show up as an unobserved task exception — naming a task type, without any + /// hint at what was running. Around a circuit which is gone, that is the common case: components of a + /// reloaded or sleeping window still receive events and still schedule work. + /// + /// The task to watch. + /// What this task was doing, for the log entry. + public static void Observe(this Task task, string context) + { + task.ContinueWith(finishedTask => + LogFailure(finishedTask.Exception, context), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static void LogFailure(AggregateException? exception, string context) + { + if (exception is null) + return; + + foreach (var innerException in exception.Flatten().InnerExceptions) + { + switch (innerException) + { + // + // The browser connection is gone, or the component was disposed while its work was still + // on its way. Neither is a defect: it is what a reload or a closed window looks like. + // + case JSDisconnectedException: + case ObjectDisposedException: + case OperationCanceledException: + LOGGER.LogDebug("Background work '{Context}' stopped because its circuit was gone: {Reason}", context, innerException.Message); + break; + + default: + LOGGER.LogError(innerException, "Background work '{Context}' failed.", context); + break; + } + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.2.md index c10a8d02..df9d3043 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.2.md @@ -4,6 +4,7 @@ - Improved the safety of plugin symbols: AI Studio now shows the symbol of a plugin in isolation, so nothing inside a symbol can reach the rest of the app. - Improved how much memory AI Studio needs. Working with large documents used to grow the app to several gigabytes, and on macOS that memory was never handed back. AI Studio now stays at a fraction of that and returns memory to your system. This matters most on devices with little memory, such as a Raspberry Pi. - Improved the preview for large documents. It now shows you the beginning of your document instead of loading all of it, so the dialog opens right away. Your complete document still goes to the AI. +- Improved how AI Studio deals with rare internal hiccups. When the app window reloads, or when it briefly loses the connection to its own user interface, work which was still running in the background is now ended properly instead of leaving errors behind. - Fixed AI Studio reading a document to the end even after you closed its preview. Closing the dialog now stops that work immediately. - Fixed AI Studio holding on to finished chats, presentation images, and plugin data. It releases them now, so memory no longer grows the longer you keep the app running. - Fixed assistants handing an outdated result to the chat. When you sent several results without opening the chat in between, you now receive the one you sent last.