From 9ae9d990d03cefc51580481cf798d27bfa4fb8bf Mon Sep 17 00:00:00 2001 From: MicroBlock <66859419+std-microblock@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:50:06 +0800 Subject: [PATCH] Add ILHookTransaction to batch per-target IL chain rebuilds Introduces an opt-in startup transaction that defers ILHook.Apply until a group of hooks can be committed together. Hooks targeting the same method are inserted into the hook graph in a single batch, so the source IL is cloned, every manipulator is replayed and the DynamicMethod is generated and JIT-compiled only once per target method instead of once per hook. - ILHook.Apply/Undo route through the transaction while it is active and IsApplied reflects the pending state. - DetourManager.AddILHooksBatch inserts a whole target group atomically and rolls the graph back if chain preparation fails. - Flush orders hooks by a stable (order, sequence) key and can serialize manipulators per owner key while running clone/generate/JIT outside the owner gate, preserving both per-mod and per-target hook ordering. Outside an active transaction, ILHook behavior is unchanged. --- .../DetourManager.Managed.cs | 127 ++++++-- src/MonoMod.RuntimeDetour/ILHook.cs | 14 +- .../ILHookTransaction.cs | 301 ++++++++++++++++++ 3 files changed, 407 insertions(+), 35 deletions(-) create mode 100644 src/MonoMod.RuntimeDetour/ILHookTransaction.cs diff --git a/src/MonoMod.RuntimeDetour/DetourManager.Managed.cs b/src/MonoMod.RuntimeDetour/DetourManager.Managed.cs index da514d30..f0d5a865 100644 --- a/src/MonoMod.RuntimeDetour/DetourManager.Managed.cs +++ b/src/MonoMod.RuntimeDetour/DetourManager.Managed.cs @@ -427,6 +427,49 @@ private void RemoveNoConfigDetour(SingleManagedDetourState detour, ManagedDetour internal readonly List noConfigIlhooks = new(); internal int ilhookVersion; + + private ILHookEntry InsertILHook(SingleILHookState ilhook) + { + if (ilhook.ManagerData is not null) + throw new InvalidOperationException("Trying to add an IL hook which was already added"); + + var entry = new ILHookEntry(ilhook); + ilhookVersion++; + if (entry.Config is { } cfg) + { + var listNode = new DepListNode(cfg, entry); + var graphNode = new DepGraphNode(listNode); + + ilhookGraph.Insert(graphNode); + ilhook.ManagerData = graphNode; + } + else + { + noConfigIlhooks.Add(entry); + ilhook.ManagerData = entry; + } + + return entry; + } + + private void RemoveInsertedILHook(SingleILHookState ilhook, ILHookEntry entry) + { + switch (Interlocked.Exchange(ref ilhook.ManagerData, null)) + { + case DepGraphNode graphNode: + ilhookGraph.Remove(graphNode); + break; + case ILHookEntry listEntry: + noConfigIlhooks.Remove(listEntry); + break; + case null: + break; + default: + throw new NotSupportedException("bad managerdata?"); + } + entry.Remove(); + } + public void AddILHook(SingleILHookState ilhook, bool takeLock = true) { ILHookEntry entry; @@ -435,25 +478,7 @@ public void AddILHook(SingleILHookState ilhook, bool takeLock = true) { if (takeLock) detourLock.Enter(ref lockTaken); - if (ilhook.ManagerData is not null) - throw new InvalidOperationException("Trying to add an IL hook which was already added"); - - entry = new ILHookEntry(ilhook); - ilhookVersion++; - if (entry.Config is { } cfg) - { - var listNode = new DepListNode(cfg, entry); - var graphNode = new DepGraphNode(listNode); - - ilhookGraph.Insert(graphNode); - - ilhook.ManagerData = graphNode; - } - else - { - noConfigIlhooks.Add(entry); - ilhook.ManagerData = entry; - } + entry = InsertILHook(ilhook); try { @@ -463,17 +488,7 @@ public void AddILHook(SingleILHookState ilhook, bool takeLock = true) catch { // the add failed, remove the node and re-update end of chain - switch (Interlocked.Exchange(ref ilhook.ManagerData, null)) - { - case DepGraphNode gn: - ilhookGraph.Remove(gn); - break; - case ILHookEntry cn: - noConfigIlhooks.Remove(cn); - break; - default: - throw new NotSupportedException("bad managerdata?"); - } + RemoveInsertedILHook(ilhook, entry); UpdateEndOfChain(); throw; } @@ -490,6 +505,45 @@ public void AddILHook(SingleILHookState ilhook, bool takeLock = true) InvokeILHookEvent(DetourManager.ILHookApplied, ILHookApplied, ilhook); } + internal void AddILHooksBatch(IReadOnlyList ilhooks, + Func? enterManipulatorGate = null) + { + if (ilhooks.Count == 0) + return; + + var added = new List<(SingleILHookState Hook, ILHookEntry Entry)>(ilhooks.Count); + var lockTaken = false; + try + { + detourLock.Enter(ref lockTaken); + foreach (var ilhook in ilhooks) + added.Add((ilhook, InsertILHook(ilhook))); + + try + { + PrepareEndOfChain(added[0].Hook.Factory); + UpdateEndOfChain(enterManipulatorGate); + UpdateChain(added[^1].Hook.Factory, out _); + } + catch + { + for (var index = added.Count - 1; index >= 0; index--) + RemoveInsertedILHook(added[index].Hook, added[index].Entry); + UpdateEndOfChain(enterManipulatorGate); + UpdateChain(added[0].Hook.Factory, out _); + throw; + } + } + finally + { + if (lockTaken) + detourLock.Exit(true); + } + + foreach (var (hook, _) in added) + InvokeILHookEvent(DetourManager.ILHookApplied, ILHookApplied, hook); + } + public void RemoveILHook(SingleILHookState ilhook, bool takeLock = true) { ILHookEntry entry; @@ -555,6 +609,9 @@ private void PrepareEndOfChain(IDetourFactory factory) } private void UpdateEndOfChain() + => UpdateEndOfChain(null); + + private void UpdateEndOfChain(Func? enterManipulatorGate) { Helpers.Assert(SourceClone is not null); @@ -578,13 +635,13 @@ private void UpdateEndOfChain() var cur = ilhookGraph.ListHead; while (cur is not null) { - InvokeManipulator(cur.ChainNode, def); + InvokeManipulator(cur.ChainNode, def, enterManipulatorGate); cur = cur.Next; } foreach (var node in noConfigIlhooks) { - InvokeManipulator(node, def); + InvokeManipulator(node, def, enterManipulatorGate); } var eoc = dmd.Generate(); @@ -597,13 +654,15 @@ private void UpdateEndOfChain() EndOfChain = eoc; } - private static void InvokeManipulator(ILHookEntry entry, MethodDefinition def) + private static void InvokeManipulator(ILHookEntry entry, MethodDefinition def, + Func? enterManipulatorGate = null) { //entry.LastContext?.Dispose(); // we can't safely clean up the old context until after we've updated the chain to point at the new method entry.IsApplied = true; var il = new ILContext(def); entry.CurrentContext = il; - il.Invoke(entry.Manip); + using (enterManipulatorGate?.Invoke(entry.Manip)) + il.Invoke(entry.Manip); if (il.IsReadOnly) { il.Dispose(); diff --git a/src/MonoMod.RuntimeDetour/ILHook.cs b/src/MonoMod.RuntimeDetour/ILHook.cs index de03f3a6..e5aa422e 100644 --- a/src/MonoMod.RuntimeDetour/ILHook.cs +++ b/src/MonoMod.RuntimeDetour/ILHook.cs @@ -5,6 +5,7 @@ using System; using System.Linq.Expressions; using System.Reflection; +using System.Threading; namespace MonoMod.RuntimeDetour { @@ -158,6 +159,13 @@ public ILHook(MethodBase source, ILContext.Manipulator manip, DetourConfig? conf private readonly DetourManager.ManagedDetourState state; private readonly DetourManager.SingleILHookState hook; + private int transactionPending; + + internal DetourManager.ManagedDetourState ManagedState => state; + internal DetourManager.SingleILHookState HookState => hook; + + internal void SetTransactionPending(bool pending) + => Volatile.Write(ref transactionPending, pending ? 1 : 0); /// /// Constructs an for the provided method using the provided manipulator and @@ -197,7 +205,7 @@ public ILHook(MethodBase method, ILContext.Manipulator manipulator, IDetourFacto /// /// Gets whether or not this is applied. /// - public bool IsApplied => hook.IsApplied; + public bool IsApplied => hook.IsApplied || Volatile.Read(ref transactionPending) != 0; /// /// Gets the for this . /// @@ -223,6 +231,8 @@ public void Apply() if (IsApplied) return; MMDbgLog.Trace($"Applying ILHook for {Method}"); + if (ILHookTransaction.TryQueue(this)) + return; state.AddILHook(hook, !lockTaken); } finally @@ -246,6 +256,8 @@ public void Undo() if (!IsApplied) return; MMDbgLog.Trace($"Undoing ILHook for {Method}"); + if (ILHookTransaction.TryCancel(this)) + return; state.RemoveILHook(hook, !lockTaken); } finally diff --git a/src/MonoMod.RuntimeDetour/ILHookTransaction.cs b/src/MonoMod.RuntimeDetour/ILHookTransaction.cs new file mode 100644 index 00000000..e1f63337 --- /dev/null +++ b/src/MonoMod.RuntimeDetour/ILHookTransaction.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MonoMod.RuntimeDetour +{ + /// + /// Defers a group of operations and commits all + /// hooks targeting the same method with a single IL-chain rebuild. + /// + /// + /// This is intended for controlled startup phases where many independent + /// components install hooks before the target methods can run. Outside an + /// active transaction, ILHook behavior is unchanged. + /// + public sealed class ILHookTransaction : IDisposable + { + private sealed class OrderContext + { + public readonly long Order; + public long Sequence; + + public OrderContext(long order) + { + Order = order; + } + } + + private sealed class PendingHook + { + public readonly ILHook Hook; + public readonly long Order; + public readonly long LocalSequence; + public readonly long GlobalSequence; + + public PendingHook(ILHook hook, long order, long localSequence, long globalSequence) + { + Hook = hook; + Order = order; + LocalSequence = localSequence; + GlobalSequence = globalSequence; + } + } + + private sealed class Scope : IDisposable + { + private readonly OrderContext? previous; + private int disposed; + + public Scope(OrderContext? previous) + { + this.previous = previous; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) == 0) + currentOrder.Value = previous; + } + } + + private sealed class MonitorScope : IDisposable + { + private object? gate; + + public MonitorScope(object gate) + { + this.gate = gate; + Monitor.Enter(gate); + } + + public void Dispose() + { + var value = Interlocked.Exchange(ref gate, null); + if (value is not null) + Monitor.Exit(value); + } + } + + private static readonly object transactionLock = new(); + private static readonly AsyncLocal currentOrder = new(); + private static readonly ConcurrentDictionary pendingOwners = new(); + private static ILHookTransaction? activeTransaction; + + private readonly ConcurrentDictionary pending = new(); + private readonly ConcurrentDictionary ownerGates = new(); + private readonly object unknownOwnerGate = new(); + private long globalSequence; + private int state; + + private ILHookTransaction() + { + } + + /// + /// Starts the process-wide IL-hook transaction. + /// + public static ILHookTransaction Begin() + { + lock (transactionLock) + { + if (activeTransaction is not null) + throw new InvalidOperationException("An ILHook transaction is already active"); + + var transaction = new ILHookTransaction(); + Volatile.Write(ref activeTransaction, transaction); + return transaction; + } + } + + /// + /// Associates subsequently queued hooks on the current execution context + /// with a stable ordering key. Hooks with the same key preserve call order. + /// + public static IDisposable EnterOrder(long order) + { + var previous = currentOrder.Value; + currentOrder.Value = new OrderContext(order); + return new Scope(previous); + } + + /// + /// Gets the number of hooks which are still waiting to be committed. + /// + public int PendingCount => pending.Count; + + internal static bool TryQueue(ILHook hook) + { + var transaction = Volatile.Read(ref activeTransaction); + if (transaction is null || Volatile.Read(ref transaction.state) != 0) + return false; + + var order = currentOrder.Value; + var item = new PendingHook( + hook, + order?.Order ?? long.MaxValue, + order is null ? 0 : Interlocked.Increment(ref order.Sequence), + Interlocked.Increment(ref transaction.globalSequence) + ); + + if (!transaction.pending.TryAdd(hook, item)) + return true; + if (!pendingOwners.TryAdd(hook, transaction)) + { + transaction.pending.TryRemove(hook, out _); + return false; + } + + hook.SetTransactionPending(true); + return true; + } + + internal static bool TryCancel(ILHook hook) + { + if (!pendingOwners.TryGetValue(hook, out var transaction)) + return false; + if (!transaction.pending.TryRemove(hook, out _)) + return false; + + pendingOwners.TryRemove(hook, out _); + hook.SetTransactionPending(false); + return true; + } + + /// + /// Commits every queued hook. Each target method is rebuilt only once. + /// Target methods may be committed concurrently. Manipulators with the + /// same owner key are always executed serially; different owners may run + /// concurrently, while clone, generation and JIT work remain outside of + /// the owner gate. + /// + /// The number of hooks and target methods committed. + [CLSCompliant(false)] + public (int Hooks, int Targets) Flush(int maxDegreeOfParallelism = 1, + Func? ownerSelector = null) + { + if (maxDegreeOfParallelism < 1) + throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism)); + + lock (transactionLock) + { + if (!ReferenceEquals(activeTransaction, this) || Interlocked.CompareExchange(ref state, 1, 0) != 0) + throw new InvalidOperationException("The ILHook transaction is not active"); + Volatile.Write(ref activeTransaction, null); + } + + var snapshot = pending.Values + .OrderBy(item => item.Order) + .ThenBy(item => item.LocalSequence) + .ThenBy(item => item.GlobalSequence) + .ToArray(); + + var groups = new List>(); + var byState = new Dictionary>(); + foreach (var item in snapshot) + { + if (!byState.TryGetValue(item.Hook.ManagedState, out var group)) + { + group = new List(); + byState.Add(item.Hook.ManagedState, group); + groups.Add(group); + } + group.Add(item); + } + + var hooksCommitted = 0; + var targetsCommitted = 0; + try + { + void CommitGroup(List group) + { + var hooks = new List(group.Count); + foreach (var item in group) + { + if (!pending.TryRemove(item.Hook, out _)) + continue; + pendingOwners.TryRemove(item.Hook, out _); + hooks.Add(item.Hook); + } + + if (hooks.Count == 0) + return; + + try + { + hooks[0].ManagedState.AddILHooksBatch( + hooks.Select(hook => hook.HookState).ToArray(), + manipulator => EnterManipulatorGate(manipulator, ownerSelector) + ); + Interlocked.Add(ref hooksCommitted, hooks.Count); + Interlocked.Increment(ref targetsCommitted); + } + finally + { + foreach (var hook in hooks) + hook.SetTransactionPending(false); + } + } + + if (maxDegreeOfParallelism == 1) + { + foreach (var group in groups) + CommitGroup(group); + } + else + { + Parallel.ForEach(groups, new ParallelOptions { + MaxDegreeOfParallelism = maxDegreeOfParallelism + }, CommitGroup); + } + + Volatile.Write(ref state, 2); + return (hooksCommitted, targetsCommitted); + } + finally + { + foreach (var item in pending.Keys) + { + pendingOwners.TryRemove(item, out _); + item.SetTransactionPending(false); + } + pending.Clear(); + if (Volatile.Read(ref state) != 2) + Volatile.Write(ref state, 3); + } + } + + private IDisposable EnterManipulatorGate(MonoMod.Cil.ILContext.Manipulator manipulator, + Func? ownerSelector) + { + var owner = ownerSelector?.Invoke(manipulator) ?? manipulator.Method.DeclaringType?.Assembly; + var gate = owner is null ? unknownOwnerGate : ownerGates.GetOrAdd(owner, static _ => new object()); + return new MonitorScope(gate); + } + + /// + /// Cancels any hooks which have not yet been committed. + /// + public void Dispose() + { + lock (transactionLock) + { + if (ReferenceEquals(activeTransaction, this)) + Volatile.Write(ref activeTransaction, null); + } + + if (Interlocked.CompareExchange(ref state, 3, 0) != 0) + return; + + foreach (var hook in pending.Keys) + { + pendingOwners.TryRemove(hook, out _); + hook.SetTransactionPending(false); + } + pending.Clear(); + } + } +}