diff --git a/src/Ellie/Marmalade/Adapters/BehaviorAdapter.cs b/src/Ellie/Marmalade/Adapters/BehaviorAdapter.cs new file mode 100644 index 0000000..63857ce --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/BehaviorAdapter.cs @@ -0,0 +1,78 @@ +#nullable enable + +[DIIgnore] +public sealed class BehaviorAdapter : ICustomBehavior +{ + private readonly WeakReference _canaryWr; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + private readonly string _name; + + public string Name => _name; + + // unused + public int Priority + => 0; + + public BehaviorAdapter(WeakReference canaryWr, IMarmaladeStrings strings, IServiceProvider services) + { + _canaryWr = canaryWr; + _strings = strings; + _services = services; + + _name = canaryWr.TryGetTarget(out var canary) + ? $"canary/{canary.GetType().Name}" + : "unknown"; + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (!_canaryWr.TryGetTarget(out var snek)) + return false; + + return await snek.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + command.Name); + } + + public async Task ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var snek)) + return false; + + return await snek.ExecOnMessageAsync(guild, msg); + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (!_canaryWr.TryGetTarget(out var snek)) + return null; + + return await snek.ExecInputTransformAsync(guild, channel, user, input); + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var snek)) + return; + + await snek.ExecOnNoCommandAsync(guild, msg); + } + + public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName) + { + if (!_canaryWr.TryGetTarget(out var snek)) + return; + + await snek.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + commandName); + } + + public override string ToString() + => _name; +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Adapters/ContextAdapterFactory.cs b/src/Ellie/Marmalade/Adapters/ContextAdapterFactory.cs new file mode 100644 index 0000000..3412c7d --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/ContextAdapterFactory.cs @@ -0,0 +1,7 @@ +internal class ContextAdapterFactory +{ + public static AnyContext CreateNew(ICommandContext context, IMarmaladeStrings strings, IServiceProvider services) + => context.Guild is null + ? new DmContextAdapter(context, strings, services) + : new GuildContextAdapter(context, strings, services); +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Adapters/DmContextAdapter.cs b/src/Ellie/Marmalade/Adapters/DmContextAdapter.cs new file mode 100644 index 0000000..e73ebce --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/DmContextAdapter.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; + +public sealed class DmContextAdapter : DmContext +{ + public override IMarmaladeStrings Strings { get; } + public override IDMChannel Channel { get; } + public override IUserMessage Message { get; } + public override ISelfUser Bot { get; } + public override IUser User + => Message.Author; + + + private readonly IServiceProvider _services; + private readonly Lazy _ebs; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public DmContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx is not { Channel: IDMChannel ch }) + { + throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx)); + } + + Strings = strings; + + _services = services; + + Channel = ch; + Message = ctx.Message; + Bot = ctx.Client.CurrentUser; + + + _ebs = new(_services.GetRequiredService()); + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + } + + public override IEmbedBuilder Embed() + => _ebs.Value.Create(); + + public override string GetText(string key, object[]? args = null) + { + var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?)); + var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty()); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Adapters/FilterAdapter.cs b/src/Ellie/Marmalade/Adapters/FilterAdapter.cs new file mode 100644 index 0000000..fe1deac --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/FilterAdapter.cs @@ -0,0 +1,31 @@ +namespace Ellie.Marmalade.Adapters; + +public class FilterAdapter : PreconditionAttribute +{ + private readonly FilterAttribute _filterAttribute; + private readonly IMarmaladeStrings _strings; + + public FilterAdapter(FilterAttribute filterAttribute, + IMarmaladeStrings strings) + { + _filterAttribute = filterAttribute; + _strings = strings; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var medusaContext = ContextAdapterFactory.CreateNew(context, + _strings, + services); + + var result = await _filterAttribute.CheckAsync(medusaContext); + + if (!result) + return PreconditionResult.FromError($"Precondition '{_filterAttribute.GetType().Name}' failed."); + + return PreconditionResult.FromSuccess(); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Adapters/GuildContextAdapter.cs b/src/Ellie/Marmalade/Adapters/GuildContextAdapter.cs new file mode 100644 index 0000000..a705359 --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/GuildContextAdapter.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; + +public sealed class GuildContextAdapter : GuildContext +{ + private readonly IServiceProvider _services; + private readonly ICommandContext _ctx; + private readonly Lazy _ebs; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public override IMarmaladeStrings Strings { get; } + public override IGuild Guild { get; } + public override ITextChannel Channel { get; } + public override ISelfUser Bot { get; } + public override IUserMessage Message + => _ctx.Message; + + public override IGuildUser User { get; } + + public override IEmbedBuilder Embed() + => _ebs.Value.Create(); + + public GuildContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel) + { + throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx)); + } + + Strings = strings; + User = (IGuildUser)ctx.User; + Bot = ctx.Client.CurrentUser; + + _services = services; + _ebs = new(_services.GetRequiredService()); + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + + (_ctx, Guild, Channel) = (ctx, guild, channel); + } + + public override string GetText(string key, object[]? args = null) + { + args ??= Array.Empty(); + + var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id); + var output = Strings.GetText(key, cultureInfo, args); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Adapters/ParamParserAdapter.cs b/src/Ellie/Marmalade/Adapters/ParamParserAdapter.cs new file mode 100644 index 0000000..98b7317 --- /dev/null +++ b/src/Ellie/Marmalade/Adapters/ParamParserAdapter.cs @@ -0,0 +1,32 @@ +public sealed class ParamParserAdapter : TypeReader +{ + private readonly ParamParser _parser; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + + public ParamParserAdapter(ParamParser parser, + IMarmaladeStrings strings, + IServiceProvider services) + { + _parser = parser; + _strings = strings; + _services = services; + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + var medusaContext = ContextAdapterFactory.CreateNew(context, + _strings, + _services); + + var result = await _parser.TryParseAsync(medusaContext, input); + + if(result.IsSuccess) + return Discord.Commands.TypeReaderResult.FromSuccess(result.Data); + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input"); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/CommandContextType.cs b/src/Ellie/Marmalade/CommandContextType.cs new file mode 100644 index 0000000..56e6348 --- /dev/null +++ b/src/Ellie/Marmalade/CommandContextType.cs @@ -0,0 +1,27 @@ +namespace Ellie.Marmalade; + +/// +/// Enum specifying in which context the command can be executed +/// +public enum CommandContextType +{ + /// + /// Command can only be executed in a guild + /// + Guild, + + /// + /// Command can only be executed in DMs + /// + Dm, + + /// + /// Command can be executed anywhere + /// + Any, + + /// + /// Command can be executed anywhere, and it doesn't require context to be passed to it + /// + Unspecified +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Config/IMarmaladeConfigService.cs b/src/Ellie/Marmalade/Config/IMarmaladeConfigService.cs new file mode 100644 index 0000000..f3e6f6a --- /dev/null +++ b/src/Ellie/Marmalade/Config/IMarmaladeConfigService.cs @@ -0,0 +1,8 @@ +namespace Ellie.Marmalade; + +public interface IMarmaladeConfigService +{ + IReadOnlyCollection GetLoadedMarmalades(); + void AddLoadedMarmalade(string name); + void RemoveLoadedMarmalade(string name); +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Config/MarmaladeConfig.cs b/src/Ellie/Marmalade/Config/MarmaladeConfig.cs new file mode 100644 index 0000000..bd2ec75 --- /dev/null +++ b/src/Ellie/Marmalade/Config/MarmaladeConfig.cs @@ -0,0 +1,20 @@ +#nullable enable +using Cloneable; +using Ellie.Common.Yml; + +namespace Ellie.Marmalade; + +[Cloneable] +public sealed partial class MarmaladeConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 1; + + [Comment("""List of medusae automatically loaded at startup""")] + public List? Loaded { get; set; } + + public MarmaladeConfig() + { + Loaded = new(); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Config/MarmaladeConfigService.cs b/src/Ellie/Marmalade/Config/MarmaladeConfigService.cs new file mode 100644 index 0000000..d108359 --- /dev/null +++ b/src/Ellie/Marmalade/Config/MarmaladeConfigService.cs @@ -0,0 +1,45 @@ +using Ellie.Common.Configs; + +namespace Ellie.Marmalade; + +public sealed class MarmaladeConfigService : ConfigServiceBase, IMarmaladeConfigService +{ + private const string FILE_PATH = "data/marmalades/marmalade.yml"; + private static readonly TypedKey _changeKey = new("config.marmalade.updated"); + + public override string Name + => "medusa"; + + public MarmaladeConfigService( + IConfigSeria serializer, + IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + } + + public IReadOnlyCollection GetLoadedMarmalades() + => Data.Loaded?.ToList() ?? new List(); + + public void AddLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + if(!conf.Loaded.Contains(name)) + conf.Loaded.Add(name); + }); + } + + public void RemoveLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + conf.Loaded.Remove(name); + }); + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/MarmaladeAssemblyLoadContext.cs b/src/Ellie/Marmalade/MarmaladeAssemblyLoadContext.cs new file mode 100644 index 0000000..02c8412 --- /dev/null +++ b/src/Ellie/Marmalade/MarmaladeAssemblyLoadContext.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Ellie.Marmalade; + +public class MarmaladeAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public MarmaladeAssemblyLoadContext(string folderPath) : base(isCollectible: true) + => _resolver = new(folderPath); + + // public Assembly MainAssembly { get; private set; } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath != null) + { + var assembly = LoadFromAssemblyPath(assemblyPath); + LoadDependencies(assembly); + return assembly; + } + + return null; + } + + public void LoadDependencies(Assembly assembly) + { + foreach (var reference in assembly.GetReferencedAssemblies()) + { + Load(reference); + } + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/MarmaladeIoCKernelModule.cs b/src/Ellie/Marmalade/MarmaladeIoCKernelModule.cs new file mode 100644 index 0000000..84919e4 --- /dev/null +++ b/src/Ellie/Marmalade/MarmaladeIoCKernelModule.cs @@ -0,0 +1,98 @@ +using System.Reflection; +using Ninject; +using Ninject.Activation; +using Ninject.Activation.Caching; +using Ninject.Modules; +using Ninject.Planning; +using System.Text.Json; + +namespace Ellie.Marmalade; + +public sealed class MarmaladeNinjectModule : NinjectModule +{ + public override string Name { get; } + private volatile bool isLoaded = false; + private readonly Dictionary _types; + + public MarmaladeNinjectModule(Assembly assembly, string name) + { + Name = name; + _types = assembly.GetExportedTypes() + .Where(t => t.IsClass) + .Where(t => t.GetCustomAttribute() is not null) + .ToDictionary(x => x, + type => type.GetInterfaces().ToArray()); + } + + public override void Load() + { + if (isLoaded) + return; + + foreach (var (type, data) in _types) + { + var attribute = type.GetCustomAttribute()!; + var scope = GetScope(attribute.Lifetime); + + Bind(type) + .ToSelf() + .InScope(scope); + + foreach (var inter in data) + { + Bind(inter) + .ToMethod(x => x.Kernel.Get(type)) + .InScope(scope); + } + } + + isLoaded = true; + } + + private Func GetScope(Lifetime lt) + => _ => lt switch + { + Lifetime.Singleton => this, + Lifetime.Transient => null, + _ => null, + }; + + public override void Unload() + { + if (!isLoaded) + return; + + var planner = (RemovablePlanner)Kernel!.Components.Get(); + var cache = Kernel.Components.Get(); + foreach (var binding in this.Bindings) + { + Kernel.RemoveBinding(binding); + } + + foreach (var type in _types.SelectMany(x => x.Value).Concat(_types.Keys)) + { + var binds = Kernel.GetBindings(type); + + if (!binds.Any()) + { + Unbind(type); + + planner.RemovePlan(type); + } + } + + + Bindings.Clear(); + + cache.Clear(this); + _types.Clear(); + + // in case the library uses System.Text.Json + var assembly = typeof(JsonSerializerOptions).Assembly; + var updateHandlerType = assembly.GetType("System.Text.Json.JsonSerializerOptionsUpdateHandler"); + var clearCacheMethod = updateHandlerType?.GetMethod("ClearCache", BindingFlags.Static | BindingFlags.Public); + clearCacheMethod?.Invoke(null, new object?[] { null }); + + isLoaded = false; + } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/MarmaladeLoaderService.cs b/src/Ellie/Marmalade/MarmaladeLoaderService.cs new file mode 100644 index 0000000..d2aafcc --- /dev/null +++ b/src/Ellie/Marmalade/MarmaladeLoaderService.cs @@ -0,0 +1,917 @@ +using Discord.Commands.Builders; +using Microsoft.Extensions.DependencyInjection; +using Ellie.Marmalade.Adapters; +using Ellie.Common.ModuleBehaviors; +using Ninject; +using Ninject.Modules; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Ellie.Marmalade; + +// ReSharper disable RedundantAssignment +public sealed class MarmaladeLoaderService : IMarmaladeLoaderSevice, IReadyExecutor, IEService +{ + private readonly CommandService _cmdService; + private readonly IBehaviorHandler _behHandler; + private readonly IPubSub _pubSub; + private readonly IMarmaladeConfigService _marmaladeConfig; + private readonly IKernel _kernel; + + private readonly ConcurrentDictionary _resolved = new(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + private readonly TypedKey _loadKey = new("marmalade:load"); + private readonly TypedKey _unloadKey = new("marmalade:unload"); + + private readonly TypedKey _stringsReload = new("marmalade:reload_strings"); + + private const string BASE_DIR = "data/marmalades"; + + public MarmaladeLoaderService( + CommandService cmdService, + IKernel kernel, + IBehaviorHandler behHandler, + IPubSub pubSub, + IMarmaladeConfigService marmaladeConfig) + { + _cmdService = cmdService; + _behHandler = behHandler; + _pubSub = pubSub; + _marmaladeConfig = marmaladeConfig; + _kernel = kernel; + + // has to be done this way to support this feature on sharded bots + _pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name)); + _pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name)); + + _pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal()); + } + + public IReadOnlyCollection GetAllMarmalades() + { + if (!Directory.Exists(BASE_DIR)) + return Array.Empty(); + + return Directory.GetDirectories(BASE_DIR) + .Select(x => Path.GetRelativePath(BASE_DIR, x)) + .ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection GetLoadedMarmalades(CultureInfo? culture) + { + var toReturn = new List(_resolved.Count); + foreach (var (name, resolvedData) in _resolved) + { + var canaries = new List(resolvedData.CanaryInfos.Count); + + foreach (var canaryInfos in resolvedData.CanaryInfos.Concat(resolvedData.CanaryInfos.SelectMany(x => x.Subcanaries))) + { + var commands = new List(); + + foreach (var command in canaryInfos.Commands) + { + commands.Add(new CanaryCommandStats(command.Aliases.First())); + } + + canaries.Add(new CanaryStats(canaryInfos.Name, canaryInfos.Instance.Prefix, commands)); + } + + toReturn.Add(new MarmaladeStats(name, resolvedData.Strings.GetDescription(culture), canaries)); + } + + return toReturn; + } + + public async Task OnReadyAsync() + { + foreach (var name in _marmaladeConfig.GetLoadedMarmalades()) + { + var result = await InternalLoadAsync(name); + if (result != MarmaladeLoadResult.Success) + Log.Warning("Unable to load '{MarmaladeName}' marmalade", name); + else + Log.Warning("Loaded marmalade '{MarmaladeName}'", name); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task LoadMarmaladeAsync(string marmaladeName) + { + // try loading on this shard first to see if it works + var res = await InternalLoadAsync(marmaladeName); + if (res == MarmaladeLoadResult.Success) + { + // if it does publish it so that other shards can load the marmalade too + // this method will be ran twice on this shard but it doesn't matter as + // the second attempt will be ignored + await _pubSub.Pub(_loadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task UnloadMarmaladeAsync(string marmaladeName) + { + var res = await InternalUnloadAsync(marmaladeName); + if (res == MarmaladeUnloadResult.Success) + { + await _pubSub.Pub(_unloadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return Array.Empty(); + + return data.Strings.GetCommandStrings(commandName, culture).Args + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Args + ?? new[] { string.Empty }; + } + + public Task ReloadStrings() + => _pubSub.Pub(_stringsReload, true); + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ReloadStringsSync() + { + foreach (var resolved in _resolved.Values) + { + resolved.Strings.Reload(); + } + } + + private async Task ReloadStringsInternal() + { + await _lock.WaitAsync(); + try + { + ReloadStringsSync(); + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return string.Empty; + + return data.Strings.GetCommandStrings(commandName, culture).Desc + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Desc + ?? string.Empty; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async ValueTask InternalLoadAsync(string name) + { + if (_resolved.ContainsKey(name)) + return MarmaladeLoadResult.AlreadyLoaded; + + var safeName = Uri.EscapeDataString(name); + + await _lock.WaitAsync(); + try + { + if (LoadAssemblyInternal(safeName, + out var ctx, + out var canaryData, + out var kernelModule, + out var strings, + out var typeReaders)) + { + var moduleInfos = new List(); + + LoadTypeReadersInternal(typeReaders); + + foreach (var point in canaryData) + { + try + { + // initialize canary and subcanaries + await point.Instance.InitializeAsync(); + foreach (var sub in point.Subcanaries) + { + await sub.Instance.InitializeAsync(); + } + + var module = await LoadModuleInternalAsync(name, point, strings, kernelModule); + moduleInfos.Add(module); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error loading canary {CanaryName}", + point.Name); + } + } + + var execs = GetExecsInternal(canaryData, strings); + await _behHandler.AddRangeAsync(execs); + + _resolved[name] = new(LoadContext: ctx, + ModuleInfos: moduleInfos.ToImmutableArray(), + CanaryInfos: canaryData.ToImmutableArray(), + strings, + typeReaders, + execs) + { + KernelModule = kernelModule + }; + + + _marmaladeConfig.AddLoadedMarmalade(safeName); + return MarmaladeLoadResult.Success; + } + + return MarmaladeLoadResult.Empty; + } + catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException) + { + return MarmaladeLoadResult.NotFound; + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred loading a marmalade"); + return MarmaladeLoadResult.UnknownError; + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetExecsInternal( + IReadOnlyCollection canaryData, + IMarmaladeStrings strings) + { + var behs = new List(); + foreach (var canary in canaryData) + { + behs.Add(new BehaviorAdapter(new(canary.Instance), strings, _kernel)); + + foreach (var sub in canary.Subcanaries) + { + behs.Add(new BehaviorAdapter(new(sub.Instance), strings, _kernel)); + } + } + + + return behs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void LoadTypeReadersInternal(Dictionary typeReaders) + { + var notAddedTypeReaders = new List(); + foreach (var (type, typeReader) in typeReaders) + { + // if type reader for this type already exists, it will not be replaced + if (_cmdService.TypeReaders.Contains(type)) + { + notAddedTypeReaders.Add(type); + continue; + } + + _cmdService.AddTypeReader(type, typeReader); + } + + // remove the ones that were not added + // to prevent them from being unloaded later + // as they didn't come from this marmalade + foreach (var toRemove in notAddedTypeReaders) + { + typeReaders.Remove(toRemove); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool LoadAssemblyInternal( + string safeName, + [NotNullWhen(true)] out WeakReference? ctxWr, + [NotNullWhen(true)] out IReadOnlyCollection? canaryData, + [NotNullWhen(true)] out INinjectModule? ninjectModule, + out IMarmaladeStrings strings, + out Dictionary typeReaders) + { + ctxWr = null; + canaryData = null; + + var path = Path.GetFullPath($"{BASE_DIR}/{safeName}/{safeName}.dll"); + var dir = Path.GetFullPath($"{BASE_DIR}/{safeName}"); + + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException($"Marmalade folder not found: {dir}"); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Marmalade dll not found: {path}"); + + strings = MarmaladeStrings.CreateDefault(dir); + var ctx = new MarmaladeAssemblyLoadContext(Path.GetDirectoryName(path)!); + var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path)); + ctx.LoadDependencies(a); + + // load services + ninjectModule = new MarmaladeNinjectModule(a, safeName); + _kernel.Load(ninjectModule); + + var sis = LoadCanariesFromAssembly(safeName, a); + typeReaders = LoadTypeReadersFromAssembly(a, strings); + + // todo allow this + if (sis.Count == 0) + { + _kernel.Unload(safeName); + return false; + } + + ctxWr = new(ctx); + canaryData = sis; + + return true; + } + + private static readonly Type _paramParserType = typeof(ParamParser<>); + + [MethodImpl(MethodImplOptions.NoInlining)] + private Dictionary LoadTypeReadersFromAssembly( + Assembly assembly, + IMarmaladeStrings strings) + { + var paramParsers = assembly.GetExportedTypes() + .Where(x => x.IsClass + && !x.IsAbstract + && x.BaseType is not null + && x.BaseType.IsGenericType + && x.BaseType.GetGenericTypeDefinition() == _paramParserType); + + var typeReaders = new Dictionary(); + foreach (var parserType in paramParsers) + { + var parserObj = ActivatorUtilities.CreateInstance(_kernel, parserType); + + var targetType = parserType.BaseType!.GetGenericArguments()[0]; + var typeReaderInstance = (TypeReader)Activator.CreateInstance( + typeof(ParamParserAdapter<>).MakeGenericType(targetType), + args: new[] { parserObj, strings, _kernel })!; + + typeReaders.Add(targetType, typeReaderInstance); + } + + return typeReaders; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task LoadModuleInternalAsync( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + INinjectModule services) + { + var module = await _cmdService.CreateModuleAsync(canaryInfo.Instance.Prefix, + CreateModuleFactory(marmaladeName, canaryInfo, strings, services)); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Action CreateModuleFactory( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + INinjectModule kernelModule) + => mb => + { + var m = mb.WithName(canaryInfo.Name); + + foreach (var f in canaryInfo.Filters) + { + m.AddPrecondition(new FilterAdapter(f, strings)); + } + + foreach (var cmd in canaryInfo.Commands) + { + m.AddCommand(cmd.Aliases.First(), + CreateCallback(cmd.ContextType, + new(canaryInfo), + new(cmd), + strings), + CreateCommandFactory(marmaladeName, cmd, strings)); + } + + foreach (var subInfo in canaryInfo.Subcanaries) + m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(marmaladeName, subInfo, strings, kernelModule)); + }; + + private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild); + private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM); + + private Action CreateCommandFactory(string marmaladeName, CanaryCommandData cmd, IMarmaladeStrings strings) + => (cb) => + { + cb.AddAliases(cmd.Aliases.Skip(1).ToArray()); + + if (cmd.ContextType == CommandContextType.Guild) + cb.AddPrecondition(_reqGuild); + else if (cmd.ContextType == CommandContextType.Dm) + cb.AddPrecondition(_reqDm); + + foreach (var f in cmd.Filters) + cb.AddPrecondition(new FilterAdapter(f, strings)); + + foreach (var ubp in cmd.UserAndBotPerms) + { + if (ubp is user_permAttribute up) + { + if (up.GuildPerm is { } gp) + cb.AddPrecondition(new UserPermAttribute(gp)); + else if (up.ChannelPerm is { } cp) + cb.AddPrecondition(new UserPermAttribute(cp)); + } + else if (ubp is bot_permAttribute bp) + { + if (bp.GuildPerm is { } gp) + cb.AddPrecondition(new BotPermAttribute(gp)); + else if (bp.ChannelPerm is { } cp) + cb.AddPrecondition(new BotPermAttribute(cp)); + } + else if (ubp is bot_owner_onlyAttribute) + { + cb.AddPrecondition(new OwnerOnlyAttribute()); + } + } + + cb.WithPriority(cmd.Priority); + + // using summary to save method name + // method name is used to retrieve desc/usages + cb.WithRemarks($"marmalade///{marmaladeName}"); + cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant()); + + foreach (var param in cmd.Parameters) + { + cb.AddParameter(param.Name, param.Type, CreateParamFactory(param)); + } + }; + + private Action CreateParamFactory(ParamData paramData) + => (pb) => + { + pb.WithIsMultiple(paramData.IsParams) + .WithIsOptional(paramData.IsOptional) + .WithIsRemainder(paramData.IsLeftover); + + if (paramData.IsOptional) + pb.WithDefault(paramData.DefaultValue); + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private Func CreateCallback( + CommandContextType contextType, + WeakReference canaryDataWr, + WeakReference canaryCommandDataWr, + IMarmaladeStrings strings) + => async ( + context, + parameters, + svcs, + _) => + { + if (!canaryCommandDataWr.TryGetTarget(out var cmdData) + || !canaryDataWr.TryGetTarget(out var canaryData)) + { + Log.Warning("Attempted to run an unloaded canary's command"); + return; + } + + var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, _kernel, strings); + + try + { + var methodInfo = cmdData.MethodInfo; + if (methodInfo.ReturnType == typeof(Task) + || (methodInfo.ReturnType.IsGenericType + && methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))) + { + await (Task)methodInfo.Invoke(canaryData.Instance, paramObjs)!; + } + else if (methodInfo.ReturnType == typeof(ValueTask)) + { + await ((ValueTask)methodInfo.Invoke(canaryData.Instance, paramObjs)!).AsTask(); + } + else // if (methodInfo.ReturnType == typeof(void)) + { + methodInfo.Invoke(canaryData.Instance, paramObjs); + } + } + finally + { + paramObjs = null; + cmdData = null; + + canaryData = null; + } + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object[] ParamObjs( + CommandContextType contextType, + CanaryCommandData cmdData, + object[] parameters, + ICommandContext context, + IServiceProvider svcs, + IServiceProvider svcProvider, + IMarmaladeStrings strings) + { + var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1; + extraParams += cmdData.InjectedParams.Count; + + var paramObjs = new object[parameters.Length + extraParams]; + + var startAt = 0; + if (contextType != CommandContextType.Unspecified) + { + paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs); + + startAt = 1; + } + + for (var i = 0; i < cmdData.InjectedParams.Count; i++) + { + var svc = svcProvider.GetService(cmdData.InjectedParams[i]); + if (svc is null) + { + throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}"); + } + + paramObjs[i + startAt] = svc; + + svc = null; + } + + startAt += cmdData.InjectedParams.Count; + + for (var i = 0; i < parameters.Length; i++) + paramObjs[startAt + i] = parameters[i]; + + return paramObjs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task InternalUnloadAsync(string name) + { + if (!_resolved.Remove(name, out var lsi)) + return MarmaladeUnloadResult.NotLoaded; + + await _lock.WaitAsync(); + try + { + UnloadTypeReaders(lsi.TypeReaders); + + foreach (var mi in lsi.ModuleInfos) + { + await _cmdService.RemoveModuleAsync(mi); + } + + await _behHandler.RemoveRangeAsync(lsi.Execs); + + await DisposeCanaryInstances(lsi); + + var lc = lsi.LoadContext; + var km = lsi.KernelModule; + lsi.KernelModule = null!; + + _kernel.Unload(km.Name); + + if (km is IDisposable d) + d.Dispose(); + + lsi = null; + + _marmaladeConfig.RemoveLoadedMarmalade(name); + return UnloadInternal(lc) + ? MarmaladeUnloadResult.Success + : MarmaladeUnloadResult.PossiblyUnable; + } + finally + { + _lock.Release(); + } + } + + private void UnloadTypeReaders(Dictionary valueTypeReaders) + { + foreach (var tr in valueTypeReaders) + { + _cmdService.TryRemoveTypeReader(tr.Key, false, out _); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task DisposeCanaryInstances(ResolvedMarmalade marmalade) + { + foreach (var si in marmalade.CanaryInfos) + { + try + { + await si.Instance.DisposeAsync(); + foreach (var sub in si.Subcanaries) + { + await sub.Instance.DisposeAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, + "Failed cleanup of Canary {CanaryName}. This marmalade might not unload correctly", + si.Instance.Name); + } + } + + // marmalades = null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool UnloadInternal(WeakReference lsi) + { + UnloadContext(lsi); + GcCleanup(); + + return !lsi.TryGetTarget(out _); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void UnloadContext(WeakReference lsiLoadContext) + { + if (lsiLoadContext.TryGetTarget(out var ctx)) + { + ctx.Unload(); + } + } + + private void GcCleanup() + { + // cleanup + for (var i = 0; i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.WaitForFullGCComplete(); + GC.Collect(); + } + } + + private static readonly Type _canaryType = typeof(Canary); + + // [MethodImpl(MethodImplOptions.NoInlining)] + // private MarmaladeIoCKernelModule LoadMarmaladeServicesInternal(string name, Assembly a) + // => new MarmaladeIoCKernelModule(name, a); + + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection LoadCanariesFromAssembly(string name, Assembly a) + { + // find all types in teh assembly + var types = a.GetExportedTypes(); + // canary is always a public non abstract class + var classes = types.Where(static x => x.IsClass + && (x.IsNestedPublic || x.IsPublic) + && !x.IsAbstract + && x.BaseType == _canaryType + && (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_canaryType))) + .ToList(); + + var topModules = new Dictionary(); + + foreach (var cl in classes) + { + if (cl.DeclaringType is not null) + continue; + + // get module data, and add it to the topModules dictionary + var module = GetModuleData(cl); + topModules.Add(cl, module); + } + + foreach (var c in classes) + { + if (c.DeclaringType is not Type dt) + continue; + + // if there is no top level module which this module is a child of + // just print a warning and skip it + if (!topModules.TryGetValue(dt, out var parentData)) + { + Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist", + c.Name, + dt.Name); + continue; + } + + GetModuleData(c, parentData); + } + + return topModules.Values.ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private CanaryInfo GetModuleData(Type type, CanaryInfo? parentData = null) + { + var filters = type.GetCustomAttributes(true) + .ToArray(); + + var instance = (Canary)ActivatorUtilities.CreateInstance(_kernel, type); + + var module = new CanaryInfo(instance.Name, + parentData, + instance, + GetCommands(instance, type), + filters); + + if (parentData is not null) + parentData.Subcanaries.Add(module); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetCommands(Canary instance, Type type) + { + var methodInfos = type + .GetMethods(BindingFlags.Instance + | BindingFlags.DeclaredOnly + | BindingFlags.Public) + .Where(static x => + { + if (x.GetCustomAttribute(true) is null) + return false; + + if (x.ReturnType.IsGenericType) + { + var genericType = x.ReturnType.GetGenericTypeDefinition(); + if (genericType == typeof(Task<>)) + return true; + + // if (genericType == typeof(ValueTask<>)) + // return true; + + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + + return false; + } + + var succ = x.ReturnType == typeof(Task) + || x.ReturnType == typeof(ValueTask) + || x.ReturnType == typeof(void); + + if (!succ) + { + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + } + + return succ; + }); + + + var cmds = new List(); + foreach (var method in methodInfos) + { + var filters = method.GetCustomAttributes(true).ToArray(); + var userAndBotPerms = method.GetCustomAttributes(true) + .ToArray(); + var prio = method.GetCustomAttribute(true)?.Priority ?? 0; + + var paramInfos = method.GetParameters(); + var cmdParams = new List(); + var diParams = new List(); + var cmdContext = CommandContextType.Unspecified; + var canInject = false; + for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++) + { + var pi = paramInfos[paramCounter]; + + var paramName = pi.Name ?? "unnamed"; + var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext)); + + var leftoverAttribute = pi.GetCustomAttribute(true); + var hasDefaultValue = pi.HasDefaultValue; + var defaultValue = pi.DefaultValue; + var isLeftover = leftoverAttribute != null; + var isParams = pi.GetCustomAttribute() is not null; + var paramType = pi.ParameterType; + var isInjected = pi.GetCustomAttribute(true) is not null; + + if (isContext) + { + if (hasDefaultValue || leftoverAttribute != null || isParams) + throw new ArgumentException( + "IContext parameter cannot be optional, leftover, constant or params. " + + GetErrorPath(method, pi)); + + if (paramCounter != 0) + throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}"); + + canInject = true; + + if (paramType.IsAssignableTo(typeof(GuildContext))) + cmdContext = CommandContextType.Guild; + else if (paramType.IsAssignableTo(typeof(DmContext))) + cmdContext = CommandContextType.Dm; + else + cmdContext = CommandContextType.Any; + + continue; + } + + if (isInjected) + { + if (!canInject && paramCounter != 0) + throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext"); + + canInject = true; + + diParams.Add(paramType); + continue; + } + + canInject = false; + + if (isParams) + { + if (hasDefaultValue) + throw new NotSupportedException("Params can't have const values at the moment. " + + GetErrorPath(method, pi)); + // if it's params, it means it's an array, and i only need a parser for the actual type, + // as the parser will run on each array element, it can't be null + paramType = paramType.GetElementType()!; + } + + // leftover can only be the last parameter. + if (isLeftover && paramCounter != paramInfos.Length - 1) + { + var path = GetErrorPath(method, pi); + Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ", + path); + throw new ArgumentException("Leftover attribute error."); + } + + cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, defaultValue, isLeftover, isParams)); + } + + + var cmdAttribute = method.GetCustomAttribute(true)!; + var aliases = cmdAttribute.Aliases; + if (aliases.Length == 0) + aliases = new[] { method.Name.ToLowerInvariant() }; + + cmds.Add(new( + aliases, + method, + instance, + filters, + userAndBotPerms, + cmdContext, + diParams, + cmdParams, + new(cmdAttribute.desc, cmdAttribute.args), + prio + )); + } + + return cmds; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi) + => $@"Module: {m.DeclaringType?.Name} +Command: {m.Name} +ParamName: {pi.Name} +ParamType: {pi.ParameterType.Name}"; +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Models/CanaryCommandData.cs b/src/Ellie/Marmalade/Models/CanaryCommandData.cs new file mode 100644 index 0000000..ba3c78a --- /dev/null +++ b/src/Ellie/Marmalade/Models/CanaryCommandData.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using CommandStrings = Ellie.Marmalade.CommandStrings; + +namespace Ellie.Marmalade; + +public sealed class CanaryCommandData +{ + public CanaryCommandData( + IReadOnlyCollection aliases, + MethodInfo methodInfo, + Canary module, + FilterAttribute[] filters, + MarmaladePermAttribute[] userAndBotPerms, + CommandContextType contextType, + IReadOnlyList injectedParams, + IReadOnlyList parameters, + CommandStrings strings, + int priority) + { + Aliases = aliases; + MethodInfo = methodInfo; + Module = module; + Filters = filters; + UserAndBotPerms = userAndBotPerms; + ContextType = contextType; + InjectedParams = injectedParams; + Parameters = parameters; + Priority = priority; + OptionalStrings = strings; + } + + public MarmaladePermAttribute[] UserAndBotPerms { get; set; } + + public CommandStrings OptionalStrings { get; set; } + + public IReadOnlyCollection Aliases { get; } + public MethodInfo MethodInfo { get; set; } + public Canary Module { get; set; } + public FilterAttribute[] Filters { get; set; } + public CommandContextType ContextType { get; } + public IReadOnlyList InjectedParams { get; } + public IReadOnlyList Parameters { get; } + public int Priority { get; } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Models/CanaryData.cs b/src/Ellie/Marmalade/Models/CanaryData.cs new file mode 100644 index 0000000..235f268 --- /dev/null +++ b/src/Ellie/Marmalade/Models/CanaryData.cs @@ -0,0 +1,11 @@ +namespace Ellie.Marmalade; + +public sealed record CanaryInfo( + string Name, + CanaryInfo? Parent, + Canary Instance, + IReadOnlyCollection Commands, + IReadOnlyCollection Filters) +{ + public List Subcanaries { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/Models/ParamData.cs b/src/Ellie/Marmalade/Models/ParamData.cs new file mode 100644 index 0000000..e329958 --- /dev/null +++ b/src/Ellie/Marmalade/Models/ParamData.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +public sealed record ParamData( + Type Type, + string Name, + bool IsOptional, + object? DefaultValue, + bool IsLeftover, + bool IsParams +); \ No newline at end of file diff --git a/src/Ellie/Marmalade/Models/ResolvedMarmalade.cs b/src/Ellie/Marmalade/Models/ResolvedMarmalade.cs new file mode 100644 index 0000000..7479ce5 --- /dev/null +++ b/src/Ellie/Marmalade/Models/ResolvedMarmalade.cs @@ -0,0 +1,16 @@ +using Ninject.Modules; +using System.Collections.Immutable; + +namespace Ellie.Marmalade; + +public sealed record ResolvedMarmalade( + WeakReference LoadContext, + IImmutableList ModuleInfos, + IImmutableList CanaryInfos, + IMarmaladeStrings Strings, + Dictionary TypeReaders, + IReadOnlyCollection Execs +) +{ + public required INinjectModule KernelModule { get; set; } +} \ No newline at end of file diff --git a/src/Ellie/Marmalade/RemovablePlanner.cs b/src/Ellie/Marmalade/RemovablePlanner.cs new file mode 100644 index 0000000..f48f15a --- /dev/null +++ b/src/Ellie/Marmalade/RemovablePlanner.cs @@ -0,0 +1,122 @@ +//------------------------------------------------------------------------------- +// +// Copyright (c) 2007-2009, Enkari, Ltd. +// Copyright (c) 2009-2011 Ninject Project Contributors +// Authors: Nate Kohari (nate@enkari.com) +// Remo Gloor (remo.gloor@gmail.com) +// +// Dual-licensed under the Apache License, Version 2.0, and the Microsoft Public License (Ms-PL). +// you may not use this file except in compliance with one of the Licenses. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// or +// http://www.microsoft.com/opensource/licenses.mspx +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//------------------------------------------------------------------------------- + +// ReSharper disable all +#pragma warning disable + +namespace Ninject.Planning; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Ninject.Components; +using Ninject.Infrastructure.Language; +using Ninject.Planning.Strategies; + +/// +/// Generates plans for how to activate instances. +/// +public class RemovablePlanner : NinjectComponent, IPlanner +{ + private readonly ReaderWriterLock plannerLock = new ReaderWriterLock(); + private readonly Dictionary plans = new Dictionary(); + + /// + /// Initializes a new instance of the class. + /// + /// The strategies to execute during planning. + public RemovablePlanner(IEnumerable strategies) + { + this.Strategies = strategies.ToList(); + } + + /// + /// Gets the strategies that contribute to the planning process. + /// + public IList Strategies { get; private set; } + + /// + /// Gets or creates an activation plan for the specified type. + /// + /// The type for which a plan should be created. + /// The type's activation plan. + public IPlan GetPlan(Type type) + { + this.plannerLock.AcquireReaderLock(Timeout.Infinite); + try + { + IPlan plan; + return this.plans.TryGetValue(type, out plan) ? plan : this.CreateNewPlan(type); + } + finally + { + this.plannerLock.ReleaseReaderLock(); + } + } + + /// + /// Creates an empty plan for the specified type. + /// + /// The type for which a plan should be created. + /// The created plan. + protected virtual IPlan CreateEmptyPlan(Type type) + { + return new Plan(type); + } + + /// + /// Creates a new plan for the specified type. + /// This method requires an active reader lock! + /// + /// The type. + /// The newly created plan. + private IPlan CreateNewPlan(Type type) + { + var lockCooki = this.plannerLock.UpgradeToWriterLock(Timeout.Infinite); + try + { + IPlan plan; + if (this.plans.TryGetValue(type, out plan)) + { + return plan; + } + + plan = this.CreateEmptyPlan(type); + this.plans.Add(type, plan); + this.Strategies.Map(s => s.Execute(plan)); + + return plan; + } + finally + { + this.plannerLock.DowngradeFromWriterLock(ref lockCooki); + } + } + + public void RemovePlan(Type type) + { + plans.Remove(type); + plans.TrimExcess(); + } +} \ No newline at end of file