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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user