fix(host): break InputBar/CommandHelpWindow/MainWindow DI cycle

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).
This commit is contained in:
2026-05-28 08:16:27 +02:00
parent f6749d206b
commit 24d3f69041
3 changed files with 56 additions and 8 deletions
@@ -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;
}
+16 -1
View File
@@ -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<Ui.Components.InputBar>(),
sp.GetRequiredService<ILogger<InputPreview>>()
));
// 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<Ui.Components.ChunkRenderer>(),
sp.GetRequiredService<Ui.Windows.MainWindow>(),
sp.GetRequiredService<ILogger<CommandHelpWindow>>()
));
services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService<Plugin>()));
@@ -312,6 +323,10 @@ internal static class PluginHostFactory
sp.GetRequiredService<PayloadHandler>(),
sp.GetRequiredService<Ui.Components.MessageList>()
));
services.AddHostedService(sp => new CommandHelpWindowInitHostedService(
sp.GetRequiredService<Ui.CommandHelpWindow>(),
sp.GetRequiredService<Ui.Windows.MainWindow>()
));
}
private static PayloadHandler MakePayloadHandler(IServiceProvider sp) =>
+18 -7
View File
@@ -13,20 +13,21 @@ namespace HellionChat.Ui;
internal sealed class CommandHelpWindow : Window
{
private readonly ChunkRenderer _chunkRenderer;
private readonly Windows.MainWindow _mainWindow;
private readonly ILogger<CommandHelpWindow> _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<CommandHelpWindow> logger
)
internal CommandHelpWindow(ChunkRenderer chunkRenderer, ILogger<CommandHelpWindow> 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;