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

@@ -12,7 +12,6 @@ namespace AIStudio.Tools.PluginSystem;
public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type), ILivePluginContentSource
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration));
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration));
private List<PluginConfigurationObject> configObjects = [];
@@ -83,8 +82,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
await StoreEnterpriseApiKeysAsync();
await StoreEnterpriseSecretsAsync();
await SettingsManagerAccess.StoreSettings();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
//
// The settings are not stored here: PluginFactory.LoadAll does that once, after all
// plugins have started. Storing them per configuration plugin wrote the whole settings
// file again for each one, which made the start of every further plugin wait for it.
//
}
}
@@ -143,11 +143,11 @@ public sealed record PluginConfigurationObject
var (wasParsingSuccessful, configObject) = configObjectType switch
{
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject),
PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject),
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => (DataDocumentAnalysisPolicy.TryProcessConfiguration(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject is DataDocumentAnalysisPolicy, configurationObject),
_ => (false, NoConfigurationObject.INSTANCE)
@@ -278,7 +278,7 @@ public sealed record PluginConfigurationObject
continue;
}
if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, out var configObject))
if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, dryRun, out var configObject))
{
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId);
continue;
@@ -372,6 +372,11 @@ public sealed record PluginConfigurationObject
/// those which could not be loaded. Objects of a deployed plugin are never removed, because the
/// plugin was not removed either.
/// </param>
/// <param name="notStartedConfigPluginIds">
/// The IDs of the configuration plugins which were loaded, but did not start. Their objects are
/// never removed either: such a plugin contributed nothing to the list of configuration objects,
/// so all of its objects would look as if the plugin had dropped them.
/// </param>
/// <param name="configObjectList">A list of all existing configuration objects.</param>
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
/// <param name="deleteSecret">When true, delete the associated non-API-key secret from the OS keyring.</param>
@@ -381,6 +386,7 @@ public sealed record PluginConfigurationObject
Expression<Func<Data, List<TClass>>> configObjectSelection,
IList<IAvailablePlugin> availablePlugins,
IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds,
IReadOnlySet<Guid> notStartedConfigPluginIds,
IList<PluginConfigurationObject> configObjectList,
SecretStoreType? secretStoreType = null,
bool deleteSecret = false) where TClass : IConfigurationObject
@@ -410,6 +416,15 @@ public sealed record PluginConfigurationObject
if(deployedEnterpriseConfigPluginIds.Contains(configObjectSourcePluginId) && availablePlugins.All(plugin => plugin.Id != configObjectSourcePluginId))
continue;
//
// Was the source plugin loaded, but did not start? Then it is not broken, it could not
// run this time, e.g., because it ran out of time on a slow machine. It contributed no
// objects, so every one of its objects would look removed from the plugin. They stay
// until the plugin starts again and tells us which ones it still defines:
//
if(notStartedConfigPluginIds.Contains(configObjectSourcePluginId))
continue;
// Is the source plugin still available? If not, we can be pretty sure that this configuration object is left
// over and should be removed:
var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId);
@@ -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;
}
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text;
using AIStudio.Models.Registry;
using AIStudio.Settings;
@@ -10,6 +11,18 @@ public static partial class PluginFactory
{
private static readonly List<PluginBase> RUNNING_PLUGINS = [];
/// <summary>
/// From this duration on, the start of a single plugin is reported as slow.
/// </summary>
/// <remarks>
/// The plugins start one after another, so every plugin after a slow one has to wait. A plugin
/// usually starts within milliseconds. A configuration plugin takes longer, because it applies its
/// configuration, stores its secrets in the OS keyring, and loads tokenizers, but seconds are
/// still far beyond that. The time limit per plugin covers only running its Lua code, so this
/// warning is the only sign of a plugin which spends its time elsewhere.
/// </remarks>
private static readonly TimeSpan SLOW_PLUGIN_START_THRESHOLD = TimeSpan.FromSeconds(2);
/// <summary>
/// A list of all running plugins.
/// </summary>
@@ -41,7 +54,7 @@ public static partial class PluginFactory
{
try
{
var startedBasePlugin = await Start(baseLanguagePluginMetaData, cancellationToken);
var startedBasePlugin = await StartWithTimeout(baseLanguagePluginMetaData, cancellationToken);
if (startedBasePlugin is NoPlugin noPlugin)
LOG.LogError($"Was not able to start the base language plugin: Id='{baseLanguagePluginId}'. Reason: {noPlugin.Issues.First()}");
@@ -84,7 +97,7 @@ public static partial class PluginFactory
{
if(cancellationToken.IsCancellationRequested)
{
LOG.LogWarning("Cancellation requested while starting plugins. Stopping the plugin startup process. Probably due to a timeout.");
LOG.LogWarning("Cancellation requested while starting plugins. Stopping the plugin startup process.");
break;
}
@@ -100,7 +113,7 @@ public static partial class PluginFactory
// differently from the other half for no reason anyone could see.
//
if (availablePlugin.IsInternal || SettingsManagerAccess.IsPluginEnabled(availablePlugin) || availablePlugin.Type is PluginType.CONFIGURATION or PluginType.ASSISTANT or PluginType.MODEL)
if(await Start(availablePlugin, cancellationToken) is { IsValid: true } plugin)
if(await StartWithTimeout(availablePlugin, cancellationToken) is { IsValid: true } plugin)
{
if (plugin is PluginConfiguration configPlugin)
configObjects.AddRange(configPlugin.ConfigObjects);
@@ -184,6 +197,37 @@ public static partial class PluginFactory
}
}
/// <summary>
/// Starts a plugin within the time limit each plugin gets on its own.
/// </summary>
/// <remarks>
/// A plugin which runs out of time is not started, and the next plugin gets the full limit
/// again. Only the caller's token stops the start of all plugins.
/// </remarks>
/// <param name="meta">The plugin to start.</param>
/// <param name="cancellationToken">The token of the whole start.</param>
/// <returns>The started plugin, or a NoPlugin when it could not be started in time.</returns>
private static async Task<PluginBase> StartWithTimeout(IAvailablePlugin meta, CancellationToken cancellationToken)
{
using var pluginTimeout = CreatePluginTimeout(cancellationToken);
var startingStartedAt = Stopwatch.GetTimestamp();
try
{
var plugin = await Start(meta, pluginTimeout.Token);
var startingDuration = Stopwatch.GetElapsedTime(startingStartedAt);
if (startingDuration >= SLOW_PLUGIN_START_THRESHOLD)
LOG.LogWarning($"Starting the plugin took {startingDuration.TotalMilliseconds:F0} ms, which is unusually long: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. All plugins after it had to wait for it.");
return plugin;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
var reason = $"It did not finish within the limit of {PLUGIN_TIMEOUT.TotalSeconds:0} seconds and was stopped after {Stopwatch.GetElapsedTime(startingStartedAt).TotalSeconds:0.0} seconds.";
LOG.LogError($"Was not able to start plugin: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. Reason: {reason} The remaining plugins start regardless.");
return new NoPlugin(reason);
}
}
private static async Task<PluginBase> Start(IAvailablePlugin meta, CancellationToken cancellationToken = default)
{
var pluginMainFile = Path.Join(meta.LocalPath, "plugin.lua");