Fixed an incomplete plugin start with extensive enterprise configurations (#1033)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-09 20:11:15 +02:00
1 parent bdf828fa4f
commit 56215aa6dd
16 files changed
+493 -70

No files matched your search

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Linq.Expressions;
using System.Text;
using AIStudio.Settings;
@@ -13,6 +14,18 @@ public static partial class PluginFactory
private static readonly List<IAvailablePlugin> AVAILABLE_PLUGINS = [];
private static readonly SemaphoreSlim PLUGIN_LOAD_SEMAPHORE = new(1, 1);
/// <summary>
/// How long a single plugin may take to load, and again to start.
/// </summary>
/// <remarks>
/// The limit applies to each plugin on its own, never to all plugins together. A shared budget
/// let a slow machine spend it on the first plugins, so the ones at the end of the line never
/// started: the language plugin among them, and configuration plugins of the organization,
/// whose objects the clean-up then removed. The limit holds in every build, so a developer sees
/// the same start as a user does.
/// </remarks>
private static readonly TimeSpan PLUGIN_TIMEOUT = TimeSpan.FromSeconds(10);
/// <summary>
/// A list of all available plugins.
/// </summary>
@@ -42,6 +55,8 @@ public static partial class PluginFactory
await PLUGIN_LOAD_SEMAPHORE.WaitAsync(cancellationToken);
var configObjectList = new List<PluginConfigurationObject>();
var hasStartedConfigurationPlugins = false;
var notStartedConfigPluginIds = new HashSet<Guid>();
try
{
@@ -65,11 +80,13 @@ public static partial class PluginFactory
IEnumerable<string> pluginMainFiles = pluginsDirectoryExists ? Directory.EnumerateFiles(PLUGINS_ROOT, "plugin.lua", SearchOption.AllDirectories) : [];
foreach (var pluginMainFile in pluginMainFiles)
{
using var pluginTimeout = CreatePluginTimeout(cancellationToken);
var loadingStartedAt = Stopwatch.GetTimestamp();
try
{
if (cancellationToken.IsCancellationRequested)
{
LOG.LogWarning("Was not able to load all plugins, because the operation was cancelled. It seems to be a timeout.");
LOG.LogWarning("Was not able to load all plugins, because the operation was cancelled.");
break;
}
@@ -79,11 +96,11 @@ public static partial class PluginFactory
await using(var fileStream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
using var reader = new StreamReader(fileStream, Encoding.UTF8);
code = await reader.ReadToEndAsync(cancellationToken);
code = await reader.ReadToEndAsync(pluginTimeout.Token);
}
var pluginPath = Path.GetDirectoryName(pluginMainFile)!;
var plugin = await Load(pluginPath, code, cancellationToken: cancellationToken);
var plugin = await Load(pluginPath, code, cancellationToken: pluginTimeout.Token);
switch (plugin)
{
@@ -189,6 +206,10 @@ public static partial class PluginFactory
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId, configurationPriority));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
LOG.LogError($"Was not able to load plugin '{pluginMainFile}'. Reason: It did not finish within the limit of {PLUGIN_TIMEOUT.TotalSeconds:0} seconds and was stopped after {Stopwatch.GetElapsedTime(loadingStartedAt).TotalSeconds:0.0} seconds. The remaining plugins load regardless.");
}
catch (Exception e)
{
LOG.LogError($"Was not able to load plugin '{pluginMainFile}'. Issue: {e.Message}");
@@ -201,6 +222,25 @@ public static partial class PluginFactory
{
var configObjects = await RestartAllPlugins(cancellationToken);
configObjectList.AddRange(configObjects);
//
// Each configuration plugin which started has changed the settings, but none of
// them stored the change. We store all of it once, at the end of this method:
//
hasStartedConfigurationPlugins = RUNNING_PLUGINS.OfType<PluginConfiguration>().Any();
//
// A configuration plugin which loaded but did not start has contributed nothing to
// the configuration objects. To the clean-up below, it would look as if the plugin
// had dropped all of its objects, and they would be deleted, secrets included. That
// is what a slow machine did to the configuration of an organization, once the
// time ran out before its plugin got its turn:
//
foreach (var notStartedConfigPlugin in AVAILABLE_PLUGINS.Where(plugin => plugin.Type is PluginType.CONFIGURATION && RUNNING_PLUGINS.All(runningPlugin => runningPlugin.Id != plugin.Id)))
{
notStartedConfigPluginIds.Add(notStartedConfigPlugin.Id);
LOG.LogWarning($"The configuration plugin '{notStartedConfigPlugin.Name}' (Id='{notStartedConfigPlugin.Id}') was loaded, but did not start. Everything it manages stays unchanged until it starts again. Please check the errors above.");
}
}
}
finally
@@ -255,35 +295,42 @@ public static partial class PluginFactory
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
}
var wasConfigurationChanged = hasStartedConfigurationPlugins;
// Check LLM providers:
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER))
wasConfigurationChanged = true;
// Check transcription providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
wasConfigurationChanged = true;
// Check embedding providers:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
wasConfigurationChanged = true;
// Check data sources:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
wasConfigurationChanged = true;
// Check chat templates:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check profiles:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check document analysis policies:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
wasConfigurationChanged = true;
// Check left-over mandatory info acceptances:
if (SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
//
// Check left-over mandatory info acceptances. The mandatory infos come from the running
// configuration plugins only, so we skip this while one of them did not start: the user
// would have to accept its infos again, although nothing about them changed:
//
if (notStartedConfigPluginIds.Count == 0 && SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
wasConfigurationChanged = true;
// Check all managed settings, i.e. settings which a configuration plugin can lock,
@@ -294,11 +341,12 @@ public static partial class PluginFactory
//
// The enterprise approvals of all configuration plugins add up. Now that every plugin has
// contributed and the clean-up above has dropped the removed ones, we rebuild the effective
// list. We skip that while a configuration plugin is deployed but could not be loaded: its
// approvals are missing from the contributions, and withdrawing them would demand a new
// security audit for assistant plugins the organization has approved:
// list. We skip that while a configuration plugin is deployed but could not be loaded, or
// was loaded but did not start: its approvals are missing from the contributions, and
// withdrawing them would demand a new security audit for assistant plugins the
// organization has approved:
//
if(unloadedEnterpriseConfigPluginIds.Count == 0 && PluginConfiguration.RefreshEnterpriseApprovedAssistantPlugins())
if(unloadedEnterpriseConfigPluginIds.Count == 0 && notStartedConfigPluginIds.Count == 0 && PluginConfiguration.RefreshEnterpriseApprovedAssistantPlugins())
wasConfigurationChanged = true;
//
@@ -310,7 +358,7 @@ public static partial class PluginFactory
wasConfigurationChanged = true;
// Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
if (RepairLegacyConfigOnlySettings(unloadedEnterpriseConfigPluginIds.Count > 0))
if (RepairLegacyConfigOnlySettings(unloadedEnterpriseConfigPluginIds.Count > 0 || notStartedConfigPluginIds.Count > 0))
wasConfigurationChanged = true;
if (wasConfigurationChanged)
@@ -354,6 +402,23 @@ public static partial class PluginFactory
return deployedEnterpriseConfigPluginIds;
}
/// <summary>
/// Creates the time limit for loading or starting a single plugin.
/// </summary>
/// <remarks>
/// The limit is linked to the token of the caller, so cancelling the whole operation still stops
/// the plugin at hand. Tell the two apart by that token: when it is not cancelled, the plugin ran
/// out of time, and only this plugin is affected.
/// </remarks>
/// <param name="cancellationToken">The token of the whole operation.</param>
/// <returns>A token source that cancels after PLUGIN_TIMEOUT or together with the caller's token.</returns>
private static CancellationTokenSource CreatePluginTimeout(CancellationToken cancellationToken)
{
var pluginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
pluginTimeout.CancelAfter(PLUGIN_TIMEOUT);
return pluginTimeout;
}
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="code">The Lua code of the plugin's main file.</param>
/// <param name="allowedBaseDirectory">
@@ -463,17 +528,17 @@ public static partial class PluginFactory
/// This is only valid as long as none of these settings gets a user interface. When you add
/// one, remove the setting from this method and from the shim's document.
/// </remarks>
/// <param name="hasUnloadedConfigPlugins" >
/// True when at least one configuration plugin is deployed but could not be loaded. In that case,
/// we cannot tell whether a value comes from that plugin or from a removed one, so we repair
/// nothing at all.
/// <param name="hasMissingConfigPlugins" >
/// True when at least one configuration plugin is deployed but could not be loaded, or was loaded
/// but did not start. In that case, we cannot tell whether a value comes from that plugin or from
/// a removed one, so we repair nothing at all.
/// </param>
/// <returns>True when at least one setting was repaired, otherwise false.</returns>
private static bool RepairLegacyConfigOnlySettings(bool hasUnloadedConfigPlugins)
private static bool RepairLegacyConfigOnlySettings(bool hasMissingConfigPlugins)
{
if (hasUnloadedConfigPlugins)
if (hasMissingConfigPlugins)
{
LOG.LogWarning("Skipping the repair of configuration-only settings: at least one configuration plugin is deployed, but could not be loaded. We try again the next time AI Studio starts.");
LOG.LogWarning("Skipping the repair of configuration-only settings: at least one configuration plugin could not be loaded or did not start. We try again the next time AI Studio starts.");
return false;
}