From 3fbbff729c220008605c026b624c3f674251ba2d Mon Sep 17 00:00:00 2001 From: Tom Bruyneel <102589380+tom-b-iodigital@users.noreply.github.com> Date: Wed, 21 Feb 2024 18:10:14 +0100 Subject: [PATCH] Add task support to DefaultObjectConverter (#1787) --- Jint.Tests/Runtime/AsyncTests.cs | 60 ++++++++++-- .../Runtime/TestClasses/AsyncTestClass.cs | 40 ++++++++ Jint/Native/JsValue.cs | 71 ++++++++++++++ Jint/Native/Object/ObjectInstance.cs | 6 +- .../Runtime/Interop/DefaultObjectConverter.cs | 93 +++++++++++-------- Jint/Runtime/Interop/DelegateWrapper.cs | 73 +-------------- 6 files changed, 222 insertions(+), 121 deletions(-) create mode 100644 Jint.Tests/Runtime/TestClasses/AsyncTestClass.cs diff --git a/Jint.Tests/Runtime/AsyncTests.cs b/Jint.Tests/Runtime/AsyncTests.cs index 5d1305632b..4d98c8e280 100644 --- a/Jint.Tests/Runtime/AsyncTests.cs +++ b/Jint.Tests/Runtime/AsyncTests.cs @@ -1,3 +1,5 @@ +using Jint.Tests.Runtime.TestClasses; + namespace Jint.Tests.Runtime; public class AsyncTests @@ -28,6 +30,26 @@ static async Task Callable() } } + [Fact] + public void ShouldReturnedTaskConvertedToPromiseInJS() + { + Engine engine = new(); + engine.SetValue("asyncTestClass", new AsyncTestClass()); + var result = engine.Evaluate("asyncTestClass.ReturnDelayedTaskAsync().then(x=>x)"); + result = result.UnwrapIfPromise(); + Assert.Equal(AsyncTestClass.TestString, result); + } + + [Fact] + public void ShouldReturnedCompletedTaskConvertedToPromiseInJS() + { + Engine engine = new(); + engine.SetValue("asyncTestClass", new AsyncTestClass()); + var result = engine.Evaluate("asyncTestClass.ReturnCompletedTask().then(x=>x)"); + result = result.UnwrapIfPromise(); + Assert.Equal(AsyncTestClass.TestString, result); + } + [Fact] public void ShouldTaskCatchWhenCancelled() { @@ -45,6 +67,19 @@ static async Task Callable(CancellationToken token) } } + [Fact] + public void ShouldReturnedTaskCatchWhenCancelled() + { + Engine engine = new(); + CancellationTokenSource cancel = new(); + cancel.Cancel(); + engine.SetValue("token", cancel.Token); + engine.SetValue("asyncTestClass", new AsyncTestClass()); + engine.SetValue("assert", new Action(Assert.True)); + var result = engine.Evaluate("asyncTestClass.ReturnCancelledTask(token).then(_ => assert(false)).catch(_ => assert(true))"); + result = result.UnwrapIfPromise(); + } + [Fact] public void ShouldTaskCatchWhenThrowError() { @@ -60,23 +95,36 @@ static async Task Callable() } } + [Fact] + public void ShouldReturnedTaskCatchWhenThrowError() + { + Engine engine = new(); + engine.SetValue("asyncTestClass", new AsyncTestClass()); + engine.SetValue("assert", new Action(Assert.True)); + var result = engine.Evaluate("asyncTestClass.ThrowAfterDelayAsync().then(_ => assert(false)).catch(_ => assert(true))"); + result = result.UnwrapIfPromise(); + } + [Fact] public void ShouldTaskAwaitCurrentStack() { //https://github.com/sebastienros/jint/issues/514#issuecomment-1507127509 Engine engine = new(); - string log = ""; + AsyncTestClass asyncTestClass = new(); + engine.SetValue("myAsyncMethod", new Func(async () => { await Task.Delay(1000); - log += "1"; + asyncTestClass.StringToAppend += "1"; })); - engine.SetValue("myAsyncMethod2", new Action(() => + engine.SetValue("mySyncMethod2", new Action(() => { - log += "2"; + asyncTestClass.StringToAppend += "2"; })); - engine.Execute("async function hello() {await myAsyncMethod();myAsyncMethod2();} hello();"); - Assert.Equal("12", log); + engine.SetValue("asyncTestClass", asyncTestClass); + + engine.Execute("async function hello() {await myAsyncMethod();mySyncMethod2();await asyncTestClass.AddToStringDelayedAsync(\"3\")} hello();"); + Assert.Equal("123", asyncTestClass.StringToAppend); } #if NETFRAMEWORK == false diff --git a/Jint.Tests/Runtime/TestClasses/AsyncTestClass.cs b/Jint.Tests/Runtime/TestClasses/AsyncTestClass.cs new file mode 100644 index 0000000000..19a640423e --- /dev/null +++ b/Jint.Tests/Runtime/TestClasses/AsyncTestClass.cs @@ -0,0 +1,40 @@ +namespace Jint.Tests.Runtime.TestClasses +{ + internal class AsyncTestClass + { + public static readonly string TestString = "Hello World"; + + public string StringToAppend { get; set; } = string.Empty; + + public async Task AddToStringDelayedAsync(string appendWith) + { + await Task.Delay(1000).ConfigureAwait(false); + + StringToAppend += appendWith; + } + + public async Task ReturnDelayedTaskAsync() + { + await Task.Delay(1000).ConfigureAwait(false); + + return TestString; + } + + public Task ReturnCompletedTask() + { + return Task.FromResult(TestString); + } + + public Task ReturnCancelledTask(CancellationToken token) + { + return Task.FromCanceled(token); + } + + public async Task ThrowAfterDelayAsync() + { + await Task.Delay(100).ConfigureAwait(false); + + throw new Exception("Task threw exception"); + } + } +} diff --git a/Jint/Native/JsValue.cs b/Jint/Native/JsValue.cs index 23e7973285..e87d81efe2 100644 --- a/Jint/Native/JsValue.cs +++ b/Jint/Native/JsValue.cs @@ -119,6 +119,77 @@ internal virtual bool TryGetIterator( return true; } + internal static JsValue ConvertAwaitableToPromise(Engine engine, object obj) + { + if (obj is Task task) + { + return ConvertTaskToPromise(engine, task); + } + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP + if (obj is ValueTask valueTask) + { + return ConvertTaskToPromise(engine, valueTask.AsTask()); + } + + // ValueTask + var asTask = obj.GetType().GetMethod(nameof(ValueTask.AsTask)); + if (asTask is not null) + { + return ConvertTaskToPromise(engine, (Task) asTask.Invoke(obj, parameters: null)!); + } +#endif + + return FromObject(engine, JsValue.Undefined); + } + + internal static JsValue ConvertTaskToPromise(Engine engine, Task task) + { + var (promise, resolve, reject) = engine.RegisterPromise(); + task = task.ContinueWith(continuationAction => + { + if (continuationAction.IsFaulted) + { + reject(FromObject(engine, continuationAction.Exception)); + } + else if (continuationAction.IsCanceled) + { + reject(FromObject(engine, new ExecutionCanceledException())); + } + else + { + // Special case: Marshal `async Task` as undefined, as this is `Task` at runtime + // See https://github.com/sebastienros/jint/pull/1567#issuecomment-1681987702 + if (Task.CompletedTask.Equals(continuationAction)) + { + resolve(FromObject(engine, JsValue.Undefined)); + return; + } + + var result = continuationAction.GetType().GetProperty(nameof(Task.Result)); + if (result is not null) + { + resolve(FromObject(engine, result.GetValue(continuationAction))); + } + else + { + resolve(FromObject(engine, JsValue.Undefined)); + } + } + }); + + engine.AddToEventLoop(() => + { + if (!task.IsCompleted) + { + // Task.Wait has the potential of inlining the task's execution on the current thread; avoid this. + ((IAsyncResult) task).AsyncWaitHandle.WaitOne(); + } + }); + + return promise; + } + [DebuggerBrowsable(DebuggerBrowsableState.Never)] public Types Type { diff --git a/Jint/Native/Object/ObjectInstance.cs b/Jint/Native/Object/ObjectInstance.cs index c54f4ca9bc..87511bc354 100644 --- a/Jint/Native/Object/ObjectInstance.cs +++ b/Jint/Native/Object/ObjectInstance.cs @@ -1059,7 +1059,7 @@ private object ToObject(ObjectTraverseStack stack) converted = result; break; } - + if (this is JsTypedArray typedArrayInstance) { converted = typedArrayInstance._arrayElementType switch @@ -1703,14 +1703,14 @@ public KeyValuePair[] Entries var i = 0; if (_obj._properties is not null) { - foreach(var key in _obj._properties) + foreach (var key in _obj._properties) { keys[i++] = new KeyValuePair(key.Key.Name, UnwrapJsValue(key.Value, _obj)); } } if (_obj._symbols is not null) { - foreach(var key in _obj._symbols) + foreach (var key in _obj._symbols) { keys[i++] = new KeyValuePair(key.Key, UnwrapJsValue(key.Value, _obj)); } diff --git a/Jint/Runtime/Interop/DefaultObjectConverter.cs b/Jint/Runtime/Interop/DefaultObjectConverter.cs index e7cc6654d4..4eb726c339 100644 --- a/Jint/Runtime/Interop/DefaultObjectConverter.cs +++ b/Jint/Runtime/Interop/DefaultObjectConverter.cs @@ -69,67 +69,80 @@ public static bool TryConvert(Engine engine, object value, Type? type, [NotNullW if (value is Delegate d) { result = new DelegateWrapper(engine, d); + return result is not null; } - else + + if (value is Task task) + { + result = JsValue.ConvertAwaitableToPromise(engine, task); + return result is not null; + } + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP + if (value is ValueTask valueTask) + { + result = JsValue.ConvertAwaitableToPromise(engine, valueTask); + return result is not null; + } +#endif + + var t = value.GetType(); + + if (!engine.Options.Interop.AllowSystemReflection + && t.Namespace?.StartsWith("System.Reflection", StringComparison.Ordinal) == true) + { + const string Message = "Cannot access System.Reflection namespace, check Engine's interop options"; + ExceptionHelper.ThrowInvalidOperationException(Message); + } + + if (t.IsEnum) { - var t = value.GetType(); + var ut = Enum.GetUnderlyingType(t); - if (!engine.Options.Interop.AllowSystemReflection - && t.Namespace?.StartsWith("System.Reflection", StringComparison.Ordinal) == true) + if (ut == typeof(ulong)) { - const string Message = "Cannot access System.Reflection namespace, check Engine's interop options"; - ExceptionHelper.ThrowInvalidOperationException(Message); + result = JsNumber.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture)); } - - if (t.IsEnum) + else { - var ut = Enum.GetUnderlyingType(t); - - if (ut == typeof(ulong)) + if (ut == typeof(uint) || ut == typeof(long)) { - result = JsNumber.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture)); + result = JsNumber.Create(Convert.ToInt64(value, CultureInfo.InvariantCulture)); } else { - if (ut == typeof(uint) || ut == typeof(long)) - { - result = JsNumber.Create(Convert.ToInt64(value, CultureInfo.InvariantCulture)); - } - else - { - result = JsNumber.Create(Convert.ToInt32(value, CultureInfo.InvariantCulture)); - } + result = JsNumber.Create(Convert.ToInt32(value, CultureInfo.InvariantCulture)); } } + } + else + { + // check global cache, have we already wrapped the value? + if (engine._objectWrapperCache?.TryGetValue(value, out var cached) == true) + { + result = cached; + } else { - // check global cache, have we already wrapped the value? - if (engine._objectWrapperCache?.TryGetValue(value, out var cached) == true) + var wrapped = engine.Options.Interop.WrapObjectHandler.Invoke(engine, value, type); + + if (ReferenceEquals(wrapped?.GetPrototypeOf(), engine.Realm.Intrinsics.Object.PrototypeObject) + && engine._typeReferences?.TryGetValue(t, out var typeReference) == true) { - result = cached; + wrapped.SetPrototypeOf(typeReference); } - else - { - var wrapped = engine.Options.Interop.WrapObjectHandler.Invoke(engine, value, type); - - if (ReferenceEquals(wrapped?.GetPrototypeOf(), engine.Realm.Intrinsics.Object.PrototypeObject) - && engine._typeReferences?.TryGetValue(t, out var typeReference) == true) - { - wrapped.SetPrototypeOf(typeReference); - } - result = wrapped; + result = wrapped; - if (engine.Options.Interop.TrackObjectWrapperIdentity && wrapped is not null) - { - engine._objectWrapperCache ??= new ConditionalWeakTable(); - engine._objectWrapperCache.Add(value, wrapped); - } + if (engine.Options.Interop.TrackObjectWrapperIdentity && wrapped is not null) + { + engine._objectWrapperCache ??= new ConditionalWeakTable(); + engine._objectWrapperCache.Add(value, wrapped); } } - - // if no known type could be guessed, use the default of wrapping using using ObjectWrapper. } + + // if no known type could be guessed, use the default of wrapping using using ObjectWrapper. } return result is not null; diff --git a/Jint/Runtime/Interop/DelegateWrapper.cs b/Jint/Runtime/Interop/DelegateWrapper.cs index beb2413a4f..2fcb819580 100644 --- a/Jint/Runtime/Interop/DelegateWrapper.cs +++ b/Jint/Runtime/Interop/DelegateWrapper.cs @@ -138,7 +138,7 @@ protected internal override JsValue Call(JsValue thisObject, JsValue[] arguments { return FromObject(Engine, result); } - return ConvertAwaitableToPromise(result!); + return ConvertAwaitableToPromise(Engine, result!); } catch (TargetInvocationException exception) { @@ -176,76 +176,5 @@ private static bool IsAwaitable(object? obj) #endif } - private JsValue ConvertAwaitableToPromise(object obj) - { - if (obj is Task task) - { - return ConvertTaskToPromise(task); - } - -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP - if (obj is ValueTask valueTask) - { - return ConvertTaskToPromise(valueTask.AsTask()); - } - - // ValueTask - var asTask = obj.GetType().GetMethod(nameof(ValueTask.AsTask)); - if (asTask is not null) - { - return ConvertTaskToPromise((Task) asTask.Invoke(obj, parameters: null)!); - } -#endif - - return FromObject(Engine, JsValue.Undefined); - } - - private JsValue ConvertTaskToPromise(Task task) - { - var (promise, resolve, reject) = Engine.RegisterPromise(); - task = task.ContinueWith(continuationAction => - { - if (continuationAction.IsFaulted) - { - reject(FromObject(Engine, continuationAction.Exception)); - } - else if (continuationAction.IsCanceled) - { - reject(FromObject(Engine, new ExecutionCanceledException())); - } - else - { - // Special case: Marshal `async Task` as undefined, as this is `Task` at runtime - // See https://github.com/sebastienros/jint/pull/1567#issuecomment-1681987702 - if (Task.CompletedTask.Equals(continuationAction)) - { - resolve(FromObject(Engine, JsValue.Undefined)); - return; - } - - var result = continuationAction.GetType().GetProperty(nameof(Task.Result)); - if (result is not null) - { - resolve(FromObject(Engine, result.GetValue(continuationAction))); - } - else - { - resolve(FromObject(Engine, JsValue.Undefined)); - } - } - }); - - Engine.AddToEventLoop(() => - { - if (!task.IsCompleted) - { - // Task.Wait has the potential of inlining the task's execution on the current thread; avoid this. - ((IAsyncResult) task).AsyncWaitHandle.WaitOne(); - } - }); - - return promise; - } - } }