-
-
Notifications
You must be signed in to change notification settings - Fork 739
Mitigate race conditions and simplify API invocations #908
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Mitigate race conditions and simplify API invocations #908
Conversation
|
@agracio - what's funny is that I have done nothing about events, so it fits nicely together with your changes without any conflicts. |
There was a problem hiding this 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 pull request introduces a significant refactoring to reduce code duplication by implementing a base class pattern (ApiBase) for Electron.NET API classes. The PR consolidates repetitive socket communication and property getter logic into reusable helper methods.
- Introduces new
ApiBaseabstract class with helper methods for calling socket methods and retrieving properties - Refactors
BrowserWindowandAppclasses to inherit fromApiBaseand use the new helper methods - Adds utility extension methods
LowerFirstandStripAsyncto support the base class functionality
Reviewed Changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| src/ElectronNET.API/API/ApiBase.cs | New base class providing common socket communication patterns with timeout handling and method/property call abstractions |
| src/ElectronNET.API/API/BrowserWindow.cs | Refactored to extend ApiBase and replace verbose socket calls with concise helper method invocations |
| src/ElectronNET.API/API/App.cs | Refactored to extend ApiBase and replace verbose socket calls with concise helper method invocations |
| src/ElectronNET.API/Common/Extensions.cs | Added LowerFirst and StripAsync string extension methods; changed class visibility to internal; removed unused import |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
I can see that you refactored a lot of tasks which is what I am doing now although I am touching considerably more files. In any case it appears I will have to roll back more than a day's worth of work. |
Oh, I'm sorry about that. But I suppose you didn't address the race conditions - which is crucial. |
|
I am struggling to understand how tasks work now. Example from BrowserWindow.cs before public Task<string> GetTitleAsync()
{
var taskCompletionSource = new TaskCompletionSource<string>();
BridgeConnector.Socket.On("browserWindow-getTitle-completed", (title) => {
BridgeConnector.Socket.Off("browserWindow-getTitle-completed");
taskCompletionSource.SetResult(title.ToString());
});
BridgeConnector.Socket.Emit("browserWindowGetTitle", Id);
return taskCompletionSource.Task;
}after public Task<string> GetTitleAsync() => this.GetPropertyAsync<string>();Emit() signature "browserWindowGetTitle", On() signature "browserWindow-getTitle-completed". Am I to dense to understand how it will be able to connect to JS backend. socket.on('browserWindowGetTitle', (id) => {
const title = getWindowById(id).getTitle();
electronSocket.emit('browserWindow-getTitle-completed', title);
});Same goes for the rest of changes. |
No, you probably just don't known about the Look at the protected Task<T> GetPropertyAsync<T>([CallerMemberName] string callerName = null)
{
Debug.Assert(callerName != null, nameof(callerName) + " != null");
return this.propertyGetters.GetOrAdd(callerName, _ =>
{
var getter = new PropertyGetter<T>(this, callerName, PropertyTimeout);
getter.Task<T>().ContinueWith(_ => this.propertyGetters.TryRemove(callerName, out var _));
return getter;
}).Task<T>();
}The |
|
My understanding is that it will use the name of the calling method, isn't that correct. |
|
That's in the var eventName = apiBase.propertyEventNames.GetOrAdd(callerName, s => $"{apiBase.objectName}-{s.StripAsync().LowerFirst()}{apiBase.SocketEventCompleteSuffix}");
var messageName = apiBase.propertyMessageNames.GetOrAdd(callerName, s => apiBase.objectName + s.StripAsync()); |
|
The dictionaries there are just a means of optimization, so that the strings do not need to be recreated each time (imagine moveWindow or resizeWindow events where you might query the values thousands of times). |
2e9e1a0 to
ba6ebce
Compare
|
This is a very clever piece of code. However it does rely on the fact that all naming conventions are somewhat similar. var eventName = apiBase.propertyEventNames.GetOrAdd(callerName, s => $"{apiBase.objectName}-{s.StripAsync().LowerFirst()}{apiBase.SocketEventCompleteSuffix}");This code does account for the fact that there are multiple completion namings, so far I have seen 3 - You have it set incorrectly for BrowserWindow it should be However this part of the code assumes that var messageName = apiBase.propertyMessageNames.GetOrAdd(callerName, s => apiBase.objectName + s.StripAsync());Unfortunately it can also be Screen.cs public Task<int> GetMenuBarHeightAsync()
{
var taskCompletionSource = new TaskCompletionSource<int>();
BridgeConnector.Socket.On("screen-getMenuBarHeightCompleted", (height) =>
{
BridgeConnector.Socket.Off("screen-getMenuBarHeightCompleted");
taskCompletionSource.SetResult(int.Parse(height.ToString()));
});
BridgeConnector.Socket.Emit("screen-getMenuBarHeight");
return taskCompletionSource.Task;
}Not sure if that covers all of them, there could be more variations. |
ba6ebce to
cde275f
Compare
True, that's why I didn't do the other classes.. |
One way to deal with it is to actually rename them. I have done it as part of events refactoring. |
You mean on the TypeScript side? That's the best way of course. If you want to continue this, it would be great, I have no plans to further work on this at the moment. A less nice but pragmatic way, when there's another widespread pattern would be a second function like GetPropertyAsyncL1().. or similar (for lower first :wink) |
|
Currently demo app is failing, easy to replicate by going to "Manage window state" clicking "View Demo" and trying to move new window. It is getting a bit late to investigate. And after failure it does leave 3 orphaned electron.exe instances. |
cde275f to
4c3065c
Compare
FlorianRappl
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM - definitely a welcome change and some significant improvements here. Many thanks @softworkz !
|
There is a fork of this project that has significant changes to BridgeConnector, it appears to involve similar approach with dictionaries but moves it to a higher level meaning that both events and tasks and anything else that calls BridgeConnector is thread safe. Take a look here https://github.com/theolivenbaum/electron-sharp/blob/main/ElectronSharp.API/BridgeConnector.cs. The project also has very significant changes to SocketIO and serialization including some changes to SocketIo in main.js. https://github.com/theolivenbaum/electron-sharp/tree/main/ElectronSharp.API/SocketIO |
|
I will hold of on any changes while @softworkz is doing refactoring. |
@softworkz could you clarify what you mean by that, no more changes expected to task and Sockets code? Could you take a look at There are very clear advantages of this code as it moves thread safety one level up encapsulating any calls to SocketIO regardless if it is made by events, tasks or anything else. But if I was to look at implementing it in Electron.NET code I believe some it would mean removal of dictionaries and all the other thread safety features that you implemented in I am more than happy to look into it but would like your opinion first. |
I made the changes in this PR three weeks ago already because I needed them (the PropertyGet part) for my actual project. But I don't think that this matters a lot. When you look at my PropertyGet code and even more their SocketBridge modification and you realize how far all this needs to go in trying to bend things into a sane shape, what does it tell you? If your conclusion is that something is very wrong - then that's spot on. Do you know what it is? |
|
I can only guess that thread syntonisation and SocketIO stability are the main issues. Additionally none of the the changes made in this repo and changes in electron-sharp resolve an issue of orphan processes in case of dotnet exception. The only ideas I have regarding it are not the best. Looking at electron-sharp code it appears to have a very comprehensive thread safety mechanics that include locks, semaphores and dictionaries to make sure events are are always synced and only one instance is used. According to readme their solution was created for use in their product making it a very similar case to yours since you are making changes to create a working product. The question is whether we want to pull their changes into Electron.NET repo - this would be a very big code change. I could take a look at it but that would be a major change and since there are dozens of additional files that are included in electron-sharp SocketIO implementation validating every one of them would very hard so it would be as-is addition. Please share your opinions on this. |
Well, those are the collaterals, but the source of all evil is something quite different: It's the use of SocketIO named events, and trying to squeeze the behavior that we would actually need into the limitations and constraints of SocketIO events. The current implementation tries to establish short-lived SocketIO event channels "per invocation":
The Ideal WayI have a bit of a problem talking about this, because I don't want to make any promises that I won't be able to fulfill, but as a matter of fact, I have done an implementation for bidirectional JS(browser) <=> CSharp projection which brings JS objects to C# and C# objects to JS. It supports method/function calling (async + sync from JS => C#), property retrieval and event registration (in both directions). It works cross-browser and "cross-ui-framework" (UWP/Xbox, WinUI w. WebView2, Electron, Android, iOS, Tizen/untested), battle-tested (approaching 1M installations) and high-performant (it can sync the movement of a CSS transition to a hWND native window on Windows). Communication is message-based and all goes through a single channel. That would mean - in regards to SocketIO - there would be a single On the JS side, all invocations are be made directly using the JS function prototypes,, there's no need to create any proxy classes like al the TS/JS code under So - that's a recipe that works and that I would use when building ElectronNET from scratch. |


There's a major design flaw in the API invocations.
The problem is that requests via socket IO are not numbered (like wiht a serial nr) and there can be only a single listener for a certain event name.
This means that when there's an invocation that is waiting for a return and a second invocation is made, the listener for the previous invocation will never be notified.
In combination with the use of TaskCompletionSource, the resulting behavior is that the tasks of such calls were never transitioned to completion (nor errored, nor canceled).
The result: code that was awaiting such tasks remained hanging forever - possibly even preventing the whole application from finishing gracefully (without needing to be forced to).
This PR simplifies those property invocations and it also mitigates those race conditions in a way that only a single invocation (= single TaskCompletionSource) exists for each method at a time. Subsequent calls are just being returned the same task from the TCS.
There can still be situations where there's no return, that's why the PR also introduces a timeout after which the tasks will be canceled. The duration might be debatable or can be made configurable. It's set to 1s at the moment.
Second benefit is the elimination of load of repetitive code. It reduces BrowserWindow from 1.7k to 1.2k lines.