Skip to content

Commit

Permalink
Introduce new AddEventListener and RemoveEventListener APIs on JSObje…
Browse files Browse the repository at this point in the history
…ct (#55849)

Introduces new AddEventListener and RemoveEventListener methods on JSObject that create a delegate wrapper with its lifetime managed by the JavaScript GC instead of managed types, to ensure that delegates don't go away while JS is still using them. Migrates BrowserWebSocket to use the new APIs.
  • Loading branch information
kg committed Jul 28, 2021
1 parent 5d2229e commit a3ddef1
Show file tree
Hide file tree
Showing 7 changed files with 428 additions and 25 deletions.
5 changes: 5 additions & 0 deletions src/libraries/Common/src/Interop/Browser/Interop.Runtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ internal static partial class Runtime
[MethodImplAttribute(MethodImplOptions.InternalCall)]
internal static extern object TypedArrayCopyFrom(int jsObjHandle, int arrayPtr, int begin, int end, int bytesPerElement, out int exceptionalResult);

[MethodImplAttribute(MethodImplOptions.InternalCall)]
internal static extern string? AddEventListener(int jsObjHandle, string name, int weakDelegateHandle, int optionsObjHandle);
[MethodImplAttribute(MethodImplOptions.InternalCall)]
internal static extern string? RemoveEventListener(int jsObjHandle, string name, int weakDelegateHandle, bool capture);

// / <summary>
// / Execute the provided string in the JavaScript context
// / </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,13 @@ internal async Task ConnectAsyncJavaScript(Uri uri, CancellationToken cancellati
_onError = errorEvt => errorEvt.Dispose();

// Attach the onError callback
_innerWebSocket.SetObjectProperty("onerror", _onError);
_innerWebSocket.AddEventListener("error", _onError);

// Setup the onClose callback
_onClose = (closeEvent) => OnCloseCallback(closeEvent, cancellationToken);

// Attach the onClose callback
_innerWebSocket.SetObjectProperty("onclose", _onClose);
_innerWebSocket.AddEventListener("close", _onClose);

// Setup the onOpen callback
_onOpen = (evt) =>
Expand Down Expand Up @@ -203,13 +203,13 @@ internal async Task ConnectAsyncJavaScript(Uri uri, CancellationToken cancellati
};

// Attach the onOpen callback
_innerWebSocket.SetObjectProperty("onopen", _onOpen);
_innerWebSocket.AddEventListener("open", _onOpen);

// Setup the onMessage callback
_onMessage = (messageEvent) => OnMessageCallback(messageEvent);

// Attach the onMessage callaback
_innerWebSocket.SetObjectProperty("onmessage", _onMessage);
_innerWebSocket.AddEventListener("message", _onMessage);
await _tcsConnect.Task.ConfigureAwait(continueOnCapturedContext: true);
}
catch (Exception wse)
Expand Down Expand Up @@ -298,7 +298,7 @@ private void OnMessageCallback(JSObject messageEvent)
}
}
};
reader.Invoke("addEventListener", "loadend", loadend);
reader.AddEventListener("loadend", loadend);
reader.Invoke("readAsArrayBuffer", blobData);
}
break;
Expand All @@ -318,26 +318,10 @@ private void NativeCleanup()
{
// We need to clear the events on websocket as well or stray events
// are possible leading to crashes.
if (_onClose != null)
{
_innerWebSocket?.SetObjectProperty("onclose", "");
_onClose = null;
}
if (_onError != null)
{
_innerWebSocket?.SetObjectProperty("onerror", "");
_onError = null;
}
if (_onOpen != null)
{
_innerWebSocket?.SetObjectProperty("onopen", "");
_onOpen = null;
}
if (_onMessage != null)
{
_innerWebSocket?.SetObjectProperty("onmessage", "");
_onMessage = null;
}
_innerWebSocket?.RemoveEventListener("close", _onClose);
_innerWebSocket?.RemoveEventListener("error", _onError);
_innerWebSocket?.RemoveEventListener("open", _onOpen);
_innerWebSocket?.RemoveEventListener("message", _onMessage);
}

public override void Dispose()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,61 @@ public object Invoke(string method, params object?[] args)
return res;
}

public struct EventListenerOptions {
public bool Capture;
public bool Once;
public bool Passive;
public object? Signal;
}

public int AddEventListener(string name, Delegate listener, EventListenerOptions? options = null)
{
var optionsDict = options.HasValue
? new JSObject()
: null;

try {
if (options?.Signal != null)
throw new NotImplementedException("EventListenerOptions.Signal");

var jsfunc = Runtime.GetJSOwnedObjectHandle(listener);
// int exception;
if (options.HasValue) {
// TODO: Optimize this
var _options = options.Value;
optionsDict?.SetObjectProperty("capture", _options.Capture, true, true);
optionsDict?.SetObjectProperty("once", _options.Once, true, true);
optionsDict?.SetObjectProperty("passive", _options.Passive, true, true);
}

// TODO: Pass options explicitly instead of using the object
// TODO: Handle errors
// We can't currently do this because adding any additional parameters or a return value causes
// a signature mismatch at runtime
var ret = Interop.Runtime.AddEventListener(JSHandle, name, jsfunc, optionsDict?.JSHandle ?? 0);
if (ret != null)
throw new JSException(ret);
return jsfunc;
} finally {
optionsDict?.Dispose();
}
}

public void RemoveEventListener(string name, Delegate? listener, EventListenerOptions? options = null)
{
if (listener == null)
return;
var jsfunc = Runtime.GetJSOwnedObjectHandle(listener);
RemoveEventListener(name, jsfunc, options);
}

public void RemoveEventListener(string name, int listenerHandle, EventListenerOptions? options = null)
{
var ret = Interop.Runtime.RemoveEventListener(JSHandle, name, listenerHandle, options?.Capture ?? false);
if (ret != null)
throw new JSException(ret);
}

/// <summary>
/// Returns the named property from the object, or throws a JSException on error.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,74 @@ public static int BindExistingObject(object rawObj, int jsId)
return jsObject.Int32Handle;
}

private static int NextJSOwnedObjectID = 1;
private static object JSOwnedObjectLock = new object();
private static Dictionary<object, int> IDFromJSOwnedObject = new Dictionary<object, int>();
private static Dictionary<int, object> JSOwnedObjectFromID = new Dictionary<int, object>();

// A JSOwnedObject is a managed object with its lifetime controlled by javascript.
// The managed side maintains a strong reference to the object, while the JS side
// maintains a weak reference and notifies the managed side if the JS wrapper object
// has been reclaimed by the JS GC. At that point, the managed side will release its
// strong references, allowing the managed object to be collected.
// This ensures that things like delegates and promises will never 'go away' while JS
// is expecting to be able to invoke or await them.
public static int GetJSOwnedObjectHandle (object o) {
if (o == null)
return 0;

int result;
lock (JSOwnedObjectLock) {
if (IDFromJSOwnedObject.TryGetValue(o, out result))
return result;

result = NextJSOwnedObjectID++;
IDFromJSOwnedObject[o] = result;
JSOwnedObjectFromID[result] = o;
return result;
}
}

// The JS layer invokes this method when the JS wrapper for a JS owned object
// has been collected by the JS garbage collector
public static void ReleaseJSOwnedObjectByHandle (int id) {
lock (JSOwnedObjectLock) {
if (!JSOwnedObjectFromID.TryGetValue(id, out object? o))
throw new Exception($"JS-owned object with id {id} was already released");
IDFromJSOwnedObject.Remove(o);
JSOwnedObjectFromID.Remove(id);
}
}

// The JS layer invokes this API when the JS wrapper for a delegate is invoked.
// In multiple places this function intentionally returns false instead of throwing
// in an unexpected condition. This is done because unexpected conditions of this
// type are usually caused by a JS object (i.e. a WebSocket) receiving an event
// after its managed owner has been disposed - throwing in that case is unwanted.
public static bool TryInvokeJSOwnedDelegateByHandle (int id, JSObject? arg1) {
Delegate? del;
lock (JSOwnedObjectLock) {
if (!JSOwnedObjectFromID.TryGetValue(id, out object? o))
return false;
del = (Delegate)o;
}

if (del == null)
return false;

// error CS0117: 'Array' does not contain a definition for 'Empty' [/home/kate/Projects/dotnet-runtime-wasm/src/libraries/System.Private.Runtime.InteropServices.JavaScript/src/System.Private.Runtime.InteropServices.JavaScript.csproj]
#pragma warning disable CA1825

if (arg1 != null)
del.DynamicInvoke(new object[] { arg1 });
else
del.DynamicInvoke(new object[0]);

#pragma warning restore CA1825

return true;
}

public static int GetJSObjectId(object rawObj)
{
JSObject? jsObject;
Expand Down
Loading

0 comments on commit a3ddef1

Please sign in to comment.