Add "loading dialog" for service init, unify blocking logic (#1779)

* wip

* hacky fix for overlapping event text in profiler

* move IsResumeGameAfterPluginLoad logic to PluginManager

* fix some warnings

* handle exceptions properly

* remove ability to cancel, rename button to "hide" instead

* undo Dalamud.Service refactor for now

* warnings

* add explainer, show which plugins are still loading

* add some text if loading takes more than 3 minutes

* undo wrong CS merge
This commit is contained in:
goat 2024-04-21 17:28:37 +02:00 committed by GitHub
parent 93adea0ac9
commit 448b0d16ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
294 changed files with 560 additions and 506 deletions

View file

@ -0,0 +1,25 @@
namespace Dalamud;
/// <summary>
/// Marker class for service types.
/// </summary>
public interface IServiceType
{
}
/// <summary><see cref="IDisposable"/>, but for <see cref="IServiceType"/>.</summary>
/// <remarks>Use this to prevent services from accidentally being disposed by plugins or <c>using</c> clauses.</remarks>
internal interface IInternalDisposableService : IServiceType
{
/// <summary>Disposes the service.</summary>
void DisposeService();
}
/// <summary>An <see cref="IInternalDisposableService"/> which happens to be public and needs to expose
/// <see cref="IDisposable.Dispose"/>.</summary>
internal interface IPublicDisposableService : IInternalDisposableService, IDisposable
{
/// <summary>Marks that only <see cref="IInternalDisposableService.DisposeService"/> should respond,
/// while suppressing <see cref="IDisposable.Dispose"/>.</summary>
void MarkDisposeOnlyFromService();
}

View file

@ -0,0 +1,252 @@
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Dalamud.Plugin.Internal;
using Dalamud.Utility;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Dalamud;
/// <summary>
/// Class providing an early-loading dialog.
/// </summary>
internal class LoadingDialog
{
// TODO: We can't localize any of what's in here at the moment, because Localization is an EarlyLoadedService.
private static int wasGloballyHidden = 0;
private Thread? thread;
private TaskDialogButton? inProgressHideButton;
private TaskDialogPage? page;
private bool canHide;
private State currentState = State.LoadingDalamud;
private DateTime firstShowTime;
/// <summary>
/// Enum representing the state of the dialog.
/// </summary>
public enum State
{
/// <summary>
/// Show a message stating that Dalamud is currently loading.
/// </summary>
LoadingDalamud,
/// <summary>
/// Show a message stating that Dalamud is currently loading plugins.
/// </summary>
LoadingPlugins,
/// <summary>
/// Show a message stating that Dalamud is currently updating plugins.
/// </summary>
AutoUpdatePlugins,
}
/// <summary>
/// Gets or sets the current state of the dialog.
/// </summary>
public State CurrentState
{
get => this.currentState;
set
{
this.currentState = value;
this.UpdatePage();
}
}
/// <summary>
/// Gets or sets a value indicating whether or not the dialog can be hidden by the user.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if called before the dialog has been created.</exception>
public bool CanHide
{
get => this.canHide;
set
{
this.canHide = value;
this.UpdatePage();
}
}
/// <summary>
/// Show the dialog.
/// </summary>
public void Show()
{
if (Volatile.Read(ref wasGloballyHidden) == 1)
return;
if (this.thread?.IsAlive == true)
return;
this.thread = new Thread(this.ThreadStart)
{
Name = "Dalamud Loading Dialog",
};
this.thread.SetApartmentState(ApartmentState.STA);
this.thread.Start();
this.firstShowTime = DateTime.Now;
}
/// <summary>
/// Hide the dialog.
/// </summary>
public void HideAndJoin()
{
if (this.thread == null || !this.thread.IsAlive)
return;
this.inProgressHideButton?.PerformClick();
this.thread!.Join();
}
private void UpdatePage()
{
if (this.page == null)
return;
this.page.Heading = this.currentState switch
{
State.LoadingDalamud => "Dalamud is loading...",
State.LoadingPlugins => "Waiting for plugins to load...",
State.AutoUpdatePlugins => "Updating plugins...",
_ => throw new ArgumentOutOfRangeException(),
};
var context = string.Empty;
if (this.currentState == State.LoadingPlugins)
{
context = "\nPreparing...";
var tracker = Service<PluginManager>.GetNullable()?.StartupLoadTracking;
if (tracker != null)
{
var nameString = tracker.GetPendingInternalNames()
.Select(x => tracker.GetPublicName(x))
.Where(x => x != null)
.Aggregate(string.Empty, (acc, x) => acc + x + ", ");
if (!nameString.IsNullOrEmpty())
context = $"\nWaiting for: {nameString[..^2]}";
}
}
// Add some text if loading takes more than a few minutes
if (DateTime.Now - this.firstShowTime > TimeSpan.FromMinutes(3))
context += "\nIt's been a while now. Please report this issue on our Discord server.";
this.page.Text = this.currentState switch
{
State.LoadingDalamud => "Please wait while Dalamud loads...",
State.LoadingPlugins => "Please wait while Dalamud loads plugins...",
State.AutoUpdatePlugins => "Please wait while Dalamud updates your plugins...",
_ => throw new ArgumentOutOfRangeException(),
#pragma warning disable SA1513
} + context;
#pragma warning restore SA1513
this.inProgressHideButton!.Enabled = this.canHide;
}
private async Task DialogStatePeriodicUpdate(CancellationToken token)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50));
while (!token.IsCancellationRequested)
{
await timer.WaitForNextTickAsync(token);
this.UpdatePage();
}
}
private void ThreadStart()
{
Application.EnableVisualStyles();
this.inProgressHideButton = new TaskDialogButton("Hide", this.canHide);
// We don't have access to the asset service here.
var workingDirectory = Service<Dalamud>.Get().StartInfo.WorkingDirectory;
TaskDialogIcon? dialogIcon = null;
if (!workingDirectory.IsNullOrEmpty())
{
var extractedIcon = Icon.ExtractAssociatedIcon(Path.Combine(workingDirectory, "Dalamud.Injector.exe"));
if (extractedIcon != null)
{
dialogIcon = new TaskDialogIcon(extractedIcon);
}
}
dialogIcon ??= TaskDialogIcon.Information;
this.page = new TaskDialogPage
{
ProgressBar = new TaskDialogProgressBar(TaskDialogProgressBarState.Marquee),
Caption = "Dalamud",
Icon = dialogIcon,
Buttons = { this.inProgressHideButton },
AllowMinimize = false,
AllowCancel = false,
Expander = new TaskDialogExpander
{
CollapsedButtonText = "What does this mean?",
ExpandedButtonText = "What does this mean?",
Text = "Some of the plugins you have installed through Dalamud are taking a long time to load.\n" +
"This is likely normal, please wait a little while longer.",
},
SizeToContent = true,
};
this.UpdatePage();
// Call private TaskDialog ctor
var ctor = typeof(TaskDialog).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Array.Empty<Type>(),
null);
var taskDialog = (TaskDialog)ctor!.Invoke(Array.Empty<object>())!;
this.page.Created += (_, _) =>
{
var hwnd = new HWND(taskDialog.Handle);
// Bring to front
Windows.Win32.PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0,
SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOMOVE);
Windows.Win32.PInvoke.SetWindowPos(hwnd, HWND.HWND_NOTOPMOST, 0, 0, 0, 0,
SET_WINDOW_POS_FLAGS.SWP_SHOWWINDOW | SET_WINDOW_POS_FLAGS.SWP_NOSIZE |
SET_WINDOW_POS_FLAGS.SWP_NOMOVE);
Windows.Win32.PInvoke.SetForegroundWindow(hwnd);
Windows.Win32.PInvoke.SetFocus(hwnd);
Windows.Win32.PInvoke.SetActiveWindow(hwnd);
};
// Call private "ShowDialogInternal"
var showDialogInternal = typeof(TaskDialog).GetMethod(
"ShowDialogInternal",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
[typeof(IntPtr), typeof(TaskDialogPage), typeof(TaskDialogStartupLocation)],
null);
var cts = new CancellationTokenSource();
_ = this.DialogStatePeriodicUpdate(cts.Token);
showDialogInternal!.Invoke(
taskDialog,
[IntPtr.Zero, this.page, TaskDialogStartupLocation.CenterScreen]);
Interlocked.Exchange(ref wasGloballyHidden, 1);
cts.Cancel();
}
}

View file

@ -0,0 +1,724 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Configuration.Internal;
using Dalamud.Game;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Storage;
using Dalamud.Utility;
using Dalamud.Utility.Timing;
using JetBrains.Annotations;
// API10 TODO: Move to Dalamud.Service namespace. Some plugins reflect this... including my own, oops. There's a todo
// for more reflective APIs, so I'll just leave it for now.
namespace Dalamud;
// TODO:
// - Unify dependency walking code(load/unload)
// - Visualize/output .dot or imgui thing
/// <summary>
/// Class to initialize <see cref="Service{T}"/>.
/// </summary>
internal static class ServiceManager
{
/// <summary>
/// Static log facility for Service{T}, to avoid duplicate instances for different types.
/// </summary>
public static readonly ModuleLog Log = new("SVC");
#if DEBUG
/// <summary>
/// Marks which service constructor the current thread's in. For use from <see cref="Service{T}"/> only.
/// </summary>
internal static readonly ThreadLocal<Type?> CurrentConstructorServiceType = new();
[SuppressMessage("ReSharper", "CollectionNeverQueried.Local", Justification = "Debugging purposes")]
private static readonly List<Type> LoadedServices = new();
#endif
private static readonly TaskCompletionSource BlockingServicesLoadedTaskCompletionSource = new();
private static readonly CancellationTokenSource UnloadCancellationTokenSource = new();
private static ManualResetEvent unloadResetEvent = new(false);
private static LoadingDialog loadingDialog = new();
/// <summary>
/// Delegate for registering startup blocker task.<br />
/// Do not use this delegate outside the constructor.
/// </summary>
/// <param name="t">The blocker task.</param>
/// <param name="justification">The justification for using this feature.</param>
[InjectableType]
public delegate void RegisterStartupBlockerDelegate(Task t, string justification);
/// <summary>
/// Delegate for registering services that should be unloaded before self.<br />
/// Intended for use with <see cref="Plugin.Internal.PluginManager"/>. If you think you need to use this outside
/// of that, consider having a discussion first.<br />
/// Do not use this delegate outside the constructor.
/// </summary>
/// <param name="unloadAfter">Services that should be unloaded first.</param>
/// <param name="justification">The justification for using this feature.</param>
[InjectableType]
public delegate void RegisterUnloadAfterDelegate(IEnumerable<Type> unloadAfter, string justification);
/// <summary>
/// Kinds of services.
/// </summary>
[Flags]
public enum ServiceKind
{
/// <summary>
/// Not a service.
/// </summary>
None = 0,
/// <summary>
/// Service that is loaded manually.
/// </summary>
ProvidedService = 1 << 0,
/// <summary>
/// Service that is loaded asynchronously while the game starts.
/// </summary>
EarlyLoadedService = 1 << 1,
/// <summary>
/// Service that is loaded before the game starts.
/// </summary>
BlockingEarlyLoadedService = 1 << 2,
/// <summary>
/// Service that is only instantiable via scopes.
/// </summary>
ScopedService = 1 << 3,
/// <summary>
/// Service that is loaded automatically when the game starts, synchronously or asynchronously.
/// </summary>
AutoLoadService = EarlyLoadedService | BlockingEarlyLoadedService,
}
/// <summary>
/// Gets task that gets completed when all blocking early loading services are done loading.
/// </summary>
public static Task BlockingResolved { get; } = BlockingServicesLoadedTaskCompletionSource.Task;
/// <summary>
/// Gets a cancellation token that will be cancelled once Dalamud needs to unload, be it due to a failure state
/// during initialization or during regular operation.
/// </summary>
public static CancellationToken UnloadCancellationToken => UnloadCancellationTokenSource.Token;
/// <summary>
/// Initializes Provided Services and FFXIVClientStructs.
/// </summary>
/// <param name="dalamud">Instance of <see cref="Dalamud"/>.</param>
/// <param name="fs">Instance of <see cref="ReliableFileStorage"/>.</param>
/// <param name="configuration">Instance of <see cref="DalamudConfiguration"/>.</param>
/// <param name="scanner">Instance of <see cref="TargetSigScanner"/>.</param>
public static void InitializeProvidedServices(Dalamud dalamud, ReliableFileStorage fs, DalamudConfiguration configuration, TargetSigScanner scanner)
{
#if DEBUG
lock (LoadedServices)
{
ProvideService(dalamud);
ProvideService(fs);
ProvideService(configuration);
ProvideService(new ServiceContainer());
ProvideService(scanner);
}
return;
void ProvideService<T>(T service) where T : IServiceType
{
Debug.Assert(typeof(T).GetServiceKind().HasFlag(ServiceKind.ProvidedService), "Provided service must have Service attribute");
Service<T>.Provide(service);
LoadedServices.Add(typeof(T));
}
#else
ProvideService(dalamud);
ProvideService(fs);
ProvideService(configuration);
ProvideService(new ServiceContainer());
ProvideService(scanner);
return;
void ProvideService<T>(T service) where T : IServiceType => Service<T>.Provide(service);
#endif
}
/// <summary>
/// Gets the concrete types of services, i.e. the non-abstract non-interface types.
/// </summary>
/// <returns>The enumerable of service types, that may be enumerated only once per call.</returns>
public static IEnumerable<Type> GetConcreteServiceTypes() =>
Assembly.GetExecutingAssembly()
.GetTypes()
.Where(x => x.IsAssignableTo(typeof(IServiceType)) && !x.IsInterface && !x.IsAbstract);
/// <summary>
/// Kicks off construction of services that can handle early loading.
/// </summary>
/// <returns>Task for initializing all services.</returns>
public static async Task InitializeEarlyLoadableServices()
{
using var serviceInitializeTimings = Timings.Start("Services Init");
var earlyLoadingServices = new HashSet<Type>();
var blockingEarlyLoadingServices = new HashSet<Type>();
var providedServices = new HashSet<Type>();
var dependencyServicesMap = new Dictionary<Type, List<Type>>();
var getAsyncTaskMap = new Dictionary<Type, Task>();
var serviceContainer = Service<ServiceContainer>.Get();
foreach (var serviceType in GetConcreteServiceTypes())
{
var serviceKind = serviceType.GetServiceKind();
CheckServiceTypeContracts(serviceType);
// Let IoC know about the interfaces this service implements
serviceContainer.RegisterInterfaces(serviceType);
// Scoped service do not go through Service<T> and are never early loaded
if (serviceKind.HasFlag(ServiceKind.ScopedService))
continue;
var genericWrappedServiceType = typeof(Service<>).MakeGenericType(serviceType);
var getTask = (Task)genericWrappedServiceType
.InvokeMember(
nameof(Service<IServiceType>.GetAsync),
BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public,
null,
null,
null);
getAsyncTaskMap[serviceType] = getTask;
// We don't actually need to load provided services, something else does
if (serviceKind.HasFlag(ServiceKind.ProvidedService))
{
providedServices.Add(serviceType);
continue;
}
Debug.Assert(
serviceKind.HasFlag(ServiceKind.EarlyLoadedService) ||
serviceKind.HasFlag(ServiceKind.BlockingEarlyLoadedService),
"At this point, service must be either early loaded or blocking early loaded");
if (serviceKind.HasFlag(ServiceKind.BlockingEarlyLoadedService))
{
blockingEarlyLoadingServices.Add(serviceType);
}
else
{
earlyLoadingServices.Add(serviceType);
}
var typeAsServiceT = ServiceHelpers.GetAsService(serviceType);
dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, false)
.Select(x => typeof(Service<>).MakeGenericType(x))
.ToList();
}
var blockerTasks = new List<Task>();
_ = Task.Run(async () =>
{
try
{
// Wait for all blocking constructors to complete first.
await WaitWithTimeoutConsent(blockingEarlyLoadingServices.Select(x => getAsyncTaskMap[x]),
LoadingDialog.State.LoadingDalamud);
// All the BlockingEarlyLoadedService constructors have been run,
// and blockerTasks now will not change. Now wait for them.
// Note that ServiceManager.CallWhenServicesReady does not get to register a blocker.
await WaitWithTimeoutConsent(blockerTasks,
LoadingDialog.State.LoadingPlugins);
Log.Verbose("=============== BLOCKINGSERVICES & TASKS INITIALIZED ===============");
Timings.Event("BlockingServices Initialized");
BlockingServicesLoadedTaskCompletionSource.SetResult();
loadingDialog.HideAndJoin();
}
catch (Exception e)
{
try
{
BlockingServicesLoadedTaskCompletionSource.SetException(e);
}
catch (InvalidOperationException)
{
// ignored, may have been set by the try/catch below
}
Log.Error(e, "Failed resolving blocking services");
}
return;
async Task WaitWithTimeoutConsent(IEnumerable<Task> tasksEnumerable, LoadingDialog.State state)
{
var tasks = tasksEnumerable.AsReadOnlyCollection();
if (tasks.Count == 0)
return;
// Time we wait until showing the loading dialog
const int loadingDialogTimeout = 5000;
var aggregatedTask = Task.WhenAll(tasks);
while (await Task.WhenAny(aggregatedTask, Task.Delay(loadingDialogTimeout)) != aggregatedTask)
{
loadingDialog.Show();
loadingDialog.CanHide = true;
loadingDialog.CurrentState = state;
}
}
}).ConfigureAwait(false);
var tasks = new List<Task>();
try
{
var servicesToLoad = new HashSet<Type>();
servicesToLoad.UnionWith(earlyLoadingServices);
servicesToLoad.UnionWith(blockingEarlyLoadingServices);
while (servicesToLoad.Any())
{
foreach (var serviceType in servicesToLoad)
{
var hasDeps = true;
foreach (var dependency in dependencyServicesMap[serviceType])
{
var depUnderlyingServiceType = dependency.GetGenericArguments().First();
var depResolveTask = getAsyncTaskMap.GetValueOrDefault(depUnderlyingServiceType);
if (depResolveTask == null)
{
Log.Error("{Type}: {Dependency} has no resolver task", serviceType.FullName!, dependency.FullName!);
Debug.Assert(false, $"No resolver for dependent service {depUnderlyingServiceType.FullName}");
}
else if (depResolveTask is { IsCompleted: false })
{
hasDeps = false;
}
}
if (!hasDeps)
continue;
// This object will be used in a task. Each task must receive a new object.
var startLoaderArgs = new List<object>();
if (serviceType.GetCustomAttribute<BlockingEarlyLoadedServiceAttribute>() is not null)
{
startLoaderArgs.Add(
new RegisterStartupBlockerDelegate(
(task, justification) =>
{
#if DEBUG
if (CurrentConstructorServiceType.Value != serviceType)
throw new InvalidOperationException("Forbidden.");
#endif
blockerTasks.Add(task);
// No need to store the justification; the fact that the reason is specified is good enough.
_ = justification;
}));
}
tasks.Add((Task)typeof(Service<>)
.MakeGenericType(serviceType)
.InvokeMember(
nameof(Service<IServiceType>.StartLoader),
BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.NonPublic,
null,
null,
new object[] { startLoaderArgs }));
servicesToLoad.Remove(serviceType);
#if DEBUG
tasks.Add(tasks.Last().ContinueWith(task =>
{
if (task.IsFaulted)
return;
lock (LoadedServices)
{
LoadedServices.Add(serviceType);
}
}));
#endif
}
if (!tasks.Any())
{
// No more services we can start loading for now.
// Either we're waiting for provided services, or there's a dependency cycle.
providedServices.RemoveWhere(x => getAsyncTaskMap[x].IsCompleted);
if (providedServices.Any())
await Task.WhenAny(providedServices.Select(x => getAsyncTaskMap[x]));
else
throw new InvalidOperationException("Unresolvable dependency cycle detected");
continue;
}
if (servicesToLoad.Any())
{
await Task.WhenAny(tasks);
var faultedTasks = tasks.Where(x => x.IsFaulted).Select(x => (Exception)x.Exception!).ToArray();
if (faultedTasks.Any())
throw new AggregateException(faultedTasks);
}
else
{
await Task.WhenAll(tasks);
}
tasks.RemoveAll(x => x.IsCompleted);
}
}
catch (Exception e)
{
UnloadCancellationTokenSource.Cancel();
Log.Error(e, "Failed resolving services");
try
{
BlockingServicesLoadedTaskCompletionSource.SetException(e);
loadingDialog.HideAndJoin();
}
catch (Exception)
{
// don't care, as this means task result/exception has already been set
}
while (tasks.Any())
{
await Task.WhenAny(tasks);
tasks.RemoveAll(x => x.IsCompleted);
}
UnloadAllServices();
throw;
}
}
/// <summary>
/// Unloads all services, in the reverse order of load.
/// </summary>
public static void UnloadAllServices()
{
UnloadCancellationTokenSource.Cancel();
var framework = Service<Framework>.GetNullable(Service<Framework>.ExceptionPropagationMode.None);
if (framework is { IsInFrameworkUpdateThread: false, IsFrameworkUnloading: false })
{
framework.RunOnFrameworkThread(UnloadAllServices).Wait();
return;
}
unloadResetEvent.Reset();
var dependencyServicesMap = new Dictionary<Type, IReadOnlyCollection<Type>>();
var allToUnload = new HashSet<Type>();
var unloadOrder = new List<Type>();
Log.Information("==== COLLECTING SERVICES TO UNLOAD ====");
foreach (var serviceType in GetConcreteServiceTypes())
{
if (!serviceType.IsAssignableTo(typeof(IServiceType)))
continue;
// Scoped services shall never be unloaded here.
// Their lifetime must be managed by the IServiceScope that owns them. If it leaks, it's their fault.
if (serviceType.GetServiceKind() == ServiceKind.ScopedService)
continue;
Log.Verbose("Calling GetDependencyServices for '{ServiceName}'", serviceType.FullName!);
var typeAsServiceT = ServiceHelpers.GetAsService(serviceType);
dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, true);
allToUnload.Add(serviceType);
}
void UnloadService(Type serviceType)
{
if (unloadOrder.Contains(serviceType))
return;
var deps = dependencyServicesMap[serviceType];
foreach (var dep in deps)
{
UnloadService(dep);
}
unloadOrder.Add(serviceType);
Log.Information("Queue for unload {Type}", serviceType.FullName!);
}
foreach (var serviceType in allToUnload)
{
UnloadService(serviceType);
}
Log.Information("==== UNLOADING ALL SERVICES ====");
unloadOrder.Reverse();
foreach (var type in unloadOrder)
{
Log.Verbose("Unload {Type}", type.FullName!);
typeof(Service<>)
.MakeGenericType(type)
.InvokeMember(
"Unset",
BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.NonPublic,
null,
null,
null);
}
#if DEBUG
lock (LoadedServices)
{
LoadedServices.Clear();
}
#endif
unloadResetEvent.Set();
}
/// <summary>
/// Wait until all services have been unloaded.
/// </summary>
public static void WaitForServiceUnload()
{
unloadResetEvent.WaitOne();
}
/// <summary>
/// Get the service type of this type.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns>The type of service this type is.</returns>
public static ServiceKind GetServiceKind(this Type type)
{
var attr = type.GetCustomAttribute<ServiceAttribute>(true)?.GetType();
if (attr == null)
return ServiceKind.None;
Debug.Assert(
type.IsAssignableTo(typeof(IServiceType)),
"Service did not inherit from IServiceType");
if (attr.IsAssignableTo(typeof(BlockingEarlyLoadedServiceAttribute)))
return ServiceKind.BlockingEarlyLoadedService;
if (attr.IsAssignableTo(typeof(EarlyLoadedServiceAttribute)))
return ServiceKind.EarlyLoadedService;
if (attr.IsAssignableTo(typeof(ScopedServiceAttribute)))
return ServiceKind.ScopedService;
return ServiceKind.ProvidedService;
}
/// <summary>Validate service type contracts, and throws exceptions accordingly.</summary>
/// <param name="serviceType">An instance of <see cref="Type"/> that is supposed to be a service type.</param>
/// <remarks>Does nothing on non-debug builds.</remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void CheckServiceTypeContracts(Type serviceType)
{
#if DEBUG
try
{
if (!serviceType.IsAssignableTo(typeof(IServiceType)))
throw new InvalidOperationException($"Non-{nameof(IServiceType)} passed.");
if (serviceType.GetServiceKind() == ServiceKind.None)
throw new InvalidOperationException("Service type is not specified.");
var isServiceDisposable =
serviceType.IsAssignableTo(typeof(IInternalDisposableService));
var isAnyDisposable =
isServiceDisposable
|| serviceType.IsAssignableTo(typeof(IDisposable))
|| serviceType.IsAssignableTo(typeof(IAsyncDisposable));
if (isAnyDisposable && !isServiceDisposable)
{
throw new InvalidOperationException(
$"A service must be an {nameof(IInternalDisposableService)} without specifying " +
$"{nameof(IDisposable)} nor {nameof(IAsyncDisposable)} if it is purely meant to be a service, " +
$"or an {nameof(IPublicDisposableService)} if it also is allowed to be constructed not as a " +
$"service to be used elsewhere and has to offer {nameof(IDisposable)} or " +
$"{nameof(IAsyncDisposable)}. See {nameof(ReliableFileStorage)} for an example of " +
$"{nameof(IPublicDisposableService)}.");
}
}
catch (Exception e)
{
throw new InvalidOperationException($"{serviceType.Name}: {e.Message}");
}
#endif
}
/// <summary>
/// Indicates that this constructor will be called for early initialization.
/// </summary>
[AttributeUsage(AttributeTargets.Constructor)]
[MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
public class ServiceConstructor : Attribute
{
}
/// <summary>
/// Indicates that the field is a service that should be loaded before constructing the class.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class ServiceDependency : Attribute
{
}
/// <summary>
/// Indicates that the class is a service.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public abstract class ServiceAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ServiceAttribute"/> class.
/// </summary>
/// <param name="kind">The kind of the service.</param>
protected ServiceAttribute(ServiceKind kind) => this.Kind = kind;
/// <summary>
/// Gets the kind of the service.
/// </summary>
public ServiceKind Kind { get; }
}
/// <summary>
/// Indicates that the class is a service, that is provided by some other source.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class ProvidedServiceAttribute : ServiceAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ProvidedServiceAttribute"/> class.
/// </summary>
public ProvidedServiceAttribute()
: base(ServiceKind.ProvidedService)
{
}
}
/// <summary>
/// Indicates that the class is a service, and will be instantiated automatically on startup.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class EarlyLoadedServiceAttribute : ServiceAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="EarlyLoadedServiceAttribute"/> class.
/// </summary>
public EarlyLoadedServiceAttribute()
: this(ServiceKind.EarlyLoadedService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="EarlyLoadedServiceAttribute"/> class.
/// </summary>
/// <param name="kind">The service kind.</param>
protected EarlyLoadedServiceAttribute(ServiceKind kind)
: base(kind)
{
}
}
/// <summary>
/// Indicates that the class is a service, and will be instantiated automatically on startup,
/// blocking game main thread until it completes.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class BlockingEarlyLoadedServiceAttribute : EarlyLoadedServiceAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockingEarlyLoadedServiceAttribute"/> class.
/// </summary>
/// <param name="blockReason">Reason of blocking the game startup.</param>
public BlockingEarlyLoadedServiceAttribute(string blockReason)
: base(ServiceKind.BlockingEarlyLoadedService)
{
this.BlockReason = blockReason;
}
/// <summary>Gets the reason of blocking the startup of the game.</summary>
public string BlockReason { get; }
}
/// <summary>
/// Indicates that the class is a service that will be created specifically for a
/// service scope, and that it cannot be created outside of a scope.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class ScopedServiceAttribute : ServiceAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ScopedServiceAttribute"/> class.
/// </summary>
public ScopedServiceAttribute()
: base(ServiceKind.ScopedService)
{
}
}
/// <summary>
/// Indicates that the method should be called when the services given in the marked method's parameters are ready.
/// This will be executed immediately after the constructor has run, if all services specified as its parameters
/// are already ready, or no parameter is given.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
[MeansImplicitUse]
public class CallWhenServicesReady : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="CallWhenServicesReady"/> class.
/// </summary>
/// <param name="justification">Specify the reason here.</param>
public CallWhenServicesReady(string justification)
{
// No need to store the justification; the fact that the reason is specified is good enough.
_ = justification;
}
}
/// <summary>
/// Indicates that something is a candidate for being considered as an injected parameter for constructors.
/// </summary>
[AttributeUsage(
AttributeTargets.Delegate
| AttributeTargets.Class
| AttributeTargets.Struct
| AttributeTargets.Enum
| AttributeTargets.Interface)]
public class InjectableTypeAttribute : Attribute
{
}
}

View file

@ -0,0 +1,473 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Utility;
using Dalamud.Utility.Timing;
using JetBrains.Annotations;
namespace Dalamud;
/// <summary>
/// Basic service locator.
/// </summary>
/// <remarks>
/// Only used internally within Dalamud, if plugins need access to things it should be _only_ via DI.
/// </remarks>
/// <typeparam name="T">The class you want to store in the service locator.</typeparam>
[SuppressMessage("ReSharper", "StaticMemberInGenericType", Justification = "Service container static type")]
internal static class Service<T> where T : IServiceType
{
private static readonly ServiceManager.ServiceAttribute ServiceAttribute;
private static TaskCompletionSource<T> instanceTcs = new();
private static List<Type>? dependencyServices;
private static List<Type>? dependencyServicesForUnload;
static Service()
{
var type = typeof(T);
ServiceAttribute =
type.GetCustomAttribute<ServiceManager.ServiceAttribute>(true)
?? throw new InvalidOperationException(
$"{nameof(T)} is missing {nameof(ServiceManager.ServiceAttribute)} annotations.");
var exposeToPlugins = type.GetCustomAttribute<PluginInterfaceAttribute>() != null;
if (exposeToPlugins)
ServiceManager.Log.Debug("Service<{0}>: Static ctor called; will be exposed to plugins", type.Name);
else
ServiceManager.Log.Debug("Service<{0}>: Static ctor called", type.Name);
if (exposeToPlugins)
Service<ServiceContainer>.Get().RegisterSingleton(instanceTcs.Task);
}
/// <summary>
/// Specifies how to handle the cases of failed services when calling <see cref="Service{T}.GetNullable"/>.
/// </summary>
public enum ExceptionPropagationMode
{
/// <summary>
/// Propagate all exceptions.
/// </summary>
PropagateAll,
/// <summary>
/// Propagate all exceptions, except for <see cref="UnloadedException"/>.
/// </summary>
PropagateNonUnloaded,
/// <summary>
/// Treat all exceptions as null.
/// </summary>
None,
}
/// <summary>Does nothing.</summary>
/// <remarks>Used to invoke the static ctor.</remarks>
public static void Nop()
{
}
/// <summary>
/// Sets the type in the service locator to the given object.
/// </summary>
/// <param name="obj">Object to set.</param>
public static void Provide(T obj)
{
ServiceManager.Log.Debug("Service<{0}>: Provided", typeof(T).Name);
if (obj is IPublicDisposableService pds)
pds.MarkDisposeOnlyFromService();
instanceTcs.SetResult(obj);
}
/// <summary>
/// Sets the service load state to failure.
/// </summary>
/// <param name="exception">The exception.</param>
public static void ProvideException(Exception exception)
{
ServiceManager.Log.Error(exception, "Service<{0}>: Error", typeof(T).Name);
instanceTcs.SetException(exception);
}
/// <summary>
/// Pull the instance out of the service locator, waiting if necessary.
/// </summary>
/// <returns>The object.</returns>
public static T Get()
{
#if DEBUG
if (ServiceAttribute.Kind != ServiceManager.ServiceKind.ProvidedService
&& ServiceManager.CurrentConstructorServiceType.Value is { } currentServiceType)
{
var deps = ServiceHelpers.GetDependencies(typeof(Service<>).MakeGenericType(currentServiceType), false);
if (!deps.Contains(typeof(T)))
{
throw new InvalidOperationException(
$"Calling {nameof(Service<IServiceType>)}<{typeof(T)}>.{nameof(Get)} which is not one of the" +
$" dependency services is forbidden from the service constructor of {currentServiceType}." +
$" This has a high chance of introducing hard-to-debug hangs.");
}
}
#endif
if (!instanceTcs.Task.IsCompleted)
instanceTcs.Task.Wait(ServiceManager.UnloadCancellationToken);
return instanceTcs.Task.Result;
}
/// <summary>
/// Pull the instance out of the service locator, waiting if necessary.
/// </summary>
/// <returns>The object.</returns>
public static Task<T> GetAsync() => instanceTcs.Task;
/// <summary>
/// Attempt to pull the instance out of the service locator.
/// </summary>
/// <param name="propagateException">Specifies which exceptions to propagate.</param>
/// <returns>The object if registered, null otherwise.</returns>
public static T? GetNullable(ExceptionPropagationMode propagateException = ExceptionPropagationMode.PropagateNonUnloaded)
{
if (instanceTcs.Task.IsCompletedSuccessfully)
return instanceTcs.Task.Result;
if (instanceTcs.Task.IsFaulted && propagateException != ExceptionPropagationMode.None)
{
if (propagateException == ExceptionPropagationMode.PropagateNonUnloaded
&& instanceTcs.Task.Exception!.InnerExceptions.FirstOrDefault() is UnloadedException)
return default;
throw instanceTcs.Task.Exception!;
}
return default;
}
/// <summary>
/// Gets an enumerable containing <see cref="Service{T}"/>s that are required for this Service to initialize
/// without blocking.
/// These are NOT returned as <see cref="Service{T}"/> types; raw types will be returned.
/// </summary>
/// <param name="includeUnloadDependencies">Whether to include the unload dependencies.</param>
/// <returns>List of dependency services.</returns>
public static IReadOnlyCollection<Type> GetDependencyServices(bool includeUnloadDependencies)
{
if (includeUnloadDependencies && dependencyServicesForUnload is not null)
return dependencyServicesForUnload;
if (dependencyServices is not null)
return dependencyServices;
var res = new List<Type>();
ServiceManager.Log.Verbose("Service<{0}>: Getting dependencies", typeof(T).Name);
var ctor = GetServiceConstructor();
if (ctor != null)
{
res.AddRange(ctor
.GetParameters()
.Select(x => x.ParameterType)
.Where(x => x.GetServiceKind() != ServiceManager.ServiceKind.None));
}
res.AddRange(typeof(T)
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Where(x => x.GetCustomAttribute<ServiceManager.ServiceDependency>(true) != null)
.Select(x => x.FieldType));
res.AddRange(typeof(T)
.GetCustomAttributes()
.OfType<InherentDependencyAttribute>()
.Select(x => x.GetType().GetGenericArguments().First()));
foreach (var type in res)
ServiceManager.Log.Verbose("Service<{0}>: => Dependency: {1}", typeof(T).Name, type.Name);
var deps = res
.Distinct()
.ToList();
if (typeof(T).GetCustomAttribute<ServiceManager.BlockingEarlyLoadedServiceAttribute>() is not null)
{
var offenders = deps.Where(
x => x.GetCustomAttribute<ServiceManager.ServiceAttribute>(true)!.Kind
is not ServiceManager.ServiceKind.BlockingEarlyLoadedService
and not ServiceManager.ServiceKind.ProvidedService)
.ToArray();
if (offenders.Any())
{
const string bels = nameof(ServiceManager.BlockingEarlyLoadedServiceAttribute);
const string ps = nameof(ServiceManager.ProvidedServiceAttribute);
var errmsg =
$"{typeof(T)} is a {bels}; it can only depend on {bels} and {ps}.\nOffending dependencies:\n" +
string.Join("\n", offenders.Select(x => $"\t* {x.Name}"));
#if DEBUG
Util.Fatal(errmsg, "Service Dependency Check");
#else
ServiceManager.Log.Error(errmsg);
#endif
}
}
return dependencyServices = deps;
}
/// <summary>
/// Starts the service loader. Only to be called from <see cref="ServiceManager"/>.
/// </summary>
/// <param name="additionalProvidedTypedObjects">Additional objects available to constructors.</param>
/// <returns>The loader task.</returns>
internal static Task<T> StartLoader(IReadOnlyCollection<object> additionalProvidedTypedObjects)
{
if (instanceTcs.Task.IsCompleted)
throw new InvalidOperationException($"{typeof(T).Name} is already loaded or disposed.");
var attr = ServiceAttribute.GetType();
if (attr.IsAssignableTo(typeof(ServiceManager.EarlyLoadedServiceAttribute)) != true)
throw new InvalidOperationException($"{typeof(T).Name} is not an EarlyLoadedService");
return Task.Run(Timings.AttachTimingHandle(async () =>
{
var ctorArgs = new List<object>(additionalProvidedTypedObjects.Count + 1);
ctorArgs.AddRange(additionalProvidedTypedObjects);
ctorArgs.Add(
new ServiceManager.RegisterUnloadAfterDelegate(
(additionalDependencies, justification) =>
{
#if DEBUG
if (ServiceManager.CurrentConstructorServiceType.Value != typeof(T))
throw new InvalidOperationException("Forbidden.");
#endif
dependencyServicesForUnload ??= new(GetDependencyServices(false));
dependencyServicesForUnload.AddRange(additionalDependencies);
// No need to store the justification; the fact that the reason is specified is good enough.
_ = justification;
}));
ServiceManager.Log.Debug("Service<{0}>: Begin construction", typeof(T).Name);
try
{
var instance = await ConstructObject(ctorArgs).ConfigureAwait(false);
instanceTcs.SetResult(instance);
List<Task>? tasks = null;
foreach (var method in typeof(T).GetMethods(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
if (method.GetCustomAttribute<ServiceManager.CallWhenServicesReady>(true) == null)
continue;
ServiceManager.Log.Debug("Service<{0}>: Calling {1}", typeof(T).Name, method.Name);
var args = await ResolveInjectedParameters(
method.GetParameters(),
Array.Empty<object>()).ConfigureAwait(false);
if (args.Length == 0)
{
ServiceManager.Log.Warning(
"Service<{0}>: Method {1} does not have any arguments. Consider merging it with the ctor.",
typeof(T).Name,
method.Name);
}
try
{
if (method.Invoke(instance, args) is Task task)
{
tasks ??= new();
tasks.Add(task);
}
}
catch (Exception e)
{
tasks ??= new();
tasks.Add(Task.FromException(e));
}
}
if (tasks is not null)
await Task.WhenAll(tasks);
ServiceManager.Log.Debug("Service<{0}>: Construction complete", typeof(T).Name);
return instance;
}
catch (Exception e)
{
ServiceManager.Log.Error(e, "Service<{0}>: Construction failure", typeof(T).Name);
instanceTcs.SetException(e);
throw;
}
}));
}
[UsedImplicitly]
private static void Unset()
{
if (!instanceTcs.Task.IsCompletedSuccessfully)
return;
switch (instanceTcs.Task.Result)
{
case IInternalDisposableService d:
ServiceManager.Log.Debug("Service<{0}>: Disposing", typeof(T).Name);
try
{
d.DisposeService();
ServiceManager.Log.Debug("Service<{0}>: Disposed", typeof(T).Name);
}
catch (Exception e)
{
ServiceManager.Log.Warning(e, "Service<{0}>: Dispose failure", typeof(T).Name);
}
break;
default:
ServiceManager.CheckServiceTypeContracts(typeof(T));
ServiceManager.Log.Debug("Service<{0}>: Unset", typeof(T).Name);
break;
}
instanceTcs = new TaskCompletionSource<T>();
instanceTcs.SetException(new UnloadedException());
}
private static ConstructorInfo? GetServiceConstructor()
{
const BindingFlags ctorBindingFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.CreateInstance | BindingFlags.OptionalParamBinding;
return typeof(T)
.GetConstructors(ctorBindingFlags)
.SingleOrDefault(x => x.GetCustomAttributes(typeof(ServiceManager.ServiceConstructor), true).Any());
}
private static async Task<T> ConstructObject(IReadOnlyCollection<object> additionalProvidedTypedObjects)
{
var ctor = GetServiceConstructor();
if (ctor == null)
throw new Exception($"Service \"{typeof(T).FullName}\" had no applicable constructor");
var args = await ResolveInjectedParameters(ctor.GetParameters(), additionalProvidedTypedObjects)
.ConfigureAwait(false);
using (Timings.Start($"{typeof(T).Name} Construct"))
{
#if DEBUG
ServiceManager.CurrentConstructorServiceType.Value = typeof(T);
try
{
return (T)ctor.Invoke(args)!;
}
finally
{
ServiceManager.CurrentConstructorServiceType.Value = null;
}
#else
return (T)ctor.Invoke(args)!;
#endif
}
}
private static Task<object[]> ResolveInjectedParameters(
IReadOnlyList<ParameterInfo> argDefs,
IReadOnlyCollection<object> additionalProvidedTypedObjects)
{
var argTasks = new Task<object>[argDefs.Count];
for (var i = 0; i < argDefs.Count; i++)
{
var argType = argDefs[i].ParameterType;
ref var argTask = ref argTasks[i];
if (argType.GetCustomAttribute<ServiceManager.InjectableTypeAttribute>() is not null)
{
argTask = Task.FromResult(additionalProvidedTypedObjects.Single(x => x.GetType() == argType));
continue;
}
argTask = (Task<object>)typeof(Service<>)
.MakeGenericType(argType)
.InvokeMember(
nameof(GetAsyncAsObject),
BindingFlags.InvokeMethod |
BindingFlags.Static |
BindingFlags.NonPublic,
null,
null,
null)!;
}
return Task.WhenAll(argTasks);
}
/// <summary>
/// Pull the instance out of the service locator, waiting if necessary.
/// </summary>
/// <returns>The object.</returns>
private static Task<object> GetAsyncAsObject() => instanceTcs.Task.ContinueWith(r => (object)r.Result);
/// <summary>
/// Exception thrown when service is attempted to be retrieved when it's unloaded.
/// </summary>
public class UnloadedException : InvalidOperationException
{
/// <summary>
/// Initializes a new instance of the <see cref="UnloadedException"/> class.
/// </summary>
public UnloadedException()
: base("Service is unloaded.")
{
}
}
}
/// <summary>
/// Helper functions for services.
/// </summary>
internal static class ServiceHelpers
{
/// <summary>
/// Get a list of dependencies for a service. Only accepts <see cref="Service{T}"/> types.
/// These are NOT returned as <see cref="Service{T}"/> types; raw types will be returned.
/// </summary>
/// <param name="serviceType">The dependencies for this service.</param>
/// <param name="includeUnloadDependencies">Whether to include the unload dependencies.</param>
/// <returns>A list of dependencies.</returns>
public static IReadOnlyCollection<Type> GetDependencies(Type serviceType, bool includeUnloadDependencies)
{
#if DEBUG
if (!serviceType.IsGenericType || serviceType.GetGenericTypeDefinition() != typeof(Service<>))
{
throw new ArgumentException(
$"Expected an instance of {nameof(Service<IServiceType>)}<>",
nameof(serviceType));
}
#endif
return (IReadOnlyCollection<Type>)serviceType.InvokeMember(
nameof(Service<IServiceType>.GetDependencyServices),
BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public,
null,
null,
new object?[] { includeUnloadDependencies }) ?? new List<Type>();
}
/// <summary>
/// Get the <see cref="Service{T}"/> type for a given service type.
/// This will throw if the service type is not a valid service.
/// </summary>
/// <param name="type">The type to obtain a <see cref="Service{T}"/> for.</param>
/// <returns>The <see cref="Service{T}"/>.</returns>
public static Type GetAsService(Type type)
{
#if DEBUG
if (!type.IsAssignableTo(typeof(IServiceType)))
throw new ArgumentException($"Expected an instance of {nameof(IServiceType)}", nameof(type));
#endif
return typeof(Service<>).MakeGenericType(type);
}
}