diff --git a/README.md b/README.md
new file mode 100644
index 00000000..54b5e892
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# Turbo Cloud
+
+A Habbo Hotel emulator built with C# and [Orleans](https://learn.microsoft.com/en-us/dotnet/orleans/).
+
+## Plugin Development
+
+Turbo Cloud uses a plugin system where game features are implemented as loadable plugins. During development, plugins are hot-reloaded in-process when you rebuild them - no server restart needed.
+
+### Prerequisites
+
+- .NET 9 SDK
+- A plugin project that references `Turbo.Contracts`
+
+### Plugin Project Setup
+
+Your plugin's `.csproj` must copy `manifest.json` to the build output:
+
+```xml
+
+
+ PreserveNewest
+
+
+```
+
+### Emulator Configuration
+
+In `appsettings.Development.json`, point `DevPluginPaths` at your plugin's build output directory:
+
+```json
+{
+ "Turbo": {
+ "Plugin": {
+ "DevPluginPaths": [
+ "C:/path/to/your-plugin/bin/Debug/net9.0"
+ ]
+ }
+ }
+}
+```
+
+You can list multiple plugin paths if you're developing several plugins at once.
+
+### Dev Workflow
+
+Open two terminals:
+
+**Terminal 1** - Run the emulator:
+```
+dotnet run --project Turbo.Main
+```
+
+**Terminal 2** - Watch your plugin for changes:
+```
+cd C:/path/to/your-plugin
+dotnet watch build
+```
+
+Now when you edit a `.cs` file and save, `dotnet watch` rebuilds the plugin automatically. The emulator detects the new DLL and hot-reloads the plugin in-process. Message handlers and services are swapped live - connected clients stay connected.
+
+### How It Works
+
+1. You edit a `.cs` file in your plugin project
+2. `dotnet watch build` detects the change and rebuilds
+3. The new DLL lands in your plugin's `bin/Debug/net9.0/`
+4. The emulator's file watcher detects the DLL change
+5. The plugin is unloaded and reloaded with the new assembly
+6. Message handlers and services are swapped atomically
+
+### Released Plugins
+
+For production/released plugins, place them in the `plugins/` directory inside the emulator's output folder. Each plugin should be in its own subdirectory with a `manifest.json`:
+
+```
+plugins/
+ MyPlugin/
+ manifest.json
+ MyPlugin.dll
+```
+
+During development, `DevPluginPaths` takes precedence - if the same plugin key exists in both `plugins/` and a dev path, the dev version is loaded.
+
+### Limitations
+
+- **Grain types cannot be hot-reloaded.** Orleans requires grain types to be registered at silo startup. If your plugin adds new grain types, a server restart is needed.
+- **Memory may grow over many reloads.** Assembly unloading relies on .NET's `AssemblyLoadContext` which may not fully release memory if type references are retained. Restart the emulator periodically during long dev sessions if memory grows.
diff --git a/Turbo.Main/Console/ConsoleCommandService.cs b/Turbo.Main/Console/ConsoleCommandService.cs
index 92c3f3d3..56c8e196 100644
--- a/Turbo.Main/Console/ConsoleCommandService.cs
+++ b/Turbo.Main/Console/ConsoleCommandService.cs
@@ -52,7 +52,7 @@ private async Task LoopAsync(CancellationToken ct)
}
}
- private Task HandleCommandAsync(string input, CancellationToken ct)
+ private async Task HandleCommandAsync(string input, CancellationToken ct)
{
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var cmd = parts[0].ToLowerInvariant();
@@ -61,7 +61,9 @@ private Task HandleCommandAsync(string input, CancellationToken ct)
switch (cmd)
{
case "help":
- System.Console.WriteLine("Available commands: help, quit, reload-plugins");
+ System.Console.WriteLine(
+ "Available commands: help, quit, reload-plugins, reload-plugin "
+ );
break;
case "quit":
@@ -71,22 +73,42 @@ private Task HandleCommandAsync(string input, CancellationToken ct)
break;
case "reload-plugins":
- var pluginMgr = _services.GetRequiredService();
- //await pluginMgr.LoadAll(true, false, ct);
+ try
+ {
+ var pluginMgr = _services.GetRequiredService();
+ await pluginMgr.LoadAllAsync(true, ct).ConfigureAwait(false);
+ System.Console.WriteLine("Plugins reloaded.");
+ }
+ catch (Exception ex)
+ {
+ System.Console.WriteLine($"Reload failed: {ex.Message}");
+ }
break;
case "reload-plugin":
{
- pluginMgr = _services.GetRequiredService();
- //await pluginMgr.Reload(args[0], ct);
+ if (args.Length == 0)
+ {
+ System.Console.WriteLine("Usage: reload-plugin ");
+ break;
+ }
+
+ try
+ {
+ var pluginMgr = _services.GetRequiredService();
+ await pluginMgr.ReloadAsync(args[0], ct).ConfigureAwait(false);
+ System.Console.WriteLine($"Plugin '{args[0]}' reloaded.");
+ }
+ catch (Exception ex)
+ {
+ System.Console.WriteLine($"Reload failed for '{args[0]}': {ex.Message}");
+ }
break;
}
default:
- System.Console.WriteLine("Unknown command: {Command}", cmd);
+ System.Console.WriteLine($"Unknown command: {cmd}");
break;
}
-
- return Task.CompletedTask;
}
}
diff --git a/Turbo.Plugins/Configuration/PluginConfig.cs b/Turbo.Plugins/Configuration/PluginConfig.cs
index ada72db5..835cf328 100644
--- a/Turbo.Plugins/Configuration/PluginConfig.cs
+++ b/Turbo.Plugins/Configuration/PluginConfig.cs
@@ -9,4 +9,8 @@ public class PluginConfig
public string PluginFolderPath { get; init; } =
Path.Combine(AppContext.BaseDirectory, "plugins");
+
+ public int DebounceMs { get; init; } = 500;
+
+ public string[] DevPluginPaths { get; init; } = [];
}
diff --git a/Turbo.Plugins/Extensions/ServiceCollectionExtensions.cs b/Turbo.Plugins/Extensions/ServiceCollectionExtensions.cs
index a4c02131..7433b2b0 100644
--- a/Turbo.Plugins/Extensions/ServiceCollectionExtensions.cs
+++ b/Turbo.Plugins/Extensions/ServiceCollectionExtensions.cs
@@ -12,13 +12,16 @@ public static IServiceCollection AddTurboPlugins(
HostApplicationBuilder builder
)
{
- services.Configure(
- builder.Configuration.GetSection(PluginConfig.SECTION_NAME)
- );
+ var pluginSection = builder.Configuration.GetSection(PluginConfig.SECTION_NAME);
+
+ services.Configure(pluginSection);
services.AddSingleton();
services.AddHostedService();
+ if (builder.Environment.IsDevelopment())
+ services.AddHostedService();
+
return services;
}
diff --git a/Turbo.Plugins/PluginHotReloadService.cs b/Turbo.Plugins/PluginHotReloadService.cs
new file mode 100644
index 00000000..9ed2d27e
--- /dev/null
+++ b/Turbo.Plugins/PluginHotReloadService.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Enumeration;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Turbo.Plugins.Configuration;
+
+namespace Turbo.Plugins;
+
+public sealed class PluginHotReloadService(
+ PluginManager pluginManager,
+ IOptions config,
+ ILogger logger
+) : IHostedService, IDisposable
+{
+ private static readonly string[] WATCH_GLOBS = ["manifest.json", "*.dll", "*.pdb", "*.deps.json"];
+
+ private readonly PluginManager _pluginManager = pluginManager;
+ private readonly PluginConfig _config = config.Value;
+ private readonly ILogger _logger = logger;
+ private readonly SemaphoreSlim _reloadGate = new(1, 1);
+ private readonly Lock _stateLock = new();
+ private readonly CancellationTokenSource _cts = new();
+ private readonly List _watchers = [];
+
+ private Timer? _debounceTimer;
+ private bool _pendingReload;
+ private string? _lastPath;
+ private int _shutdownStarted;
+
+ public Task StartAsync(CancellationToken ct)
+ {
+ _debounceTimer = new Timer(
+ _ => _ = Task.Run(ProcessReloadAsync, _cts.Token),
+ state: null,
+ Timeout.Infinite,
+ Timeout.Infinite
+ );
+
+ WatchDirectory(_config.PluginFolderPath, includeSubdirectories: true);
+
+ foreach (var devPath in _config.DevPluginPaths)
+ WatchDirectory(Path.GetFullPath(devPath), includeSubdirectories: false);
+
+ _logger.LogInformation("Plugin hot reload is enabled.");
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken ct)
+ {
+ Shutdown();
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ Shutdown();
+ _reloadGate.Dispose();
+ _cts.Dispose();
+ }
+
+ private void WatchDirectory(string path, bool includeSubdirectories)
+ {
+ if (!Directory.Exists(path))
+ return;
+
+ var watcher = new FileSystemWatcher(path)
+ {
+ IncludeSubdirectories = includeSubdirectories,
+ NotifyFilter =
+ NotifyFilters.FileName
+ | NotifyFilters.DirectoryName
+ | NotifyFilters.LastWrite
+ | NotifyFilters.CreationTime
+ | NotifyFilters.Size,
+ EnableRaisingEvents = true,
+ };
+
+ watcher.Changed += OnFsEvent;
+ watcher.Created += OnFsEvent;
+ watcher.Deleted += OnFsEvent;
+ watcher.Renamed += OnFsRenamed;
+ watcher.Error += OnFsError;
+
+ _watchers.Add(watcher);
+
+ _logger.LogDebug("Watching plugin path: {Path}", path);
+ }
+
+ private void OnFsEvent(object sender, FileSystemEventArgs e)
+ {
+ if (Volatile.Read(ref _shutdownStarted) == 1)
+ return;
+
+ if (!ShouldReactToPath(e.FullPath))
+ return;
+
+ QueueDebouncedReload(e.FullPath, e.ChangeType.ToString());
+ }
+
+ private void OnFsRenamed(object sender, RenamedEventArgs e)
+ {
+ if (Volatile.Read(ref _shutdownStarted) == 1)
+ return;
+
+ if (!ShouldReactToPath(e.FullPath) && !ShouldReactToPath(e.OldFullPath))
+ return;
+
+ QueueDebouncedReload(e.FullPath, "Renamed");
+ }
+
+ private void OnFsError(object sender, ErrorEventArgs e)
+ {
+ _logger.LogWarning(e.GetException(), "Plugin file watcher emitted an error event.");
+ }
+
+ private void QueueDebouncedReload(string fullPath, string changeType)
+ {
+ lock (_stateLock)
+ {
+ _pendingReload = true;
+ _lastPath = fullPath;
+ _debounceTimer?.Change(
+ Math.Max(100, _config.DebounceMs),
+ Timeout.Infinite
+ );
+ }
+
+ _logger.LogDebug(
+ "Plugin change detected ({Type}) at {Path}; reload scheduled.",
+ changeType,
+ fullPath
+ );
+ }
+
+ private async Task ProcessReloadAsync()
+ {
+ if (_cts.IsCancellationRequested)
+ return;
+
+ string? changedPath;
+
+ lock (_stateLock)
+ {
+ if (!_pendingReload)
+ return;
+
+ _pendingReload = false;
+ changedPath = _lastPath;
+ }
+
+ try
+ {
+ await _reloadGate.WaitAsync(_cts.Token).ConfigureAwait(false);
+
+ _logger.LogInformation("Plugin reload started (trigger: {Path})", changedPath ?? "");
+
+ const int maxAttempts = 3;
+
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ await _pluginManager.LoadAllAsync(true, _cts.Token).ConfigureAwait(false);
+ _logger.LogInformation("Plugin reload completed.");
+ return;
+ }
+ catch (Exception ex)
+ when (
+ attempt < maxAttempts
+ && ex is IOException or InvalidDataException
+ )
+ {
+ _logger.LogWarning(
+ ex,
+ "Plugin reload attempt {Attempt}/{MaxAttempts} failed due to file churn; retrying.",
+ attempt,
+ maxAttempts
+ );
+
+ await Task.Delay(250, _cts.Token).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // host is stopping
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Plugin reload failed.");
+ }
+ finally
+ {
+ _reloadGate.Release();
+ }
+ }
+
+ private static bool ShouldReactToPath(string path)
+ {
+ var fileName = Path.GetFileName(path);
+
+ return !string.IsNullOrWhiteSpace(fileName) && WATCH_GLOBS.Any(glob => FileSystemName.MatchesSimpleExpression(glob, fileName, ignoreCase: true));
+ }
+
+ private void DisposeWatchers()
+ {
+ foreach (var w in _watchers)
+ {
+ w.EnableRaisingEvents = false;
+ w.Changed -= OnFsEvent;
+ w.Created -= OnFsEvent;
+ w.Deleted -= OnFsEvent;
+ w.Renamed -= OnFsRenamed;
+ w.Error -= OnFsError;
+ w.Dispose();
+ }
+
+ _watchers.Clear();
+ }
+
+ private void Shutdown()
+ {
+ if (Interlocked.Exchange(ref _shutdownStarted, 1) == 1)
+ return;
+
+ DisposeWatchers();
+ _debounceTimer?.Dispose();
+ _debounceTimer = null;
+ _cts.Cancel();
+ }
+}
diff --git a/Turbo.Plugins/PluginManager.cs b/Turbo.Plugins/PluginManager.cs
index dc482482..67c81b08 100644
--- a/Turbo.Plugins/PluginManager.cs
+++ b/Turbo.Plugins/PluginManager.cs
@@ -26,8 +26,6 @@ public sealed class PluginManager(
ILogger logger
)
{
- private readonly IServiceProvider _host = host;
- private readonly AssemblyProcessor _processor = processor;
private readonly ExportRegistry _exports = new();
private readonly PluginConfig _config = config.Value;
private readonly ILogger _logger = logger;
@@ -41,6 +39,7 @@ ILogger logger
private readonly ConcurrentDictionary _keyLocks = new(
StringComparer.OrdinalIgnoreCase
);
+ private readonly SemaphoreSlim _reloadGate = new(1, 1);
private static readonly ServiceProviderOptions SP_OPTIONS = new()
{
@@ -51,22 +50,46 @@ ILogger logger
private List<(PluginManifest manifest, string folder)> DiscoverPlugins()
{
var list = new List<(PluginManifest, string)>(capacity: 16);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
- if (!Directory.Exists(_config.PluginFolderPath))
- return list;
-
- foreach (var dir in Directory.EnumerateDirectories(_config.PluginFolderPath))
+ foreach (var devPath in _config.DevPluginPaths)
{
+ var dir = Path.GetFullPath(devPath);
+
+ if (!Directory.Exists(dir))
+ continue;
+
try
{
var manifest = PluginHelpers.ReadManifest(dir);
- if (manifest is not null)
+ if (seen.Add(manifest.Key))
+ {
list.Add((manifest, dir));
+ _logger.LogDebug("Discovered dev plugin {Key} from {Dir}", manifest.Key, dir);
+ }
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to read plugin manifest in {Dir}", dir);
+ _logger.LogError(ex, "Failed to read dev plugin manifest in {Dir}", dir);
+ }
+ }
+
+ if (!Directory.Exists(_config.PluginFolderPath)) return list;
+ {
+ foreach (var dir in Directory.EnumerateDirectories(_config.PluginFolderPath))
+ {
+ try
+ {
+ var manifest = PluginHelpers.ReadManifest(dir);
+
+ if (seen.Add(manifest.Key))
+ list.Add((manifest, dir));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to read plugin manifest in {Dir}", dir);
+ }
}
}
@@ -75,104 +98,184 @@ ILogger logger
public async Task LoadAllAsync(bool unloadRemoved = true, CancellationToken ct = default)
{
- var discovered = DiscoverPlugins();
- var manifests = PluginHelpers.SortManifests([.. discovered.Select(d => d.manifest)]);
- var byKey = discovered.ToDictionary(
- d => d.manifest.Key,
- d => d.folder,
- StringComparer.OrdinalIgnoreCase
- );
- var envs = new List();
- var tasks = new List>();
+ await _reloadGate.WaitAsync(ct).ConfigureAwait(false);
- _dependents.Clear();
+ try
+ {
+ var discovered = DiscoverPlugins();
+ var manifests = PluginHelpers.SortManifests([.. discovered.Select(d => d.manifest)]);
+ var byKey = discovered.ToDictionary(
+ d => d.manifest.Key,
+ d => d.folder,
+ StringComparer.OrdinalIgnoreCase
+ );
+ var envs = new List();
+ var tasks = new List>();
+
+ RebuildDependents(manifests);
+
+ foreach (var m in manifests)
+ {
+ var gate = GetKeyGate(m.Key);
+
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ var folder = byKey[m.Key];
+ LoadedAssembly asm;
+
+ try
+ {
+ asm = GetLoadedPluginAssembly(m, folder);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Failed to load assembly for {Name}@{Version} by {Author}",
+ m.Name,
+ m.Version,
+ m.Author
+ );
+
+ gate.Release();
+
+ continue;
+ }
+
+ try
+ {
+ var current = _live.GetValueOrDefault(m.Key);
+
+ if (current is not null)
+ {
+ if (
+ _dependents.TryGetValue(m.Key, out var deps)
+ && deps.Any(_live.ContainsKey)
+ )
+ throw new InvalidOperationException(
+ $"Cannot reload {m.Key} while dependents are active: {string.Join(",", deps.Where(_live.ContainsKey))}"
+ );
+
+ await StopAndTearDownAsync(current, ct).ConfigureAwait(false);
+ }
+
+ var next = await BuildEnvelopeAsync(asm, m, folder, ct).ConfigureAwait(false);
+
+ _live[m.Key] = next;
+ envs.Add(next);
+
+ tasks.Add(async () =>
+ {
+ var disp = await processor
+ .ProcessAsync(asm.Assembly, next.ServiceProvider, ct)
+ .ConfigureAwait(false);
+
+ next.Disposables.Add(disp);
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Failed to load {Name}@{Version} by {Author}",
+ m.Name,
+ m.Version,
+ m.Author
+ );
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ var degree = Math.Max(2, Environment.ProcessorCount * 4);
- foreach (var m in manifests)
+ await BoundedHelper.RunAsync(tasks, degree, ct).ConfigureAwait(false);
+
+ if (unloadRemoved)
+ await UnloadRemovedAsync(envs.Select(d => d.Key), ct).ConfigureAwait(false);
+
+ _logger.LogInformation("Loaded {Count} plugins", _live.Count);
+ }
+ finally
{
- var gate = GetKeyGate(m.Key);
+ _reloadGate.Release();
+ }
+ }
- await gate.WaitAsync(ct).ConfigureAwait(false);
+ public async Task ReloadAsync(string key, CancellationToken ct = default)
+ {
+ await _reloadGate.WaitAsync(ct).ConfigureAwait(false);
- foreach (var dep in m.Dependencies)
- _dependents.GetOrAdd(dep.Key, _ => []).Add(m.Key);
+ try
+ {
+ var discovered = DiscoverPlugins();
+ var manifests = PluginHelpers.SortManifests([.. discovered.Select(d => d.manifest)]);
+ var byKey = discovered.ToDictionary(
+ d => d.manifest.Key,
+ d => d.folder,
+ StringComparer.OrdinalIgnoreCase
+ );
- var folder = byKey[m.Key];
- LoadedAssembly asm;
+ RebuildDependents(manifests);
- try
+ if (!byKey.TryGetValue(key, out var folder))
{
- asm = GetLoadedPluginAssembly(m, folder);
+ await UnloadAsync(key, ct).ConfigureAwait(false);
+ _logger.LogInformation("Plugin {Key} was removed from disk and unloaded.", key);
+ return;
}
- catch (Exception ex)
- {
- _logger.LogError(
- ex,
- "Failed to load assembly for {Name}@{Version} by {Author}",
- m.Name,
- m.Version,
- m.Author
- );
- gate.Release();
+ var manifest = manifests.First(m =>
+ string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase)
+ );
- continue;
- }
+ var gate = GetKeyGate(key);
+ await gate.WaitAsync(ct).ConfigureAwait(false);
try
{
- var current = _live.GetValueOrDefault(m.Key);
-
- if (current is not null)
+ foreach (var dep in manifest.Dependencies.Where(dep => !_live.ContainsKey(dep.Key)))
{
- if (_dependents.TryGetValue(m.Key, out var deps) && deps.Any(_live.ContainsKey))
- throw new InvalidOperationException(
- $"Cannot reload {m.Key} while dependents are active: {string.Join(",", deps.Where(_live.ContainsKey))}"
- );
-
- await StopAndTearDownAsync(current, ct).ConfigureAwait(false);
+ throw new InvalidOperationException(
+ $"Cannot reload {key}; dependency {dep.Key} is not active."
+ );
}
- var next = await BuildEnvelopeAsync(asm, m, folder, ct).ConfigureAwait(false);
+ if (_dependents.TryGetValue(key, out var deps) && deps.Any(_live.ContainsKey))
+ throw new InvalidOperationException(
+ $"Cannot reload {key} while dependents are active: {string.Join(",", deps.Where(_live.ContainsKey))}"
+ );
- _live[m.Key] = next;
- envs.Add(next);
+ var asm = GetLoadedPluginAssembly(manifest, folder);
+ var current = _live.GetValueOrDefault(key);
- tasks.Add(async () =>
- {
- var disp = await _processor
- .ProcessAsync(asm.Assembly, next.ServiceProvider, ct)
- .ConfigureAwait(false);
+ if (current is not null)
+ await StopAndTearDownAsync(current, ct).ConfigureAwait(false);
- next.Disposables.Add(disp);
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(
- ex,
- "Failed to load {Name}@{Version} by {Author}",
- m.Name,
- m.Version,
- m.Author
- );
+ var next = await BuildEnvelopeAsync(asm, manifest, folder, ct).ConfigureAwait(false);
+ _live[key] = next;
+
+ var disp = await processor
+ .ProcessAsync(asm.Assembly, next.ServiceProvider, ct)
+ .ConfigureAwait(false);
+ next.Disposables.Add(disp);
+
+ _logger.LogInformation("Reloaded plugin {Key}", key);
}
finally
{
gate.Release();
}
- }
-
- var degree = Math.Max(2, Environment.ProcessorCount * 4);
-
- await BoundedHelper.RunAsync(tasks, degree, ct).ConfigureAwait(false);
- if (unloadRemoved)
- await UnloadRemovedAsync(envs.Select(d => d.Key), ct).ConfigureAwait(false);
-
- _logger.LogInformation("Loaded {Count} plugins", _live.Count);
+ }
+ finally
+ {
+ _reloadGate.Release();
+ }
}
- public async Task UnloadAsync(string key, CancellationToken ct = default)
+ private async Task UnloadAsync(string key, CancellationToken ct = default)
{
var gate = GetKeyGate(key);
@@ -312,8 +415,8 @@ PluginManifest manifest
services.AddSingleton(manifest);
services.AddSingleton(new PluginCatalog(_exports));
- services.AddSingleton(new HostServices(_host));
- services.ConfigurePrefixedLogging(_host, manifest.Name);
+ services.AddSingleton(new HostServices(host));
+ services.ConfigurePrefixedLogging(host, manifest.Name);
plugin.ConfigureServices(services, manifest);
@@ -369,7 +472,7 @@ private async Task StopAndTearDownAsync(PluginEnvelope env, CancellationToken ct
{
try
{
- if (env.ServiceProvider is IServiceProvider sp)
+ if (env.ServiceProvider is { } sp)
{
foreach (var svc in sp.GetServices())
{
@@ -433,11 +536,30 @@ private async Task StopAndTearDownAsync(PluginEnvelope env, CancellationToken ct
}
if (env.Alc is not null)
- await AssemblyMemoryLoader
- .UnloadAndWaitAsync(env.Alc, default, ct)
+ {
+ var unloaded = await AssemblyMemoryLoader
+ .UnloadAndWaitAsync(env.Alc, 5000, ct)
.ConfigureAwait(false);
+
+ if (!unloaded)
+ _logger.LogWarning(
+ "ALC for plugin {Key} did not unload within timeout. Possible memory leak from retained type references.",
+ env.Manifest.Key
+ );
+ }
}
private SemaphoreSlim GetKeyGate(string key) =>
_keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+
+ private void RebuildDependents(IEnumerable manifests)
+ {
+ _dependents.Clear();
+
+ foreach (var manifest in manifests)
+ {
+ foreach (var dep in manifest.Dependencies)
+ _dependents.GetOrAdd(dep.Key, _ => []).Add(manifest.Key);
+ }
+ }
}