Files
sbox-public/engine/Sandbox.Event/EventSystem.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

474 lines
11 KiB
C#

using Sandbox.Diagnostics;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Sandbox.Internal;
internal class EventSystem : IDisposable
{
Logger log = new Logger( "EventSystem" );
public EventSystem()
{
}
/// <summary>
/// A Type with events on it
/// </summary>
public class EventClass
{
public string Assembly;
public Type Type;
public List<EventAction> Events = new();
public List<object> Targets = new();
public void Destroy()
{
Assembly = null;
Type = null;
Targets.Clear();
foreach ( var e in Events.ToArray() )
{
e.Destroy();
}
Assert.AreEqual( 0, Events.Count() );
}
}
/// <summary>
/// A method on a type
/// </summary>
public class EventAction : IComparable<EventAction>
{
public int Priority;
public EventClass Class;
public EventList Group;
public EventDelegate Delegate;
public bool IsStatic;
public int CompareTo( EventAction other )
{
return other.Priority.CompareTo( Priority );
}
public void Destroy()
{
Class.Events.Remove( this );
Group.Remove( this );
}
/// <summary>
/// Run this event action, aggregating any exceptions.
/// </summary>
internal void Run( string name, object[] args = null )
{
if ( IsStatic )
{
try
{
Delegate( null, args );
return;
}
catch ( Exception ex )
{
throw new TargetInvocationException( $"Error calling event '{name}' on '{Class.Type}'", ex.InnerException ?? ex );
}
}
List<Exception> innerExceptions = null;
for ( int i = Class.Targets.Count - 1; i >= 0; i-- )
{
var target = Class.Targets[i];
try
{
Delegate( target, args );
}
catch ( Exception ex )
{
innerExceptions ??= new();
innerExceptions.Add( new TargetInvocationException( $"Error calling event '{name}' on '{target}'", ex.InnerException ?? ex ) );
}
}
switch ( innerExceptions )
{
case { Count: 1 }:
throw innerExceptions[0];
case { Count: > 1 }:
throw new AggregateException( innerExceptions.ToArray() );
}
}
}
/// <summary>
/// A list of events, usually indexed by the event name
/// </summary>
public class EventList : List<EventAction>
{
/// <summary>
/// Run this event list, aggregating any exceptions.
/// </summary>
internal void Run( string name, object[] args = null )
{
List<Exception> innerExceptions = null;
for ( int i = Count - 1; i >= 0; i-- )
{
var action = this[i];
try
{
action.Run( name, args );
}
catch ( Exception ex )
{
innerExceptions ??= new();
innerExceptions.Add( new TargetInvocationException( $"Error calling event '{name}' on '{action.Class.Type}'", ex.InnerException ?? ex ) );
}
}
switch ( innerExceptions )
{
case { Count: 1 }:
throw innerExceptions[0];
case { Count: > 1 }:
throw new AggregateException( innerExceptions.ToArray() );
}
}
}
public delegate void EventDelegate( object root, object[] parms );
Dictionary<string, EventClass> Classes = new();
Dictionary<string, EventList> Groups = new( StringComparer.OrdinalIgnoreCase );
/// <summary>
/// Instances that have had their assembly removed. We keep them around becuase the
/// assembly might be re-registered.
/// </summary>
HashSet<object> OrphanedInstances = new();
string TypeKey( Type t )
{
return $"{t.Assembly.GetName().Name}/{t.FullName}";
}
void AddEventsForType( Type t, Type rootType = null )
{
EventClass classEvent = null;
foreach ( var m in t.GetMethods( BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic ) )
{
if ( rootType != null && !m.IsPrivate ) continue;
foreach ( var attr in m.GetCustomAttributes<EventAttribute>( true ) )
{
if ( classEvent == null )
{
var typeKey = TypeKey( rootType ?? t );
if ( !Classes.TryGetValue( typeKey, out classEvent ) )
{
classEvent = new EventClass { Assembly = (rootType ?? t).Assembly.GetName().Name, Type = rootType ?? t };
Classes[typeKey] = classEvent;
}
}
log.Trace( $" {t} -> {m} - {attr}" );
var group = GetGroup( attr.EventName, true );
var eventAction = new EventAction
{
Priority = attr.Priority,
Class = classEvent,
Group = group,
Delegate = BuildDelegate( m ),
IsStatic = m.IsStatic
};
classEvent.Events.Add( eventAction );
eventAction.Group.Add( eventAction );
group.Sort();
}
}
if ( t.BaseType is not null && t.BaseType != typeof( object ) )
AddEventsForType( t.BaseType, rootType ?? t );
}
EventDelegate BuildDelegate( MethodInfo info )
{
// TODO - handle arguments
var parameters = info.GetParameters();
object[] args = default;
if ( parameters.Length > 0 )
{
args = new object[parameters.Length];
}
EventDelegate d = ( o, p ) =>
{
if ( args != null )
{
if ( p == null )
throw new ArgumentException( $"{info.Name} expects {args.Length} arguments but event passed none" );
if ( p.Length != args.Length )
throw new ArgumentException( $"Event passed {p.Length} arguments but {info.Name} expects {args.Length}" );
for ( int i = 0; i < args.Length; i++ )
{
args[i] = p[i];
}
}
else
{
if ( p != null && p.Length != 0 )
throw new ArgumentException( $"Event passed {p.Length} arguments but {info.Name} expects none" );
}
info?.Invoke( o, args );
};
return d;
}
internal EventList GetGroup( string name, bool create )
{
name = name.ToLower(); // TODO - hotload bug makes dict case senstitive
if ( Groups.TryGetValue( name, out var group ) )
return group;
if ( !create )
return null;
group = new EventList();
Groups.Add( name, group );
return group;
}
/// <summary>
/// Register an assembly. If old assembly is valid, we try to remove all of the old event hooks
/// from this assembly, while retaining a list of objects.
/// </summary>
internal void UnregisterAssembly( Assembly assm )
{
log.Trace( $"Removing {assm}" );
// collect instances
string assemblyName = assm.GetName().Name;
Dictionary<string, EventClass> classes = Classes.Where( x => x.Value.Assembly == assemblyName ).ToDictionary( x => x.Key, x => x.Value );
log.Trace( $"Found {classes.Count()} Types" );
object[] instances = classes.SelectMany( x => x.Value.Targets ).ToArray();
foreach ( var instance in instances )
{
OrphanedInstances.Add( instance );
}
log.Trace( $"Found {instances.Count()} Instances" );
foreach ( var clss in classes )
{
clss.Value.Destroy();
Classes.Remove( clss.Key );
}
}
/// <summary>
/// Register an assembly. If old assembly is valid, we try to remove all of the old event hooks
/// from this assembly, while retaining a list of objects.
/// </summary>
internal void RegisterAssembly( Assembly assm )
{
log.Trace( $"Registering Event System: [{assm}]" );
if ( assm == null )
return;
var assemblyName = assm.GetName().Name;
EventClass[] classes = Classes.Values.Where( x => x.Assembly == assemblyName ).ToArray();
Assert.AreEqual( 0, classes.Length, "Register Assembly - event already contains this dll!" );
var newTypes = assm.GetTypes();
foreach ( var type in newTypes )
{
Classes.TryGetValue( TypeKey( type ), out EventClass classEvent );
Assert.IsNull( classEvent );
AddEventsForType( type );
}
foreach ( var instance in OrphanedInstances.ToArray() )
{
// instance could have turned null because hotload lost the type
if ( instance is null )
{
OrphanedInstances.Remove( instance );
return;
}
if ( instance.GetType().Assembly == assm )
{
log.Trace( $"Re-registering orphan: {instance}" );
Register( instance );
OrphanedInstances.Remove( instance );
continue;
}
// If the assembly didn't match - then if the assembly has the same name
// it could be a sign of trouble - because all the instances should have
// been swapped to the new assembly type
if ( instance.GetType().Assembly.GetName().Name == assemblyName )
{
log.Warning( $"instance {instance} is from {instance.GetType().Assembly} - but doesn't match {assm} - should this have been hotload swapped?" );
}
}
}
internal void Run( string v )
{
try
{
GetGroup( v, false )?.Run( v );
}
catch ( Exception ex )
{
log.Error( ex );
}
}
internal void Run( string v, params object[] list )
{
try
{
GetGroup( v, false )?.Run( v, list );
}
catch ( Exception ex )
{
log.Error( ex );
}
}
WeakHashSet<object> AllTargets = new WeakHashSet<object>();
internal void RunInterface<T>( Action<T> t )
{
foreach ( var e in AllTargets.OfType<T>().ToArray() )
{
t.Invoke( e );
}
}
internal void Register( object obj )
{
AllTargets.Add( obj );
if ( !Classes.TryGetValue( TypeKey( obj.GetType() ), out var type ) )
return;
type.Targets.Add( obj );
}
internal void Unregister( object obj )
{
AllTargets.Remove( obj );
if ( OrphanedInstances.Remove( obj ) )
return;
if ( !Classes.TryGetValue( TypeKey( obj.GetType() ), out var type ) )
return;
type.Targets.Remove( obj );
}
public void Dispose()
{
Classes.Clear();
Classes = null;
Groups.Clear();
Groups = null;
}
}
#nullable enable
internal sealed class WeakHashSet<T> : IEnumerable<T>
where T : class
{
// TODO: We're only accessing items in this with OfType<T2>, should we index by type?
// TODO: T is always object, can we simplify?
private readonly ConditionalWeakTable<T, object?> _weakTable = new();
private int _lastGcCount;
public void Add( T item )
{
ClearCaches();
_weakTable.AddOrUpdate( item, null );
}
public bool Contains( T item ) => _weakTable.TryGetValue( item, out _ );
public bool Remove( T item )
{
ClearCaches();
return _weakTable.Remove( item );
}
#region IEnumerable
private readonly object _lock = new();
private WeakReference<IEnumerable<T>>? _liveItemCache;
private void ClearCaches() => _liveItemCache = null;
public IEnumerator<T> GetEnumerator()
{
// Enumerating using _weakTable.Select( x => x.Key ) is quite slow, so we cache it.
// The cache is invalidated:
// 1) Explicitly when items are added / removed
// 2) Implicitly when a GC happens
var gcCount = GC.CollectionCount( 0 );
if ( _lastGcCount == gcCount && _liveItemCache?.TryGetTarget( out var items ) is true )
{
return items.GetEnumerator();
}
lock ( _lock )
{
_lastGcCount = gcCount;
_liveItemCache = new WeakReference<IEnumerable<T>>( items = _weakTable.Select( x => x.Key ).ToArray() );
}
return items.GetEnumerator();
}
IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#endregion
}