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

@@ -0,0 +1,77 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks that storing a file attachment never asks the file system about it.
/// </summary>
/// <remarks>
/// Whether the file still exists is a question to the file system, and on a network share which is
/// out of reach it takes until the SMB timeout to get an answer -- about 20 seconds. Chat templates
/// carry attachments, so every store of the settings would wait that long, and the plugins starting
/// during that time ran out of their budget. The answer is not worth storing anyway: it is stale the
/// moment it is written.
/// </remarks>
[TestFixture]
public sealed class FileAttachmentSerializationTests
{
private const string FILE_PATH = @"\\10.255.255.1\share\report.pdf";
[Test]
public void AChatTemplateIsStoredWithoutCheckingItsAttachments()
{
var template = new ChatTemplate
{
FileAttachments = [new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", FILE_PATH, 1024)],
ExampleConversation =
[
new ContentBlock
{
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = new ContentText
{
Text = "Summarize the report.",
FileAttachments = [new FileAttachmentImage("chart.png", @"\\10.255.255.1\share\chart.png", 2048)],
},
},
],
};
var json = JsonSerializer.Serialize(template, SettingsManager.JSON_OPTIONS);
Assert.Multiple(() =>
{
Assert.That(json, Does.Contain(FILE_PATH.Replace(@"\", @"\\")), "The attachment has to reach the JSON, or this test checks nothing.");
Assert.That(json, Does.Contain("chart.png"), "The attachment of the example conversation has to reach the JSON, or this test checks nothing.");
Assert.That(json, Does.Not.Contain("\"Exists\""));
});
}
[Test]
public void AnAttachmentStoredWithTheFormerFieldStillLoads()
{
const string LEGACY_JSON = """
{
"$type": "file",
"Type": "DOCUMENT",
"FileName": "report.pdf",
"FilePath": "/tmp/report.pdf",
"FileSizeBytes": 1024,
"Exists": true
}
""";
var read = JsonSerializer.Deserialize<FileAttachment>(LEGACY_JSON, SettingsManager.JSON_OPTIONS)!;
Assert.Multiple(() =>
{
Assert.That(read.FileName, Is.EqualTo("report.pdf"));
Assert.That(read.FilePath, Is.EqualTo("/tmp/report.pdf"));
Assert.That(read.FileSizeBytes, Is.EqualTo(1024));
});
}
}
@@ -0,0 +1,125 @@
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks which objects of a configuration plugin the clean-up removes from the settings.
/// </summary>
/// <remarks>
/// After every start of the plugins, the clean-up compares the objects in the settings with the ones
/// the configuration plugins defined, and removes what no plugin defines anymore. A plugin which was
/// loaded but did not start has defined nothing at all. Its objects must not count as dropped: on a
/// slow machine, where the time ran out before the plugin got its turn, that deleted the providers,
/// chat templates, and profiles of an organization on every start, their API keys included.<br/><br/>
/// The settings are reached through Program.SERVICE_PROVIDER, which is why this fixture does not run
/// alongside others.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class LeftOverConfigurationObjectsTests
{
private static readonly Guid PLUGIN_ID = Guid.Parse("5c1f0e7a-2b9d-4c3e-8f6a-1d2e3f4a5b6c");
private RustService rustService = null!;
private ServiceProvider serviceProvider = null!;
private IServiceProvider previousServiceProvider = null!;
private SettingsManager settingsManager = null!;
[SetUp]
public void CreateSettingsWithATemplateOfThePlugin()
{
// Only builds its HTTP clients. Nothing connects, because a chat template has no secret:
this.rustService = new RustService("1", "unused");
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, this.rustService);
this.settingsManager.ConfigurationData.ChatTemplates.Add(new ChatTemplate
{
Id = Guid.NewGuid().ToString(),
Name = "Template of the organization",
IsEnterpriseConfiguration = true,
EnterpriseConfigurationPluginId = PLUGIN_ID,
});
this.previousServiceProvider = Program.SERVICE_PROVIDER;
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).AddSingleton(this.rustService).BuildServiceProvider();
Program.SERVICE_PROVIDER = this.serviceProvider;
}
[TearDown]
public void RestoreApplicationState()
{
Program.SERVICE_PROVIDER = this.previousServiceProvider;
this.serviceProvider.Dispose();
this.rustService.Dispose();
}
[Test]
public async Task TheObjectsOfAPluginWhichDidNotStartStay()
{
var wasChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, [new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid> { PLUGIN_ID }, []);
Assert.Multiple(() =>
{
Assert.That(wasChanged, Is.False);
Assert.That(this.settingsManager.ConfigurationData.ChatTemplates, Has.Count.EqualTo(1));
});
}
[Test]
public async Task TheObjectsOfAPluginWhichStartedWithoutThemAreRemoved()
{
var wasChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, [new AvailablePlugin()], new HashSet<Guid>(), new HashSet<Guid>(), []);
Assert.Multiple(() =>
{
Assert.That(wasChanged, Is.True, "A plugin which started and no longer defines the template has dropped it, or the test above checks nothing.");
Assert.That(this.settingsManager.ConfigurationData.ChatTemplates, Is.Empty);
});
}
/// <summary>
/// The configuration plugin as the plugin factory lists it after loading it.
/// </summary>
private sealed class AvailablePlugin : IAvailablePlugin
{
public string IconDataUrl => string.Empty;
public PluginType Type => PluginType.CONFIGURATION;
public Guid Id => PLUGIN_ID;
public string Name => "Configuration of the organization";
public string Description => string.Empty;
public PluginVersion Version => new(1, 0, 0);
public string[] Authors => [];
public string SupportContact => string.Empty;
public string SourceURL => string.Empty;
public PluginCategory[] Categories => [];
public PluginTargetGroup[] TargetGroups => [];
public bool IsMaintained => true;
public string DeprecationMessage => string.Empty;
public bool IsInternal => false;
public string LocalPath => string.Empty;
public bool IsManagedByConfigServer => false;
public Guid? ManagedConfigurationId => null;
public int ConfigurationPriority => 0;
}
}
@@ -0,0 +1,38 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that a plugin whose code never finishes can be stopped.
/// </summary>
/// <remarks>
/// Every plugin gets a time limit of its own while it is loaded and started, so that one plugin
/// cannot keep all the others from starting. That limit is only worth something when the Lua
/// runtime gives up on a cancelled token, even in the middle of a loop which never yields. If it
/// did not, a single plugin like that would hang the start of AI Studio for good.
/// </remarks>
[TestFixture]
public sealed class PluginTimeoutTests
{
private const string ENDLESS_PLUGIN = """
ID = "0b7e5c3a-4f1d-4e8b-9a6c-2d3f4e5a6b7c"
while true do end
""";
[Test]
public async Task ALoopWhichNeverEndsIsStopped()
{
//
// The loop runs on its own, and when the runtime ignores the token, it outlives this test.
// It therefore gets the token alone, never the source, which is disposed at the end:
//
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var token = timeout.Token;
var loading = Task.Run(() => PluginFactory.Load(null, ENDLESS_PLUGIN, cancellationToken: token));
var finishedInTime = await Task.WhenAny(loading, Task.Delay(TimeSpan.FromSeconds(10))) == loading;
Assert.That(finishedInTime, Is.True, "The Lua runtime ignored the cancelled token.");
Assert.That(async () => await loading, Throws.InstanceOf<OperationCanceledException>(), "Only a cancellation tells a time limit apart from a broken plugin.");
}
}