Hardened background work against disconnected circuits (#937)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-25 18:53:09 +02:00
1 parent 99089f853e
commit f03c2d1c88
44 files changed
+500 -126

No files matched your search

@@ -167,7 +167,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase, IAssistantCat
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (this.OwnedByThisBlock(owner))
_ = this.InvokeAsync(this.StateHasChanged);
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<TSettings>)}: rendering a media import change");
}
protected override void DisposeResources()
@@ -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");
}
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
@@ -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();
}
@@ -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");
}
/// <summary>Consumes a terminal media notification when its chat is visible.</summary>
@@ -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)
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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;
@@ -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!;
/// <summary>
/// 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.
/// </summary>
[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();
}
@@ -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");
}
/// <summary>Unsubscribes from singleton import state changes.</summary>
@@ -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");
}
}
@@ -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");
}
/// <summary>Consumes outcomes for dialog-local controls that have no assistant owner surface.</summary>
@@ -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();
}
@@ -5,10 +5,10 @@
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")">
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => _ = this.UpdateLangBehaviour(selectedValue))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => this.UpdateLangBehaviour(selectedValue).Observe(nameof(this.UpdateLangBehaviour)))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
@if (this.SettingsManager.ConfigurationData.App.LanguageBehavior is LangBehavior.MANUAL)
{
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => _ = this.UpdateManuallySelectedLanguage(selectedValue))" OptionHelp="@T("Select the language for the app.")"/>
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => this.UpdateManuallySelectedLanguage(selectedValue).Observe(nameof(this.UpdateManuallySelectedLanguage)))" OptionHelp="@T("Select the language for the app.")"/>
}
<ConfigurationSelect OptionDescription="@T("Color theme")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="@T("Choose the color theme that best suits for you.")"/>
@@ -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
/// <summary>
/// Hands the focused-window shortcut back to the browser before this component goes away.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
@@ -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()