Skip to content

Conversation

@breyed
Copy link
Contributor

@breyed breyed commented Oct 30, 2025

This PR fixes an event subscription memory leak and race condition.

EventSubscriber now accounts for Dispose, QueueInteropEvent, and WaitForEvent all being called simultaneously from different threads.

EventSubscription.OnComplete contains a workaround for the likelihood that even with the concurrency improvement, there is still a race condition.

The commit for this PR is based on the commit for #35 because they involve common code. Doing so will avoid a conflict when merging.

To reproduce the leak, run a stress test that subscribes and unsubscribes to lots of instances of an object. To reproduce the bug and demonstrated the fix, only one instance needs to exist at a time, and no events need to be raised. Without the fix, as the test continues, you'll notice in Task manager FoxPro consuming more and more memory.

LOCAL asyncCount
asyncCount = 0

FOR i = 1 TO 1000
	someClassWithEvents = CreateDotNetObject("SomeClassWithEvents")
	someEventHandler = CREATEOBJECT("SomeEventHandler")
	subscription = loBridge.SubscribeToEvents(someClassWithEvents, someEventHandler)
	subscription.Unsubscribe()
	loBridge.InvokeStaticMethod("System.GC", "Collect")

	asyncCount = asyncCount + 1
	IF asyncCount % 100 = 0
		? asyncCount
	ENDIF
ENDFOR

DEFINE CLASS SomeEventHandler as Custom
	FUNCTION OnSomeEvent()
	ENDFUNC
ENDDEFINE

Edward Brey added 2 commits September 4, 2025 12:50
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.
Copilot AI review requested due to automatic review settings October 30, 2025 04:21
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR refactors the event synchronization mechanism in EventSubscriber from using TaskCompletionSource to using SemaphoreSlim, and changes the event parameter storage from object[] to ArrayList for better FoxPro interop.

  • Replaced TaskCompletionSource with SemaphoreSlim for thread signaling in event handling
  • Changed RaisedEvent.Params type from object[] to ArrayList to simplify FoxPro parameter access
  • Improved disposal logic with proper cleanup and null checks

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
DotnetBridge/Utilities/EventSubscriber.cs Refactored synchronization mechanism and changed event parameter storage type
Tests/EventSubscriberTests.cs Updated test assertions to use Count property instead of Length for ArrayList
Distribution/wwDotnetBridge.PRG Simplified FoxPro event handler to directly access ArrayList items without creating temporary objects

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +75 to +76
// Release any waiting thread and dispose the semaphore.
_signal.Release();
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Release() before Dispose() creates a race condition. If a waiting thread wakes up from Wait() in WaitForEvent() (line 109) after Release() is called, it could access the semaphore after it's disposed. Consider removing the Release() call and relying on the ObjectDisposedException catch in WaitForEvent() to handle disposal gracefully.

Suggested change
// Release any waiting thread and dispose the semaphore.
_signal.Release();
// Dispose the semaphore.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +89
var parametersArrayList = new ArrayList(parameters.Length);
parametersArrayList.AddRange(parameters);
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating an ArrayList wrapper for every event is inefficient. Since ArrayList can be initialized directly from a collection, consider using new ArrayList(parameters) instead, which eliminates the AddRange call and improves performance.

Suggested change
var parametersArrayList = new ArrayList(parameters.Length);
parametersArrayList.AddRange(parameters);
var parametersArrayList = new ArrayList(parameters);

Copilot uses AI. Check for mistakes.
IF ISNULL(lvResult) && If the call to WaitForEvent was canceled:
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.
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment describes a workaround for a race condition but doesn't explain its root cause or propose a proper fix. If the race condition is fixed by the C# changes in this PR, this comment should be updated or removed. If the issue persists, the comment should provide more details about when and why this occurs to help future maintainers address it properly.

Suggested change
* 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.
* Note: lvResult is sometimes an empty string instead of an object. This occurs when the asynchronous event subscription is canceled or the handler is unsubscribed before the event is processed, resulting in a race condition between the event firing and the handler's disposal. This workaround checks the type of lvResult to avoid processing invalid results. If the underlying C# event handling is improved to prevent this race condition, this workaround and comment can be removed.

Copilot uses AI. Check for mistakes.

// 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) { }
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poor error handling: empty catch block.

Suggested change
try { signal.Wait(); } catch (ObjectDisposedException) { }
try { signal.Wait(); } catch (ObjectDisposedException ex) { System.Diagnostics.Debug.WriteLine($"ObjectDisposedException in WaitForEvent: {ex.Message}"); }

Copilot uses AI. Check for mistakes.
@breyed
Copy link
Contributor Author

breyed commented Nov 2, 2025

Superseded by #40.

@breyed breyed closed this Nov 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant