From faa90951416a85cf385034849b231e60c9df2eb6 Mon Sep 17 00:00:00 2001 From: Diddyy Date: Fri, 6 Feb 2026 17:44:15 +0000 Subject: [PATCH] Add plugin dev workflow with DevPluginPaths and hot-reload --- README.md | 86 ++++++ Turbo.Main/Console/ConsoleCommandService.cs | 40 ++- Turbo.Plugins/Configuration/PluginConfig.cs | 4 + .../Extensions/ServiceCollectionExtensions.cs | 9 +- Turbo.Plugins/PluginHotReloadService.cs | 238 +++++++++++++++ Turbo.Plugins/PluginManager.cs | 288 +++++++++++++----- 6 files changed, 570 insertions(+), 95 deletions(-) create mode 100644 README.md create mode 100644 Turbo.Plugins/PluginHotReloadService.cs 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); + } + } }