From 24d3f69041a286c7c2c53c6788bc26c71fbdafcc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 28 May 2026 08:16:27 +0200 Subject: [PATCH] fix(host): break InputBar/CommandHelpWindow/MainWindow DI cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit J + J2 closed a singleton cycle: InputBar.ctor -> CommandHelpWindow (J2) CommandHelpWindow.ctor -> MainWindow (J) MainWindow.ctor -> InputBar (pre-existing) MS.DI does not detect cycles through FactoryCallSite registrations, so resolution recursed silently on the async plugin-init thread until the worker died with an uncatchable StackOverflowException. Dalamud's LoadAsync task never resolved; the plugin UI hung on "Enabling..." with no exception in the log. First triggered at Plugin.cs:289 (TypingIpc.ctor needs InputBar). Fix: break the cycle on the laziest edge. - CommandHelpWindow.ctor no longer takes MainWindow. - New AttachMainWindow setter wired in CommandHelpWindowInitHostedService.StartAsync, mirroring the existing §6.2 MessageList.AttachPayloadHandler pattern. - UpdateContent throws InvalidOperationException if the setter never ran, so a future regression fails loudly instead of a silent NullRef during input draw. Also enable UseDefaultServiceProvider(ValidateOnBuild + ValidateScopes) so future ConstructorCallSite cycles throw at Build time instead of silently hanging. Catches reflection-based registrations; will not catch FactoryCallSite cycles like this one (those still need code review). Verified via 6 enable/disable cycles in-game; plugin loads cleanly, Hosting starts, FilterAllTabs completes, command help popup renders for /em and /say (exercises AttachMainWindow), hover counter ticks (exercises PayloadHandlerInitHostedService AddonLifecycle wiring). --- .../Hosting/InitHostedServices.cs | 22 ++++++++++++++++ HellionChat/PluginHostFactory.cs | 17 ++++++++++++- HellionChat/Ui/CommandHelpWindow.cs | 25 +++++++++++++------ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index 43d1a81..49ed022 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -3,7 +3,9 @@ using Dalamud.Plugin; using HellionChat.Integrations; using HellionChat.Ipc; using HellionChat.Themes; +using HellionChat.Ui; using HellionChat.Ui.Components; +using HellionChat.Ui.Windows; using Microsoft.Extensions.Hosting; namespace HellionChat.Infrastructure.Hosting; @@ -142,3 +144,23 @@ internal sealed class PayloadHandlerInitHostedService( }); } } + +// Wires MainWindow into CommandHelpWindow post-container-build. CommandHelpWindow +// cannot take MainWindow as a ctor-param because that would close the cycle +// InputBar -> CommandHelpWindow -> MainWindow -> InputBar (MS.DI does not catch +// it through FactoryCallSite registrations and the resolve recurses silently). +// Both singletons exist by host.StartAsync time, so this is the first safe point +// to wire the setter — same §6.2 pattern as MessageList.AttachPayloadHandler. +internal sealed class CommandHelpWindowInitHostedService( + CommandHelpWindow commandHelpWindow, + MainWindow mainWindow +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + commandHelpWindow.AttachMainWindow(mainWindow); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index ea3cdb7..b0dbb14 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -29,6 +29,15 @@ internal static class PluginHostFactory logging.AddDalamudLogging(dependencies.PluginLog); logging.SetMinimumLevel(LogLevel.Trace); }) + // ValidateOnBuild eagerly instantiates every singleton at Build time + // so missing registrations / ConstructorCallSite cycles throw on + // load instead of producing a silent hang. ValidateScopes is cheap + // (we only use singletons) but guards against future Scoped misuse. + .UseDefaultServiceProvider(o => + { + o.ValidateOnBuild = true; + o.ValidateScopes = true; + }) .ConfigureServices(services => ConfigureServices(services, plugin, dependencies)) .Build(); } @@ -268,9 +277,11 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService>() )); + // No MainWindow ctor-param: breaks the InputBar -> CommandHelpWindow -> + // MainWindow -> InputBar singleton cycle. MainWindow is wired post-build + // via CommandHelpWindowInitHostedService. services.AddSingleton(sp => new CommandHelpWindow( sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService>() )); services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); @@ -312,6 +323,10 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddHostedService(sp => new CommandHelpWindowInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); } private static PayloadHandler MakePayloadHandler(IServiceProvider sp) => diff --git a/HellionChat/Ui/CommandHelpWindow.cs b/HellionChat/Ui/CommandHelpWindow.cs index 53ee9c8..0d4caa4 100644 --- a/HellionChat/Ui/CommandHelpWindow.cs +++ b/HellionChat/Ui/CommandHelpWindow.cs @@ -13,20 +13,21 @@ namespace HellionChat.Ui; internal sealed class CommandHelpWindow : Window { private readonly ChunkRenderer _chunkRenderer; - private readonly Windows.MainWindow _mainWindow; private readonly ILogger _logger; + // Setter-injected post-ctor to break the InputBar -> CommandHelpWindow -> + // MainWindow -> InputBar singleton cycle (MS.DI does not detect cycles + // through FactoryCallSite registrations). Wired in + // CommandHelpWindowInitHostedService.StartAsync, same §6.2 pattern as + // MessageList.AttachPayloadHandler. + private Windows.MainWindow? _mainWindow; + private ReadOnlySeString? _commandDescription; - internal CommandHelpWindow( - ChunkRenderer chunkRenderer, - Windows.MainWindow mainWindow, - ILogger logger - ) + internal CommandHelpWindow(ChunkRenderer chunkRenderer, ILogger logger) : base("command help##chat2-commandhelp") { _chunkRenderer = chunkRenderer; - _mainWindow = mainWindow; _logger = logger; Flags = @@ -44,8 +45,18 @@ internal sealed class CommandHelpWindow : Window _ = _logger; } + internal void AttachMainWindow(Windows.MainWindow mainWindow) => _mainWindow = mainWindow; + public void UpdateContent(ReadOnlySeString commandDesc) { + // Loud-fail if the HostedService didn't run AttachMainWindow before + // the first slash-command call — better than a silent NullRef during + // input draw. + if (_mainWindow is null) + throw new InvalidOperationException( + "CommandHelpWindow.UpdateContent called before AttachMainWindow." + ); + _commandDescription = commandDesc; var width = 350;