Skip to content
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

feat(client): add callback/event mechanism between TypeScript and Go #2330

Merged
merged 28 commits into from
Feb 1, 2025

Conversation

jyyi1
Copy link
Contributor

@jyyi1 jyyi1 commented Jan 15, 2025

This PR introduces a callback mechanism that allows TypeScript code to subscribe to events triggered in the Go code.

To demonstrate the functionality, this PR implements a VPN connection state change event. The TypeScript code can now subscribe to this event and receive updates on the VPN connection state.

This mechanism can be leveraged for both electron and the Cordova code.

Tested Outline-Client.deb on Ubuntu 22

@jyyi1 jyyi1 marked this pull request as ready for review January 16, 2025 03:17
@jyyi1 jyyi1 requested review from a team as code owners January 16, 2025 03:17
@github-actions github-actions bot added size/XL and removed size/L labels Jan 16, 2025
@jyyi1 jyyi1 requested review from fortuna and sbruens January 16, 2025 03:18
client/electron/go_plugin.ts Outdated Show resolved Hide resolved
client/electron/go_plugin.ts Outdated Show resolved Hide resolved
Copy link
Collaborator

@fortuna fortuna left a comment

Choose a reason for hiding this comment

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

It seems you still need to update the PR to decouple the callback from the event handling. Let me know when I should take a look

client/electron/go_plugin.ts Outdated Show resolved Hide resolved
client/electron/go_plugin.ts Outdated Show resolved Hide resolved
@jyyi1 jyyi1 force-pushed the junyi/vpn-callback-linux branch from f617f0b to 3ced93e Compare January 18, 2025 01:58
@jyyi1 jyyi1 requested review from fortuna and sbruens January 22, 2025 01:26
Copy link
Contributor

@sbruens sbruens left a comment

Choose a reason for hiding this comment

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

Nice. Just one small nit.

client/electron/go_plugin.ts Outdated Show resolved Hide resolved
client/go/Taskfile.yml Outdated Show resolved Hide resolved
* Subscribes to all VPN connection related events.
* This function ensures that the subscription only happens once.
*/
async function subscribeVPNConnEvents(): Promise<void> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Pass the VpnStatusCallback here and delete onVpnStatusChanged.

This eliminates the global state and makes the lifecycle/object relationship clearer. Easier to reason about.

Let the app worry about calling it once.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added InitVpnService function to index.ts, so it have a chance to subscribe to the event only once when the electron app is ready.

const VPNConnDisconnecting: VPNConnStatus = 'Disconnecting';
const VPNConnDisconnected: VPNConnStatus = 'Disconnected';

interface VPNConn {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not a VPN connection: you can't read/write to it.

Perhaps call it VPNStatus?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

VPNConnectionStatus or VPNConnectionState sounds good to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed to VPNConnectionState to avoid confusion with VPNConnStatus.

* Koffi requires us to register all persistent callbacks; we track the registrations here.
* @see https://koffi.dev/callbacks#registered-callbacks
*/
const koffiCallbacks = new Map<string, koffi.IKoffiRegisteredCallback>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

No need to change for this PR since it's already implemented, but there's an alternative design where the callback mapping you do in the Go code is done in the Typescript code instead. That's what Cordova does. This would circumvent the restrictions from koffi, since all you need in that case is a single callback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually Koffi's limit is pretty big. We might not event need that much of callbacks in the entire system.

A maximum of 8192 callbacks can exist at the same time.

client/electron/vpn_service.ts Outdated Show resolved Hide resolved
client/go/outline/callback/callback.go Outdated Show resolved Hide resolved
client/electron/go_plugin.ts Outdated Show resolved Hide resolved
// RemoveListener removes a [callback.Callback] from the specified [EventName].
//
// Calling this function is safe even if the event has not been registered.
func RemoveListener(evt EventName, cb callback.Token) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I worry about global events. It hurts composability and reusability, as it's impossible to track who is firing what events. Different components may conflict with the same events (e.g. "OnError").

We need a design that can compartmentalize events.

For example, you could have functions specific for the VPN events (AddVPNAPIListener). Or have a way to create independent "channels" (AddListener(channel, event, callback)). The channel would have a unique id.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We also talked about having a SetVPNListener, which is easier to implement and reason about.

Copy link
Contributor Author

@jyyi1 jyyi1 Jan 24, 2025

Choose a reason for hiding this comment

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

I would not worry too much about "tracking who is firing the event" because the trigger object can (and must) include itself in the event data. (Also OnError is a bad event, events should be concrete, like OnVPNConnectionConnectError, OnVPNConnectionDisconnectError, etc., the convention is On<ObjectType><EventName>, so actually no global events here).

The firing object should call the event handler with at least the following fields: { "this": "<object-id>", "data": "..." }.

The event handler can then distinguish the trigger object by looking at the event details and then distribute it accordingly in TypeScript. That's also why I included the VPNConn.ID in the event, it's not just an ID from the app code, it's also the ID which can uniquely identify the VPN connection object in Go.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's ok, but the code safety and correctness relies on adhering to a strict structure that is not enforced by the compiler. A better approach would be to ensure we can create independent channels.

At a minimal create the object that represents the channel to stop using globals. This way we can have other methods that use their own channels.

Copy link
Contributor Author

@jyyi1 jyyi1 Jan 28, 2025

Choose a reason for hiding this comment

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

Same question as the one in callback.go, do you mean a EventManager and a event.DefaultManager() method? But then will an EventManager also accepts a CallbackManager? This would add more unnecessary complexity to the code.

@jyyi1 jyyi1 requested review from fortuna and sbruens January 27, 2025 21:51
}

/** Interface containing the exported native CGo functions. */
interface CgoFunctions {
Copy link
Collaborator

Choose a reason for hiding this comment

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

The encapsulation here is a bit off. It can be more object-oriented and remove globals.

Perhaps call this CallbackMap with public New and Delete methods.
Make the fields private.
Move koffiCallbacks into here.

The logic in ensureCgo can be in the constructor or factory function. Then there's no need to call ensureCgo in the methods.

Perhaps rename ensureCgo to getCallbackMap()

This makes the code more encapsulated, safer and with less globals.

Copy link
Contributor Author

@jyyi1 jyyi1 Jan 28, 2025

Choose a reason for hiding this comment

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

It involves multiple components, I would prefer getBackendLib(), and returns an object with a methodChannel and a callbackManager object.

@@ -471,6 +477,8 @@ function main() {
// TODO(fortuna): Start the app with the window hidden on auto-start?
setupWindow();

await initVpnService();
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can be more explicit. Like monitorVpnEvents. Or you could just inline the function here.

"init" doesn't say much, and it feels like you are doing much more than you actually are. It's also misleading, since some VPN initialization happen somewhere else


var (
mu sync.RWMutex
callbacks = make(map[Token]Callback)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment here. Create a callback map object, rather than using globals.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you mean introduce a CallbackManager and then have a default singleton method such as callback.DefaultManager() ?


var (
mu sync.RWMutex
listeners = make(map[EventName][]callback.Token)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, please avoid globals. Create an object to hold the fields and methods to manage the listeners.

// RemoveListener removes a [callback.Callback] from the specified [EventName].
//
// Calling this function is safe even if the event has not been registered.
func RemoveListener(evt EventName, cb callback.Token) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's ok, but the code safety and correctness relies on adhering to a strict structure that is not enforced by the compiler. A better approach would be to ensure we can create independent channels.

At a minimal create the object that represents the channel to stop using globals. This way we can have other methods that use their own channels.

@jyyi1
Copy link
Contributor Author

jyyi1 commented Jan 31, 2025

Hi @fortuna @sbruens , I simplified the code by removing the event package, also exposing VPN state change event directly via methodChannel, and encapsulating callback managers in Go & TS and backend channel in TS. Please take another look. Thanks!

@jyyi1 jyyi1 requested a review from fortuna January 31, 2025 05:54
@jyyi1 jyyi1 merged commit de32ebc into master Feb 1, 2025
25 checks passed
@jyyi1 jyyi1 deleted the junyi/vpn-callback-linux branch February 1, 2025 00:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants