From 5dbe0a5f318cb72a6cf9130997886a489128f76f Mon Sep 17 00:00:00 2001 From: Edward Brey Date: Wed, 27 Aug 2025 10:58:47 -0500 Subject: [PATCH 1/2] Fix event subscriptions for certain parameter types on .NET 9. --- Distribution/wwDotnetBridge.PRG | 25 ++++++++--------------- DotnetBridge/Utilities/EventSubscriber.cs | 7 +++++-- Tests/EventSubscriberTests.cs | 4 ++-- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Distribution/wwDotnetBridge.PRG b/Distribution/wwDotnetBridge.PRG index 92e1af2..8040fac 100644 --- a/Distribution/wwDotnetBridge.PRG +++ b/Distribution/wwDotnetBridge.PRG @@ -1838,34 +1838,25 @@ this.oBridge.InvokeMethodAsync(this,this.oSubscriber,"WaitForEvent") ENDFUNC ************************************************************************ -* OnComplete +* OnCompleted **************************************** *** Function: Event Proxy that forwards the event to a function *** named On{Event} with event's parameters. ************************************************************************ FUNCTION OnCompleted(lvResult, lcMethod) -LOCAL loParams,lParamText,lCount +LOCAL lParamText, lCount -IF ISNULL(lvResult) && If the call to WaitForEvent was canceled: +IF ISNULL(lvResult) OR VARTYPE(THIS.oHandler) != "O" && If the call to WaitForEvent was canceled or the handler was unsubscribed: RETURN ENDIF - -loParams=CREATEOBJECT("EMPTY") && Workaround to index into array of parameters. lParamText = "" -IF NOT ISNULL(lvResult.Params) - lCount = 0 - FOR EACH lParam IN lvResult.Params - lCount = lCount + 1 - AddProperty(loParams,"P" + ALLTRIM(STR(lCount)),lParam) - lParamText = lParamText + ",loParams.P" + ALLTRIM(STR(lCount)) - ENDFOR -ENDIF +FOR lCount = 0 TO lvResult.Params.Count - 1 + lParamText = lParamText + ",lvResult.Params.Item(" + ALLTRIM(STR(lCount)) + ")" +ENDFOR -IF VARTYPE(THIS.oHandler) = "O" - =EVALUATE("this.oHandler." + this.oPrefix + lvResult.Name + "("+SUBSTR(lParamText,2)+")") - this.HandleNextEvent() -ENDIF +=EVALUATE("this.oHandler." + this.oPrefix + lvResult.Name + "("+SUBSTR(lParamText,2)+")") +this.HandleNextEvent() ENDFUNC diff --git a/DotnetBridge/Utilities/EventSubscriber.cs b/DotnetBridge/Utilities/EventSubscriber.cs index 2e134c1..264fa36 100644 --- a/DotnetBridge/Utilities/EventSubscriber.cs +++ b/DotnetBridge/Utilities/EventSubscriber.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -69,7 +70,9 @@ public void Dispose() private void QueueInteropEvent(string name, object[] parameters) { - var interopEvent = new RaisedEvent { Name = name, Params = parameters }; + var parametersArrayList = new ArrayList(parameters.Length); + parametersArrayList.AddRange(parameters); + var interopEvent = new RaisedEvent { Name = name, Params = parametersArrayList }; if (!_completion.TrySetResult(interopEvent)) _raisedEvents.Enqueue(interopEvent); } @@ -93,6 +96,6 @@ public RaisedEvent WaitForEvent() public class RaisedEvent { public string Name { get; internal set; } - public object[] Params { get; internal set; } + public ArrayList Params { get; internal set; } } } diff --git a/Tests/EventSubscriberTests.cs b/Tests/EventSubscriberTests.cs index 7ce016b..f5b3d14 100644 --- a/Tests/EventSubscriberTests.cs +++ b/Tests/EventSubscriberTests.cs @@ -35,9 +35,9 @@ public void EventSubscriber_WaitForFutureEvents() static void VerifyResults(EventSubscriber subscriber) { var result = subscriber.WaitForEvent(); - Assert.IsTrue(result.Name == nameof(Loopback.NoParams) && result.Params.Length == 0); + Assert.IsTrue(result.Name == nameof(Loopback.NoParams) && result.Params.Count == 0); result = subscriber.WaitForEvent(); - Assert.IsTrue(result.Name == nameof(Loopback.TwoParams) && result.Params.Length == 2 && (string)result.Params[0] == "A" && (int)result.Params[1] == 1); + Assert.IsTrue(result.Name == nameof(Loopback.TwoParams) && result.Params.Count == 2 && (string)result.Params[0] == "A" && (int)result.Params[1] == 1); } } From 9599747ac9238553845e1407cff6fa7a5570b9e3 Mon Sep 17 00:00:00 2001 From: Edward Brey Date: Wed, 29 Oct 2025 22:41:05 -0500 Subject: [PATCH 2/2] Fix event subscription memory leak and race condition. EventSubscriber now accounts for Dispose, QueueInteropEvent, and WaitForEvent all being called simultaneously from different threads. EventSubscriber contains a workaround for the likelihood that even with the concurrency improvement, there is still a race condition. --- Distribution/wwDotnetBridge.PRG | 3 +- DotnetBridge/Utilities/EventSubscriber.cs | 47 ++++++++++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Distribution/wwDotnetBridge.PRG b/Distribution/wwDotnetBridge.PRG index 8040fac..fb380f2 100644 --- a/Distribution/wwDotnetBridge.PRG +++ b/Distribution/wwDotnetBridge.PRG @@ -1846,7 +1846,8 @@ ENDFUNC FUNCTION OnCompleted(lvResult, lcMethod) LOCAL lParamText, lCount -IF ISNULL(lvResult) OR VARTYPE(THIS.oHandler) != "O" && If the call to WaitForEvent was canceled or the handler was unsubscribed: +IF VARTYPE(lvResult) != "O" OR VARTYPE(THIS.oHandler) != "O" && If the call to WaitForEvent was canceled or the handler was unsubscribed: + * Note that lvResult is sometimes an empty string. This is likely the result of a race condition. Fortunately, checking whether it is an object seems to be a reliable workaround. RETURN ENDIF diff --git a/DotnetBridge/Utilities/EventSubscriber.cs b/DotnetBridge/Utilities/EventSubscriber.cs index 264fa36..82adf47 100644 --- a/DotnetBridge/Utilities/EventSubscriber.cs +++ b/DotnetBridge/Utilities/EventSubscriber.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Westwind.WebConnection @@ -19,13 +20,10 @@ public sealed class EventSubscriber : IDisposable private readonly object _source; private readonly List _eventHandlers = new List(); private readonly ConcurrentQueue _raisedEvents = new ConcurrentQueue(); - private TaskCompletionSource _completion = new TaskCompletionSource(); + private volatile SemaphoreSlim _signal = new SemaphoreSlim(0); public EventSubscriber(object source, String prefix = "", dynamic vfp = null) { - // Indicates that initially the client is not waiting. - _completion.SetResult(null); - // For each event, adds a handler that calls QueueInteropEvent. _source = source; foreach (var ev in source.GetType().GetEvents()) @@ -63,18 +61,37 @@ public DelegateInfo(Delegate handler, EventInfo eventInfo) public void Dispose() { + if (_signal == null) + return; + + // Unsubscribe from all events. foreach (var item in _eventHandlers) item.EventInfo.RemoveEventHandler(_source, item.Delegate); - _completion.TrySetCanceled(); + + // Remove references to objects. + _eventHandlers.Clear(); + while (_raisedEvents.TryDequeue(out _)) { } + + // Release any waiting thread and dispose the semaphore. + _signal.Release(); + _signal.Dispose(); + _signal = null; } private void QueueInteropEvent(string name, object[] parameters) { + var signal = _signal; + if (signal == null) + return; + + // Push event to queue var parametersArrayList = new ArrayList(parameters.Length); parametersArrayList.AddRange(parameters); var interopEvent = new RaisedEvent { Name = name, Params = parametersArrayList }; - if (!_completion.TrySetResult(interopEvent)) - _raisedEvents.Enqueue(interopEvent); + _raisedEvents.Enqueue(interopEvent); + + // Release waiting thread + signal.Release(); } /// @@ -83,13 +100,15 @@ private void QueueInteropEvent(string name, object[] parameters) /// The next event, or null if this subscriber has been disposed. public RaisedEvent WaitForEvent() { - if (_raisedEvents.TryDequeue(out var interopEvent)) return interopEvent; - _completion = new TaskCompletionSource(); - var task = _completion.Task; - - task.Wait(); - - return task.IsCanceled ? null : task.Result; + var signal = _signal; + if (signal == null) + return null; + + // Wait and return the event. + // Since signal keeps a count, there will always be exactly one event to dequeue, except when the subscriber is disposed, in which case there will be none. + try { signal.Wait(); } catch (ObjectDisposedException) { } + _raisedEvents.TryDequeue(out var interopEvent); + return interopEvent; } }