From a878e7c637ccce2915f5c12fc4c837d967c1b7a9 Mon Sep 17 00:00:00 2001 From: Edward Brey Date: Wed, 5 Nov 2025 09:18:20 -0600 Subject: [PATCH 1/2] Add synchronization context. * Supports posting methods to the synchronization context to be dispatched from the message loop on the main thread. * Events are forwarded directly to the handler, except that events from background threads or configured to post are posted to the synchronization context. * When a task method completes, the status is posted on the synchronization context. * C# async/await uses the synchronization context by default, causing continuations to run on the main thread. .NET code that can run safely on a background thread can opt to do so with `ConfigureAwait(false)`. This commit eliminates background thread calls into FoxPro and C# continuations, avoiding race conditions and data tearing, and providing a deterministic runtime environment. --- Distribution/wwDotnetBridge.PRG | 231 +++++++++++++----- DotnetBridge/Utilities/EventSubscriber.cs | 144 +++++------ .../Utilities/FoxProSynchronizationContext.cs | 70 ++++++ DotnetBridge/wwDotNetBridge.cs | 87 +++++-- DotnetBridge/wwDotNetBridge.csproj | 1 + Tests/EventSubscriberTests.cs | 51 ++-- Tests/wwDotnetBridge.Tests.csproj | 1 + 7 files changed, 387 insertions(+), 198 deletions(-) create mode 100644 DotnetBridge/Utilities/FoxProSynchronizationContext.cs diff --git a/Distribution/wwDotnetBridge.PRG b/Distribution/wwDotnetBridge.PRG index af61a87..c3a443a 100644 --- a/Distribution/wwDotnetBridge.PRG +++ b/Distribution/wwDotnetBridge.PRG @@ -26,6 +26,9 @@ SET PROCEDURE TO wwDotnetBridge ADDITIVE * wwDotnetBridge.dll #ENDIF +* Used by EventSubscription to determine which events to subscribe to. +PUBLIC wwDotnetBridgeEventHandler + ************************************************************************ * GetwwDotNetBridge @@ -67,7 +70,6 @@ FUNCTION InitializeDotnetVersion(lcVersion,llUseCom) RETURN GetwwDotnetBridge(lcVersion,llUseCom) ENDFUNC - ************************************************************* DEFINE CLASS wwDotNetBridge AS Custom ************************************************************* @@ -224,12 +226,37 @@ IF VARTYPE(this.oDotNetBridge) != "O" this.oDotNetBridge.LoadAssembly("System") this.oDotNetBridge.IsThrowOnErrorEnabled = this.lThrowOnError + this.SetSynchronizationContext() ENDIF RETURN this.oDotNetBridge ENDFUNC * CreateDotNetBridge + +************************************************************************ +* SetSynchronizationContext +**************************************** +PROTECTED FUNCTION SetSynchronizationContext() +LOCAL postMessageId +postMessageId = this.oDotNetBridge.SetSynchronizationContext(_VFP.hWnd) +BINDEVENT(_VFP.hWnd, postMessageId, this, "Dispatch") +ENDFUNC +* SetSynchronizationContext + + +************************************************************************ +* Dispatch +**************************************** +*** Function: Dispatches all queued send or post callbacks in the synchronization context. +*** Return: nothing +************************************************************************ +FUNCTION Dispatch(hWnd, nMsg, wParam, lParam) && Windows message handler signature +this.oDotNetBridge.Dispatch() +ENDFUNC +* Dispatch + + ************************************************************************ * SetClrVersion **************************************** @@ -481,6 +508,66 @@ ENDFUNC * InvokeMethod +************************************************************************ +* PostMethod +**************************************** +*** Function: Posts a .NET instance method to the FoxPro message queue. Useful to avoid reentrancy in event handlers. +*** Return: nothing +************************************************************************ +FUNCTION PostMethod(loObject, lcMethod, lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,; + lvParm6, lvParm7, lvParm8, lvParm9, lvParm10) +TRY + this.oDotNetBridge.PostInvokedMethods = .T. + this.SetError() + + LOCAL loBridge, lnParms + loBridge = this.oDotNetBridge + lnParms = PCOUNT() + DO CASE + CASE lnParms = 2 + loBridge.InvokeMethod(loObject, lcMethod) + CASE lnParms = 3 + loBridge.InvokeMethod_OneParm(loObject, lcMethod, lvParm1) + CASE lnParms = 4 + loBridge.InvokeMethod_TwoParms(loObject, lcMethod,lvParm1, lvParm2) + CASE lnParms = 5 + loBridge.InvokeMethod_ThreeParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3) + CASE lnParms = 6 + loBridge.InvokeMethod_FourParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4) + CASE lnParms = 7 + loBridge.InvokeMethod_FiveParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5) + CASE lnParms = 8 + loBridge.InvokeMethod_SixParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5, lvParm6) + CASE lnParms = 9 + loBridge.InvokeMethod_SevenParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5, lvParm6, lvParm7) + CASE lnParms = 10 + loBridge.InvokeMethod_EightParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5, lvParm6, lvParm7, lvParm8) + CASE lnParms = 11 + loBridge.InvokeMethod_NineParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5, lvParm6, lvParm7, lvParm8, lvParm9) + CASE lnParms = 12 + loBridge.InvokeMethod_TenParms(loObject, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5, lvParm6, lvParm7, lvParm8, lvParm9, lvParm10) + + OTHERWISE + LOCAL loArray as Westwind.WebConnection.ComArray + loArray = this.CreateArray("System.Object") + + LOCAL lvParm + FOR lnX = 1 TO lnParms-2 + lvParm = EVALUATE("lvParm" + TRANSFORM(lnX)) + loArray.AddItem(lvParm) + ENDFOR + ENDCASE + + IF loBridge.Error + this.SetError(loBridge.ErrorMessage) + ENDIF +FINALLY + this.oDotNetBridge.PostInvokedMethods = .F. +ENDTRY +ENDFUNC +* PostMethod + + ************************************************************************ * InvokeMethod_ParameterArray **************************************** @@ -769,7 +856,7 @@ ENDFUNC ************************************************************************ * InvokeStaticMethod **************************************** -*** Function: Calls a static .NET method with up to 5 parameters +*** Function: Calls a static .NET method with up to 10 parameters *** Assume: *** Pass: *** Return: @@ -819,6 +906,57 @@ RETURN loResult ENDFUNC * InvokeStaticMethod + +************************************************************************ +* PostStaticMethod +**************************************** +*** Function: Posts a static .NET method with up to 10 parameters to the FoxPro message queue. Useful to avoid reentrancy in event handlers. +*** Return: nothing +************************************************************************ +FUNCTION PostStaticMethod(lcTypeName, lcMethod, lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,; + lvParm6, lvParm7, lvParm8, lvParm9, lvParm10) +TRY + this.oDotNetBridge.PostInvokedMethods = .T. + this.SetError() + + LOCAL loBridge, lnParms + loBridge = this.oDotNetBridge + lnParms = PCOUNT() + DO CASE + CASE lnParms = 3 + loBridge.InvokeStaticMethod_OneParm(lcTypeName, lcMethod, lvParm1) + CASE lnParms = 4 + loBridge.InvokeStaticMethod_TwoParms(lcTypeName, lcMethod,lvParm1, lvParm2) + CASE lnParms = 5 + loBridge.InvokeStaticMethod_ThreeParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3) + CASE lnParms = 6 + loBridge.InvokeStaticMethod_FourParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4) + CASE lnParms = 7 + loBridge.InvokeStaticMethod_FiveParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5) + CASE lnParms = 8 + loBridge.InvokeStaticMethod_SixParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,lvParm6) + CASE lnParms = 9 + loBridge.InvokeStaticMethod_SevenParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,lvParm6, lvParm7) + CASE lnParms = 10 + loBridge.InvokeStaticMethod_EightParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,lvParm6, lvParm7, lvParm8) + CASE lnParms = 11 + loBridge.InvokeStaticMethod_NineParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,lvParm6, lvParm7, lvParm8, lvParm9) + CASE lnParms = 12 + loBridge.InvokeStaticMethod_TenParms(lcTypeName, lcMethod,lvParm1, lvParm2, lvParm3, lvParm4, lvParm5,lvParm6, lvParm7, lvParm8, lvParm9, lvParm10) + OTHERWISE + loBridge.InvokeStaticMethod(lcTypeName, lcMethod) + ENDCASE + + IF loBridge.Error + this.SetError(loBridge.ErrorMessage) + ENDIF +FINALLY + this.oDotNetBridge.PostInvokedMethods = .F. +ENDTRY +ENDFUNC +* PostStaticMethod + + ************************************************************************ * GetStaticProperty **************************************** @@ -1154,20 +1292,23 @@ ENDFUNC ************************************************************************ * SubscribeToEvents **************************************** -*** Function: Handles all events of a source object for subsequent retrieval by calling WaitForEvent. +*** Function: Handles all events of a source object. *** loSource: The object for which to subscribe to events. -*** loHandler: An object with a method OnEvent(loEventName, loParams). +*** loHandler: An object with handler methods corresponding to each event to subscribe to. *** lcPrefix: The initial part of the event handler function for each event. Defaults to "On". -*** Return: A subscription object. The subscription ends when this object goes out of scope. +*** llPost: Events are posted to the synchronization context if on a background thread or if llPost is true. It is useful to set llPost to true if your event handlers call back into an event source that does not support reentrancy. +*** Return: A subscription object. The subscription ends when it is no longer reference or when Unsubscribe() is called. ************************************************************************ -FUNCTION SubscribeToEvents(loSource, loHandler, lcPrefix) +FUNCTION SubscribeToEvents(loSource, loHandler, lcPrefix, llPost) IF VARTYPE(lcPrefix) # "C" lcPrefix = "On" ENDIF -LOCAL loSubscription -loSubscription = CREATEOBJECT("EventSubscription") -loSubscription.Setup(this, loSource, loHandler, lcPrefix) -RETURN loSubscription + +m.wwDotnetBridgeEventHandler = loHandler +LOCAL subscription +subscription = CREATEOBJECT("EventSubscription", this, loSource, loHandler, lcPrefix, llPost) +m.wwDotnetBridgeEventHandler = null +RETURN subscription ENDFUNC * SubscribeToEvents @@ -1824,17 +1965,12 @@ DEFINE CLASS EventSubscription as AsyncCallbackEvents *: Author: Edward Brey - https://github.com/breyed *: Usage: Used internally by SubscribeToEvents ************************************************************ -HIDDEN oBridge, oHandler, oSubscriber, oPrefix - -oBridge = null -oHandler = null -oPrefix = null -oSubscriber = null +HIDDEN oSubscriber ************************************************************************ -* Setup +* Init **************************************** -*** Function: Sets up an event subscription. +*** Function: Initializes an event subscription. *** Assume: *** Pass: loBridge - dnb instance *** loSource - Source Object fires events @@ -1842,62 +1978,24 @@ oSubscriber = null *** lcPrefix - prefix for event methods *** implemented on target (defaults to "On") ************************************************************************ -FUNCTION Setup(loBridge, loSource, loHandler, lcPrefix) -this.oBridge = loBridge -this.oHandler = loHandler -this.oPrefix = lcPrefix -Private handler -handler = m.loHandler -this.oSubscriber = loBridge.CreateInstance("Westwind.WebConnection.EventSubscriber", loSource, m.lcPrefix, _Vfp) -this.HandleNextEvent() +FUNCTION Init(loBridge, loSource, loHandler, lcPrefix, llPost) +this.oSubscriber = loBridge.CreateInstance("Westwind.WebConnection.EventSubscriber", loSource, loHandler, lcPrefix, llPost, _VFP) ENDFUNC -************************************************************************ -* UnSubscribe -**************************************** -*** Function: Unsubscribes events that are currently subscribed to -************************************************************************ -FUNCTION UnSubscribe() -IF !ISNULL(THIS.oSubscriber) - this.oSubscriber.Dispose() -ENDIF -ENDFUNC - - -FUNCTION HandleNextEvent() -this.oBridge.InvokeMethodAsync(this,this.oSubscriber,"WaitForEvent") +FUNCTION Destroy() +this.Unsubscribe() ENDFUNC ************************************************************************ -* OnComplete +* Unsubscribe **************************************** -*** Function: Event Proxy that forwards the event to a function -*** named On{Event} with event's parameters. +*** Function: Unsubscribes events that are currently subscribed to ************************************************************************ -FUNCTION OnCompleted(lvResult, lcMethod) -LOCAL loParams,lParamText,lCount - -IF ISNULL(lvResult) && If the call to WaitForEvent was canceled: - 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 - -IF VARTYPE(THIS.oHandler) = "O" - =EVALUATE("this.oHandler." + this.oPrefix + lvResult.Name + "("+SUBSTR(lParamText,2)+")") - this.HandleNextEvent() +FUNCTION Unsubscribe() +IF !ISNULL(this.oSubscriber) + this.oSubscriber.Dispose() + this.oSubscriber = null ENDIF - ENDFUNC ENDDEFINE @@ -2034,6 +2132,7 @@ IF VARTYPE(this.oDotNetBridge) != "O" *this.oDotNetBridge.LoadAssembly("System") this.oDotNetBridge.IsThrowOnErrorEnabled = this.lThrowOnError + this.SetSynchronizationContext() ENDIF diff --git a/DotnetBridge/Utilities/EventSubscriber.cs b/DotnetBridge/Utilities/EventSubscriber.cs index 2e134c1..53d65b2 100644 --- a/DotnetBridge/Utilities/EventSubscriber.cs +++ b/DotnetBridge/Utilities/EventSubscriber.cs @@ -1,98 +1,64 @@ -using System; -using System.Collections.Concurrent; +#nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; -using System.Text; -using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Threading; namespace Westwind.WebConnection -{ - /// - /// FoxPro interop access to .NET events. Handles all events of a source object for subsequent retrieval by a FoxPro client. - /// - /// For a FoxPro program to be notified of events, it should use `wwDotNetBridge.InvokeMethodAsync` to call . When asynchronously completes, the FoxPro program should handle the event it returns and then call again to wait for the next event. The FoxPro class `EventSubscription`, which is returned by `SubscribeToEvents`, encapsulates this async wait loop. - 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(); - - 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()) - { - // handler is a PRIVATE variable defined in EventSubscription.Setup(). - Boolean hasMethod = vfp?.Eval($"PEMSTATUS(m.handler, '{prefix}{ev.Name}', 5)") ?? true; - if (!hasMethod) - continue; - - var eventParams = ev.EventHandlerType.GetMethod("Invoke").GetParameters().Select(p => Expression.Parameter(p.ParameterType)).ToArray(); - var eventHandlerLambda = Expression.Lambda(ev.EventHandlerType, - Expression.Call( - instance: Expression.Constant(this), - method: typeof(EventSubscriber).GetMethod(nameof(QueueInteropEvent), BindingFlags.NonPublic | BindingFlags.Instance), - arg0: Expression.Constant(ev.Name), - arg1: Expression.NewArrayInit(typeof(object), eventParams.Select(p => Expression.Convert(p, typeof(object))))), - eventParams); - var eventHandler = eventHandlerLambda.Compile(); - ev.AddEventHandler(source, eventHandler); - _eventHandlers.Add(new DelegateInfo(eventHandler, ev)); - } - } - - class DelegateInfo - { - public DelegateInfo(Delegate handler, EventInfo eventInfo) +{ + /// + /// Subscribes to all events for which a handler object has corresponding methods. + /// + public sealed class EventSubscriber : IDisposable + { + private readonly object _eventSource; + private readonly object _handler; + private readonly bool _post; + private readonly List<(EventInfo, Delegate)> _eventDelegates = []; + + private static readonly MethodInfo invokeMethod = typeof(EventSubscriber).GetMethod(nameof(InvokeMethod), BindingFlags.NonPublic | BindingFlags.Instance); + + public EventSubscriber(object eventSource, object handler, string prefix, bool post, dynamic vfp) + { + _eventSource = eventSource; + _handler = handler; + _post = post; + var instanceExpression = Expression.Constant(this); + var handlerExpression = Expression.Constant(handler); + + foreach (var eventInfo in eventSource.GetType().GetEvents()) { - Delegate = handler; - EventInfo = eventInfo; - } - - public Delegate Delegate { get; } - public EventInfo EventInfo { get; } - } - - public void Dispose() - { - foreach (var item in _eventHandlers) - item.EventInfo.RemoveEventHandler(_source, item.Delegate); - _completion.TrySetCanceled(); - } - - private void QueueInteropEvent(string name, object[] parameters) - { - var interopEvent = new RaisedEvent { Name = name, Params = parameters }; - if (!_completion.TrySetResult(interopEvent)) - _raisedEvents.Enqueue(interopEvent); - } - - /// - /// Waits until an event is raised, or returns immediately if a queued event is available. - /// - /// 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; - } - } - - public class RaisedEvent - { - public string Name { get; internal set; } - public object[] Params { get; internal set; } + string methodName = prefix + eventInfo.Name; + bool hasMethod = vfp.Eval($"PEMSTATUS(m.wwDotnetBridgeEventHandler, '{methodName}', 5)"); + if (!hasMethod) + continue; + + var eventHandlerType = eventInfo.EventHandlerType; + var paramExpressions = eventHandlerType.GetMethod("Invoke").GetParameters().Select(p => Expression.Parameter(p.ParameterType, p.Name)).ToArray(); + var arguments = paramExpressions.Select(p => Expression.Convert(p, typeof(object))).ToArray(); + var callExpression = Expression.Call(instanceExpression, invokeMethod, handlerExpression, Expression.Constant(methodName), Expression.NewArrayInit(typeof(object), arguments)); + var eventDelegate = Expression.Lambda(eventHandlerType, callExpression, paramExpressions).Compile(); + eventInfo.AddEventHandler(eventSource, eventDelegate); + _eventDelegates.Add((eventInfo, eventDelegate)); + } + } + + public void Dispose() + { + foreach ((var eventInfo, var eventDelegate) in _eventDelegates) + eventInfo.RemoveEventHandler(_eventSource, eventDelegate); + Marshal.FinalReleaseComObject(_handler); + } + + private void InvokeMethod(object handler, string methodName, object[] arguments) + { + if (_post || Thread.CurrentThread != wwDotNetBridge._mainThread) + wwDotNetBridge._synchronizationContext.Post(_ => handler.GetType().InvokeMember(methodName, BindingFlags.InvokeMethod, null, handler, arguments), null); + else + handler.GetType().InvokeMember(methodName, BindingFlags.InvokeMethod, null, handler, arguments); + } } } diff --git a/DotnetBridge/Utilities/FoxProSynchronizationContext.cs b/DotnetBridge/Utilities/FoxProSynchronizationContext.cs new file mode 100644 index 0000000..60bd71f --- /dev/null +++ b/DotnetBridge/Utilities/FoxProSynchronizationContext.cs @@ -0,0 +1,70 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Westwind.WebConnection +{ + /// + /// Synchronizes tasks with the FoxPro main thread. + /// + /// When one or more tasks are ready to run, posts a Windows message to the main FoxPro window. + internal sealed class FoxProSynchronizationContext(int hwnd, wwDotNetBridge bridge) : SynchronizationContext + { + private readonly IntPtr _hwnd = (IntPtr)hwnd; + private readonly ConcurrentQueue<(SendOrPostCallback handler, object? state)> _postQueue = []; + + /// + /// Gets the ID of the Windows message posted when a task is ready to run. + /// + public int PostMessageId { get; private set; } = RegisterWindowMessage("FoxProSynchronizationContextDispatch"); + + /// + /// Posts a message to indicate that there are posts ready to dispatch. Thread safe. + /// + public override void Post(SendOrPostCallback d, object? state) + { + _postQueue.Enqueue((d, state)); + + if (!PostMessage(_hwnd, PostMessageId, IntPtr.Zero, IntPtr.Zero)) + bridge.LastException = new OutOfMemoryException("Failed to post dispatch message."); + } + + /// + /// Dispatches all queued send or post callbacks in the synchronization context. Called when a Windows message with ID is received. + /// + public void Dispatch() + { + // FoxPro ignores a PostMessageId message when posted while handling a previous PostMessageId message. Therefore, it is important to run all queued posts. + + while (_postQueue.TryDequeue(out var post)) + { + try + { + post.handler(post.state); + } + catch (Exception ex) + { + bridge.LastException = ex; + } + } + } + + /// + /// Starts a dispatch operation. Used by external code to dispatch queued callbacks. + /// + public override void OperationStarted() => Dispatch(); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("user32.dll", CharSet = CharSet.Unicode, BestFitMapping = false, ThrowOnUnmappableChar = true)] + private static extern int RegisterWindowMessage(string lpString); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + } +} diff --git a/DotnetBridge/wwDotNetBridge.cs b/DotnetBridge/wwDotNetBridge.cs index c4131a7..00473e9 100644 --- a/DotnetBridge/wwDotNetBridge.cs +++ b/DotnetBridge/wwDotNetBridge.cs @@ -34,20 +34,21 @@ // comment for OpenSource version // #define WestwindProduct +using Microsoft.Win32; using System; using System.Collections; using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Reflection; -using System.IO; using System.Data; +using System.IO; using System.Net; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Win32; -using System.Runtime.CompilerServices; - - + + #if WestwindProduct using Newtonsoft.Json; #endif @@ -71,7 +72,14 @@ namespace Westwind.WebConnection public class wwDotNetBridge { - private static bool _firstLoad = true; + internal static Thread _mainThread; + + internal static FoxProSynchronizationContext _synchronizationContext; + + /// + /// Gets or sets whether invoked methods should be posted to the message queue instead of called immediately. + /// + public bool PostInvokedMethods { get; set; } /// /// Returns error information if the call fails @@ -87,12 +95,14 @@ public class wwDotNetBridge public wwDotNetBridge() { - + bool firstLoad = _mainThread == null; + _mainThread ??= Thread.CurrentThread; + if (Environment.Version.Major >= 4) { LoadAssembly("System.Core"); - if (_firstLoad) + if (firstLoad) { if (!ServicePointManager.SecurityProtocol.HasFlag(SecurityProtocolType.Tls12)) { @@ -103,9 +113,6 @@ public wwDotNetBridge() SecurityProtocolType.Tls; } - _firstLoad = false; - - AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; } @@ -119,6 +126,18 @@ private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs a return ass; } + /// + /// Sets the current synchronization context to use the FoxPro main thread. + /// + /// The ID of the Windows message posted when a task is ready to run. + public int SetSynchronizationContext(int hwnd) + { + _synchronizationContext = new FoxProSynchronizationContext(hwnd, this); + SynchronizationContext.SetSynchronizationContext(_synchronizationContext); + return _synchronizationContext.PostMessageId; + } + + public void Dispatch() => _synchronizationContext.Dispatch(); #region LoadAssembly Routines @@ -696,14 +715,17 @@ public object InvokeStaticMethod_TenParms(string TypeName, string Method, object Parm9, Parm10); } - /// - /// Invokes a static method - /// - /// - /// - /// - /// - internal object InvokeStaticMethod_Internal(string TypeName, string Method, params object[] args) + internal object InvokeStaticMethod_Internal(string TypeName, string method, params object[] args) + { + if (PostInvokedMethods) + { + _synchronizationContext.Post(_ => InvokeStaticMethod_Now(TypeName, method, args), null); + return null; + } + return InvokeStaticMethod_Now(TypeName, method, args); + } + + internal object InvokeStaticMethod_Now(string TypeName, string Method, params object[] args) { SetError(); @@ -962,7 +984,22 @@ public object InvokeMethod_ParameterArray(object instance, string method, object return InvokeMethod_InternalWithObjectArray(instance, method, ParmArray.Instance as object[]); } - internal object InvokeMethod_Internal(object instance, string method, params object[] args) + internal object InvokeMethod_Internal(object instance, string method, params object[] args) + { + if (PostInvokedMethods) + { + PostMethod_Internal(instance, method, args); + return null; + } + return InvokeMethod_Now(instance, method, args); + } + + internal void PostMethod_Internal(object instance, string method, params object[] args) + { + _synchronizationContext.Post(_ => InvokeMethod_Now(instance, method, args), null); + } + + internal object InvokeMethod_Now(object instance, string method, params object[] args) { var fixedInstance = FixupParameter(instance); @@ -1465,7 +1502,7 @@ public void InvokeTaskMethodAsync( ex = WrapException(LastException); InvokeMethod_Internal(callBack, "onError", ex.Message, ex, method); } - }); + }, TaskScheduler.FromCurrentSynchronizationContext()); } catch (Exception ex) { @@ -1543,7 +1580,7 @@ private void _InvokeMethodAsync(object parmList) { try { - InvokeMethod_Internal(callBack, "onError", ex.Message, ex.GetBaseException(), method); + PostMethod_Internal(callBack, "onError", ex.Message, ex.GetBaseException(), method); } catch { @@ -1560,7 +1597,7 @@ private void _InvokeMethodAsync(object parmList) { try { - InvokeMethod_Internal(callBack, "onCompleted", result, method); + PostMethod_Internal(callBack, "onCompleted", result, method); } catch (Exception ex) { diff --git a/DotnetBridge/wwDotNetBridge.csproj b/DotnetBridge/wwDotNetBridge.csproj index a67d85b..c30aa34 100644 --- a/DotnetBridge/wwDotNetBridge.csproj +++ b/DotnetBridge/wwDotNetBridge.csproj @@ -32,6 +32,7 @@ https://github.com/RickStrahl/wwDotnetBridge git true + latest $(NoWarn);CS1591;CS1572;CS1573 diff --git a/Tests/EventSubscriberTests.cs b/Tests/EventSubscriberTests.cs index 7ce016b..33809b7 100644 --- a/Tests/EventSubscriberTests.cs +++ b/Tests/EventSubscriberTests.cs @@ -14,31 +14,46 @@ namespace wwDotnetBridge.Tests [TestClass] public class EventSubscriberTests { - [TestMethod] - public void EventSubscriber_WaitForPastEvents() + private readonly wwDotNetBridge _bridge = new(); + private readonly TaskCompletionSource _raisedCompletion = new(); + private bool _onNoParamsRaised; + + [TestInitialize] + public void TestInitialize() { - var loopback = new Loopback(); - var subscriber = new EventSubscriber(loopback); - loopback.Raise(); - VerifyResults(subscriber); + _bridge.SetSynchronizationContext(0); } [TestMethod] - public void EventSubscriber_WaitForFutureEvents() - { - var loopback = new Loopback(); - var subscriber = new EventSubscriber(loopback); - Task.Delay(1).ContinueWith(t => loopback.Raise()); - VerifyResults(subscriber); - } + public Task EventSubscriber_RaiseImmediateEvent() => RaiseEvent(false); + + [TestMethod] + public Task EventSubscriber_RaisePostedEvent() => RaiseEvent(true); - static void VerifyResults(EventSubscriber subscriber) + private async Task RaiseEvent(bool post) { - var result = subscriber.WaitForEvent(); - Assert.IsTrue(result.Name == nameof(Loopback.NoParams) && result.Params.Length == 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); + var loopback = new Loopback(); + var subscriber = new EventSubscriber(loopback, this, "On", post, this); + loopback.Raise(); + if (post) // Conditional to verify that dispatching is not required for immediate events (even though it is harmless). + _bridge.Dispatch(); + await _raisedCompletion.Task; + Assert.ThrowsException(subscriber.Dispose); // Expect Marshal.FinalReleaseComObject to throw because our test handler is not a COM object. + } + + public void OnNoParams() + { + _onNoParamsRaised = true; + } + + public void OnTwoParams(string s, int i) + { + Assert.IsTrue(_onNoParamsRaised); + Assert.IsTrue(s == "A" && i == 1); + _raisedCompletion.SetResult(null); } + + public bool Eval(string _) => true; } public class Loopback diff --git a/Tests/wwDotnetBridge.Tests.csproj b/Tests/wwDotnetBridge.Tests.csproj index 5433684..536b454 100644 --- a/Tests/wwDotnetBridge.Tests.csproj +++ b/Tests/wwDotnetBridge.Tests.csproj @@ -5,6 +5,7 @@ Westwind.WebConnection.Tests net472 false + latest From 4fefac194d31c27bbbd97bb0f0c6bc90799fee22 Mon Sep 17 00:00:00 2001 From: Edward Brey Date: Fri, 5 Dec 2025 16:16:55 -0600 Subject: [PATCH 2/2] Subclass main window to dispatch tasks. --- Distribution/wwDotnetBridge.PRG | 16 +---- .../Utilities/FoxProSynchronizationContext.cs | 65 ++++++++++++++----- DotnetBridge/wwDotNetBridge.cs | 6 +- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/Distribution/wwDotnetBridge.PRG b/Distribution/wwDotnetBridge.PRG index c3a443a..1663a5a 100644 --- a/Distribution/wwDotnetBridge.PRG +++ b/Distribution/wwDotnetBridge.PRG @@ -238,25 +238,11 @@ ENDFUNC * SetSynchronizationContext **************************************** PROTECTED FUNCTION SetSynchronizationContext() -LOCAL postMessageId -postMessageId = this.oDotNetBridge.SetSynchronizationContext(_VFP.hWnd) -BINDEVENT(_VFP.hWnd, postMessageId, this, "Dispatch") +this.oDotNetBridge.SetSynchronizationContext(_VFP.hWnd) ENDFUNC * SetSynchronizationContext -************************************************************************ -* Dispatch -**************************************** -*** Function: Dispatches all queued send or post callbacks in the synchronization context. -*** Return: nothing -************************************************************************ -FUNCTION Dispatch(hWnd, nMsg, wParam, lParam) && Windows message handler signature -this.oDotNetBridge.Dispatch() -ENDFUNC -* Dispatch - - ************************************************************************ * SetClrVersion **************************************** diff --git a/DotnetBridge/Utilities/FoxProSynchronizationContext.cs b/DotnetBridge/Utilities/FoxProSynchronizationContext.cs index 60bd71f..d2cfc5b 100644 --- a/DotnetBridge/Utilities/FoxProSynchronizationContext.cs +++ b/DotnetBridge/Utilities/FoxProSynchronizationContext.cs @@ -12,16 +12,39 @@ namespace Westwind.WebConnection /// /// Synchronizes tasks with the FoxPro main thread. /// - /// When one or more tasks are ready to run, posts a Windows message to the main FoxPro window. - internal sealed class FoxProSynchronizationContext(int hwnd, wwDotNetBridge bridge) : SynchronizationContext + /// + /// When one or more tasks are ready to run, posts a Windows message to the main FoxPro window. + /// Subclasses the window to receive the posted message and dispatch the tasks. + /// Unlike FoxPro BINDEVENT, subclassing processes messages even when FoxPro pumps messages from a dispatched task (e.g. from a modal form). + /// + internal sealed class FoxProSynchronizationContext : SynchronizationContext { - private readonly IntPtr _hwnd = (IntPtr)hwnd; + private readonly IntPtr _hwnd; + private readonly wwDotNetBridge _bridge; private readonly ConcurrentQueue<(SendOrPostCallback handler, object? state)> _postQueue = []; + private readonly WndProcDelegate _wndProcDelegate; + private readonly IntPtr _originalWndProc; + private readonly uint _postMessageId; - /// - /// Gets the ID of the Windows message posted when a task is ready to run. - /// - public int PostMessageId { get; private set; } = RegisterWindowMessage("FoxProSynchronizationContextDispatch"); + public FoxProSynchronizationContext(int hwnd, wwDotNetBridge bridge) + { + _hwnd = (IntPtr)hwnd; + _bridge = bridge; + _wndProcDelegate = WndProc; // Prevents the delegate from being garbage collected. + _originalWndProc = SetWindowLongPtr(_hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate)); + _postMessageId = RegisterWindowMessage("FoxProSynchronizationContextDispatch"); + } + + private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + if (msg == _postMessageId) + { + Dispatch(); + return IntPtr.Zero; + } + + return CallWindowProc(_originalWndProc, hWnd, msg, wParam, lParam); + } /// /// Posts a message to indicate that there are posts ready to dispatch. Thread safe. @@ -30,17 +53,15 @@ public override void Post(SendOrPostCallback d, object? state) { _postQueue.Enqueue((d, state)); - if (!PostMessage(_hwnd, PostMessageId, IntPtr.Zero, IntPtr.Zero)) - bridge.LastException = new OutOfMemoryException("Failed to post dispatch message."); + if (!PostMessage(_hwnd, _postMessageId, IntPtr.Zero, IntPtr.Zero)) + _bridge.LastException = new OutOfMemoryException("Failed to post dispatch message."); } /// - /// Dispatches all queued send or post callbacks in the synchronization context. Called when a Windows message with ID is received. + /// Dispatches all queued send or post callbacks in the synchronization context. /// - public void Dispatch() + private void Dispatch() { - // FoxPro ignores a PostMessageId message when posted while handling a previous PostMessageId message. Therefore, it is important to run all queued posts. - while (_postQueue.TryDequeue(out var post)) { try @@ -49,7 +70,7 @@ public void Dispatch() } catch (Exception ex) { - bridge.LastException = ex; + _bridge.LastException = ex; } } } @@ -59,12 +80,24 @@ public void Dispatch() /// public override void OperationStarted() => Dispatch(); + private const int GWLP_WNDPROC = -4; + + private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("user32.dll", EntryPoint = "SetWindowLongA")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("user32.dll")] + private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] [DllImport("user32.dll", CharSet = CharSet.Unicode, BestFitMapping = false, ThrowOnUnmappableChar = true)] - private static extern int RegisterWindowMessage(string lpString); + private static extern uint RegisterWindowMessage(string lpString); [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] [DllImport("user32.dll", SetLastError = true)] - private static extern bool PostMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); } } diff --git a/DotnetBridge/wwDotNetBridge.cs b/DotnetBridge/wwDotNetBridge.cs index 00473e9..de029d2 100644 --- a/DotnetBridge/wwDotNetBridge.cs +++ b/DotnetBridge/wwDotNetBridge.cs @@ -129,15 +129,11 @@ private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs a /// /// Sets the current synchronization context to use the FoxPro main thread. /// - /// The ID of the Windows message posted when a task is ready to run. - public int SetSynchronizationContext(int hwnd) + public void SetSynchronizationContext(int hwnd) { _synchronizationContext = new FoxProSynchronizationContext(hwnd, this); SynchronizationContext.SetSynchronizationContext(_synchronizationContext); - return _synchronizationContext.PostMessageId; } - - public void Dispatch() => _synchronizationContext.Dispatch(); #region LoadAssembly Routines