diff --git a/.gitignore b/.gitignore index 5311edc..75e587b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,12 @@ _ReSharper*/ *.sdf *.opensdf TestResult.xml -/.vs +.vs/ /Source/Noesis.Javascript/packages + +# NuGet +.nuget/ +packages/ +*.nupkg +*.snupkg + diff --git a/Fiddling/Fiddling.csproj b/Fiddling/Fiddling.csproj index 10a5ccc..a3304ec 100644 --- a/Fiddling/Fiddling.csproj +++ b/Fiddling/Fiddling.csproj @@ -1,99 +1,32 @@ - - - + - Debug - AnyCPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D} Exe - Properties - Fiddling - Fiddling - v4.7.2 - 512 - + net8.0 + enable + false + AnyCPU;x64;x86 - + + x64 - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false x64 - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false - x64 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full + + x86 - prompt - MinimumRecommendedRules.ruleset - false Win32 - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - false - Win32 + + + x64 + x64 + - - - - - - - - - - - + - - - - - - {7ecfed5e-8b33-4065-acbf-ab1050fb0f4c} - JavaScript.Net - - - - - copy $(ProjectDir)..\$(V8Platform)\$(Configuration)\v8.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\$(V8Platform)\$(Configuration)\v8_libbase.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\$(V8Platform)\$(Configuration)\v8_libplatform.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\$(V8Platform)\$(Configuration)\zlib.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\$(V8Platform)\$(Configuration)\icu*.* $(ProjectDir)$(OutDir) - - - \ No newline at end of file diff --git a/Fiddling/Program.cs b/Fiddling/Program.cs index 40fa136..09012a4 100644 --- a/Fiddling/Program.cs +++ b/Fiddling/Program.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.Remoting.Contexts; using System.Text; using Noesis.Javascript; @@ -15,45 +14,72 @@ class Program { public class Product { + public Product(decimal price) + { + Price = price; + } public decimal Price { get; set; } + public void DoSomething() { } + public void DoSomethingElse() { } + public IEnumerable GetTaxes() => new List { 0.01m, 0.02m }; + public decimal GetSalesTax(JavascriptFunction callback) => Convert.ToDecimal(callback.Call(Price)); + public override string ToString() => Price.ToString(); } + // ... + static void Main(string[] args) { + using (JavascriptContext context = new JavascriptContext()) + { + try + { + // breakpoint here + context.SetConstructor("Product", (Func)(price => new Product(price))); + context.SetParameter("globalProduct", new Product(2)); + var result = context.Run($@" +{{ + const importantProduct = new Product(3); + let sum = 0; + for (let i = 0; i < 200_000; i++) {{ - JavascriptContext.SetFatalErrorHandler(FatalErrorHandler); - using (JavascriptContext context = new JavascriptContext()) { - //int[] ints = new[] { 1, 2, 3 }; - //_context.SetParameter("n", ints); - //var result = _context.Run("n[1]"); - //Console.WriteLine(result.ToString()); - //_context.SetParameter("bozo", new Bozo(ints)); + // Commit 1 - creating managed objects from JS + const product = new Product(Math.random()); - context.SetParameter("test", new Product() { Price = new decimal(0.333) }); - context.Run("test.Price = test.Price * 2"); - Console.WriteLine(context.Run("test.Price").ToString()); - //context.Run("modified_price = test.Price * 2"); - //Console.WriteLine(context.GetParameter("modified_price").ToString()); //returns 0.... not good + // Commit 2 - calling methods on managed objects + product.DoSomething(); + product.DoSomething(); + product.DoSomethingElse(); + // Commits 3 and 4 - using iterators + for (const tax of product.GetTaxes()) + {{ + sum += tax; + }} - try { - //_context.Run("a=[]; while(true) a.push(0)"); - //_context.Run("function f() { f(); } f()"); + // Commit 5 - using JS callbacks in managed code without disposing them explicitly + sum += product.GetSalesTax(p => p * 0.19); + // End of scenarios - //while(true) { - // Console.Write("> "); - // var input = Console.ReadLine(); - // if (input == "quit") - // break; - // var result = _context.Run(input); - // Console.WriteLine("Result is: `{0}`", result); - //} - } catch (Exception ex) { - string s = (string)ex.Data["V8StackTrace"]; + sum += product.Price; + }} + [sum, importantProduct.Price, globalProduct.Price].toString(); +}} +"); + Console.WriteLine(result); + Console.WriteLine(context.GetParameter("globalProduct")); + // breakpoint here - pre dispose of the context + } + catch (Exception ex) + { + var s = (string)ex.Data["V8StackTrace"]!; Console.WriteLine(s); } - //Console.WriteLine(ints[1]); } + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + // breakpoint here - after dispose of the context (the garbage collection was only triggered to compare the object count in the memory dump) } static void FatalErrorHandler(string a, string b) @@ -69,7 +95,7 @@ class Bozo internal Bozo(Array a) { this.a = a; } public object this[int i] { - get { throw new ApplicationException("bozo"); return a.GetValue(i); } + get { throw new ApplicationException("bozo"); } set { a.SetValue(value, i); } } } diff --git a/Fiddling/app.config b/Fiddling/app.config deleted file mode 100644 index 312bb3f..0000000 --- a/Fiddling/app.config +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/JavaScript.Net.sln b/JavaScript.Net.sln index 4ff8bb6..f056baf 100644 --- a/JavaScript.Net.sln +++ b/JavaScript.Net.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.2020 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "JavaScript.Net", "Source\Noesis.Javascript\JavaScript.Net.vcxproj", "{7ECFED5E-8B33-4065-ACBF-AB1050FB0F4C}" EndProject @@ -36,28 +36,28 @@ Global {7ECFED5E-8B33-4065-ACBF-AB1050FB0F4C}.Release|x64.Build.0 = Release|x64 {7ECFED5E-8B33-4065-ACBF-AB1050FB0F4C}.Release|x86.ActiveCfg = Release|Win32 {7ECFED5E-8B33-4065-ACBF-AB1050FB0F4C}.Release|x86.Build.0 = Release|Win32 - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x64.ActiveCfg = Debug|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x64.Build.0 = Debug|Any CPU + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|Any CPU.ActiveCfg = Debug|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|Any CPU.Build.0 = Debug|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x64.ActiveCfg = Debug|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x64.Build.0 = Debug|x64 {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x86.ActiveCfg = Debug|x86 {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Debug|x86.Build.0 = Debug|x86 - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|Any CPU.Build.0 = Release|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x64.ActiveCfg = Release|Any CPU - {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x64.Build.0 = Release|Any CPU + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|Any CPU.ActiveCfg = Release|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|Any CPU.Build.0 = Release|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x64.ActiveCfg = Release|x64 + {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x64.Build.0 = Release|x64 {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x86.ActiveCfg = Release|x86 {9E0C9657-BC4C-4A25-B7DD-9C772FC5670D}.Release|x86.Build.0 = Release|x86 - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x64.ActiveCfg = Debug|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x64.Build.0 = Debug|Any CPU + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|Any CPU.ActiveCfg = Debug|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|Any CPU.Build.0 = Debug|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x64.ActiveCfg = Debug|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x64.Build.0 = Debug|x64 {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x86.ActiveCfg = Debug|x86 {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Debug|x86.Build.0 = Debug|x86 - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|Any CPU.Build.0 = Release|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x64.ActiveCfg = Release|Any CPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x64.Build.0 = Release|Any CPU + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|Any CPU.ActiveCfg = Release|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|Any CPU.Build.0 = Release|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x64.ActiveCfg = Release|x64 + {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x64.Build.0 = Release|x64 {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x86.ActiveCfg = Release|x86 {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87}.Release|x86.Build.0 = Release|x86 EndGlobalSection diff --git a/README.md b/README.md index db98f75..c004c9c 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,31 @@ when you redistribute Noesis.Javascript.dll then you will get errors when loading the DLL on some users machines. (Many, but not all users will already have it.) -Targets: .NET Framework 4.5 +Targets: .NET 8 + +For legacy .NET Framework 4.7.2 support, see previous versions. Building from Source ==================== -Open the .sln file in Visual Studio, use Configuration Manager to switch to platform to x64, and build. I've been working using Visual Studio 2017, -but 2015 will probably work too. +**Requirements:** +* Visual Studio 2022 (17.0+) or Visual Studio 2019 (16.4+) +* .NET 8 SDK +* Windows SDK 10.0 or later + +**Steps:** + +1. Open the .sln file in Visual Studio +2. Use Configuration Manager to switch the platform to x64 +3. Build the solution + +The project uses: +* C++/CLI with .NET Core support (`CLRSupport=NetCore`) +* .NET 8 SDK-style projects for C# code +* V8 version 9.8.177.4 via NuGet packages + +**Note:** The C++/CLI project requires Windows and Visual Studio with C++/CLI support for .NET Core. This is available in Visual Studio 2019 16.4+ and Visual Studio 2022. The following warnings are expected: diff --git a/Source/Noesis.Javascript/JavaScript.Net.vcxproj b/Source/Noesis.Javascript/JavaScript.Net.vcxproj index 196e601..7185756 100644 --- a/Source/Noesis.Javascript/JavaScript.Net.vcxproj +++ b/Source/Noesis.Javascript/JavaScript.Net.vcxproj @@ -1,9 +1,9 @@ - - + + Debug @@ -25,38 +25,39 @@ 15.0 {7ECFED5E-8B33-4065-ACBF-AB1050FB0F4C} - v4.7.2 + net8.0 ManagedCProj JavaScriptNet - 10.0.18362.0 + 10.0 + false DynamicLibrary true v143 - true + NetCore Unicode DynamicLibrary false v143 - true + NetCore Unicode DynamicLibrary true v143 - true + NetCore Unicode DynamicLibrary false v143 - true + NetCore Unicode @@ -83,6 +84,7 @@ true + $(SolutionDir)$(Platform)\$(Configuration)\ false @@ -90,6 +92,7 @@ false + $(SolutionDir)$(Platform)\$(Configuration)\ @@ -150,11 +153,8 @@ - - - - - + + @@ -187,5 +187,5 @@ - + \ No newline at end of file diff --git a/Source/Noesis.Javascript/JavascriptContext.cpp b/Source/Noesis.Javascript/JavascriptContext.cpp index 61cb676..0089f72 100644 --- a/Source/Noesis.Javascript/JavascriptContext.cpp +++ b/Source/Noesis.Javascript/JavascriptContext.cpp @@ -166,6 +166,7 @@ JavascriptContext::JavascriptContext() isolate->SetFatalErrorHandler(FatalErrorCallback); mExternals = gcnew System::Collections::Generic::Dictionary(); + mFunctions = gcnew System::Collections::Generic::Dictionary(); mMethods = gcnew System::Collections::Generic::Dictionary(); mTypeToConstructorMapping = gcnew System::Collections::Generic::Dictionary(); HandleScope scope(isolate); @@ -182,6 +183,23 @@ JavascriptContext::~JavascriptContext() v8::Isolate::Scope isolate_scope(isolate); for each (WrappedJavascriptExternal wrapped in mExternals->Values) delete wrapped.Pointer; + // Clean up JavascriptFunction wrappers + for each (WrappedJavascriptFunction wrapped in mFunctions->Values) + { + auto wrapper = wrapped.Pointer; + if (wrapper) + { + if (wrapper->handle && !wrapper->handle->IsEmpty()) + { + wrapper->handle->ClearWeak(); + wrapper->handle->Reset(); + delete wrapper->handle; + } + if (wrapper->managedHandle.IsAllocated) + wrapper->managedHandle.Free(); + delete wrapper; + } + } for each (WrappedMethod wrapped in mMethods->Values) { wrapped.Pointer->Reset(); @@ -193,6 +211,7 @@ JavascriptContext::~JavascriptContext() delete mContext; mContext = nullptr; delete mExternals; + delete mFunctions; delete mMethods; delete mTypeToConstructorMapping; } diff --git a/Source/Noesis.Javascript/JavascriptContext.h b/Source/Noesis.Javascript/JavascriptContext.h index 3719bae..240bc46 100644 --- a/Source/Noesis.Javascript/JavascriptContext.h +++ b/Source/Noesis.Javascript/JavascriptContext.h @@ -111,6 +111,56 @@ public value struct WrappedJavascriptExternal } }; +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Wrapper for JavascriptFunction - used for caching and cleanup +//////////////////////////////////////////////////////////////////////////////////////////////////// +ref class JavascriptFunction; // Forward declaration + +// Native wrapper that holds both V8 handle and weak reference to managed object +struct JavascriptFunctionWrapper +{ + v8::Persistent* handle; + System::Runtime::InteropServices::GCHandle managedHandle; + + JavascriptFunctionWrapper() : handle(nullptr) {} + + JavascriptFunctionWrapper(v8::Persistent* h, JavascriptFunction^ func) + : handle(h) + { + managedHandle = System::Runtime::InteropServices::GCHandle::Alloc( + func, + System::Runtime::InteropServices::GCHandleType::Weak + ); + } +}; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// WrappedJavascriptFunction +// +// Wraps a pointer to JavascriptFunctionWrapper for storage in managed dictionary. +// Similar pattern to WrappedMethod and WrappedJavascriptExternal. +//////////////////////////////////////////////////////////////////////////////////////////////////// +public value struct WrappedJavascriptFunction +{ +private: + System::IntPtr pointer; + +internal: + WrappedJavascriptFunction(JavascriptFunctionWrapper* value) + { + System::IntPtr value_pointer(value); + pointer = value_pointer; + } + + property JavascriptFunctionWrapper* Pointer + { + JavascriptFunctionWrapper* get() + { + return (JavascriptFunctionWrapper*)(void*)pointer; + } + } +}; + //////////////////////////////////////////////////////////////////////////////////////////////////// // JavascriptContext @@ -203,12 +253,19 @@ public ref class JavascriptContext: public System::IDisposable //////////////////////////////////////////////////////////// // Data members //////////////////////////////////////////////////////////// -internal: +public: // Stores every JavascriptExternal we create. This saves time if the same // objects are recreated frequently, and stops us building up a huge // collection of JavascriptExternal objects that won't be freed until // the context is destroyed. System::Collections::Generic::Dictionary^ mExternals; +internal: + + // Stores JavascriptFunction wrappers keyed by V8 function identity hash. + // Entries are automatically removed by GC callback when V8 collects the function. + // This prevents memory leaks from accumulating function wrappers. + System::Collections::Generic::Dictionary^ mFunctions; + System::Collections::Generic::Dictionary^ mMethods; protected: // By entering an isolate before using a context, we can have multiple diff --git a/Source/Noesis.Javascript/JavascriptExternal.cpp b/Source/Noesis.Javascript/JavascriptExternal.cpp index d006cd3..ee7b193 100644 --- a/Source/Noesis.Javascript/JavascriptExternal.cpp +++ b/Source/Noesis.Javascript/JavascriptExternal.cpp @@ -50,15 +50,20 @@ using namespace std; JavascriptExternal::JavascriptExternal(System::Object^ iObject) { - mObjectHandle = System::Runtime::InteropServices::GCHandle::Alloc(iObject); - mOptions = SetParameterOptions::None; + System::Runtime::InteropServices::GCHandle handle = System::Runtime::InteropServices::GCHandle::Alloc(iObject, System::Runtime::InteropServices::GCHandleType::Normal); + mObjectHandle = System::Runtime::InteropServices::GCHandle::ToIntPtr(handle); + mOptions = SetParameterOptions::None; } //////////////////////////////////////////////////////////////////////////////////////////////////// JavascriptExternal::~JavascriptExternal() { - mObjectHandle.Free(); + if (mObjectHandle != System::IntPtr::Zero) { + System::Runtime::InteropServices::GCHandle handle = System::Runtime::InteropServices::GCHandle::FromIntPtr(mObjectHandle); + handle.Free(); + mObjectHandle = System::IntPtr::Zero; + } if (!mPersistent.IsEmpty()) { mPersistent.ClearWeak(); mPersistent.Reset(); @@ -73,8 +78,12 @@ void GCCallback(const WeakCallbackInfo& data) auto context = JavascriptContext::GetCurrent(); auto external = data.GetParameter(); auto object = external->GetObject(); - if (context->mExternals->ContainsKey(object)) - context->mExternals->Remove(object); + + if (object != nullptr) { + if (context->mExternals->ContainsKey(object)) { + context->mExternals->Remove(object); + } + } delete external; } @@ -111,7 +120,13 @@ JavascriptExternal::ToLocal(Isolate* isolate) System::Object^ JavascriptExternal::GetObject() { - return mObjectHandle.Target; + // .NET 8: Convert IntPtr back to GCHandle to access Target + if (mObjectHandle == System::IntPtr::Zero) + return nullptr; + System::Runtime::InteropServices::GCHandle handle = System::Runtime::InteropServices::GCHandle::FromIntPtr(mObjectHandle); + if (!handle.IsAllocated) + return nullptr; + return handle.Target; } //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -122,7 +137,7 @@ JavascriptExternal::GetMethod(wstring iName) auto context = JavascriptContext::GetCurrent(); auto isolate = JavascriptContext::GetCurrentIsolate(); - auto type = mObjectHandle.Target->GetType(); + auto type = GetObject()->GetType(); auto memberName = gcnew System::String(iName.c_str()); auto uniqueMethodName = type->AssemblyQualifiedName + L"." + memberName; @@ -158,7 +173,7 @@ JavascriptExternal::GetMethod(Local iName) bool JavascriptExternal::GetProperty(wstring iName, Local &result) { - System::Object^ self = mObjectHandle.Target; + System::Object^ self = GetObject(); System::Type^ type = self->GetType(); PropertyInfo^ propertyInfo = type->GetProperty(gcnew System::String(iName.c_str())); @@ -209,7 +224,7 @@ JavascriptExternal::GetProperty(wstring iName, Local &result) Local JavascriptExternal::GetProperty(uint32_t iIndex) { - System::Object^ self = mObjectHandle.Target; + System::Object^ self = GetObject(); System::Type^ type = self->GetType(); cli::array^ propertyInfo = type->GetProperties(); int index = iIndex; @@ -254,7 +269,7 @@ JavascriptExternal::GetProperty(uint32_t iIndex) Local JavascriptExternal::SetProperty(wstring iName, Local iValue) { - System::Object^ self = mObjectHandle.Target; + System::Object^ self = GetObject(); System::Type^ type = self->GetType(); PropertyInfo^ propertyInfo = type->GetProperty(gcnew System::String(iName.c_str())); @@ -324,7 +339,7 @@ JavascriptExternal::SetProperty(wstring iName, Local iValue) Local JavascriptExternal::SetProperty(uint32_t iIndex, Local iValue) { - System::Object^ self = mObjectHandle.Target; + System::Object^ self = GetObject(); System::Type^ type = self->GetType(); cli::array^ propertyInfo = type->GetProperties(); int index = iIndex; @@ -366,7 +381,7 @@ JavascriptExternal::SetProperty(uint32_t iIndex, Local iValue) Local JavascriptExternal::GetIterator() { auto context = JavascriptContext::GetCurrent(); - auto type = mObjectHandle.Target->GetType(); + auto type = GetObject()->GetType(); auto uniqueMethodName = type->AssemblyQualifiedName + L".$$Iterator"; auto isolate = JavascriptContext::GetCurrentIsolate(); diff --git a/Source/Noesis.Javascript/JavascriptExternal.h b/Source/Noesis.Javascript/JavascriptExternal.h index 95ee8bc..177f3b8 100644 --- a/Source/Noesis.Javascript/JavascriptExternal.h +++ b/Source/Noesis.Javascript/JavascriptExternal.h @@ -97,7 +97,8 @@ class JavascriptExternal // Handle to the .Net object being wrapped. It takes this // form so that the garbage collector won't try to move it. - System::Runtime::InteropServices::GCHandle mObjectHandle; + // .NET 8: Store as IntPtr instead of GCHandle for safe native storage + System::IntPtr mObjectHandle; SetParameterOptions mOptions; diff --git a/Source/Noesis.Javascript/JavascriptFunction.cpp b/Source/Noesis.Javascript/JavascriptFunction.cpp index 28dc94c..7a1a3f8 100644 --- a/Source/Noesis.Javascript/JavascriptFunction.cpp +++ b/Source/Noesis.Javascript/JavascriptFunction.cpp @@ -9,6 +9,39 @@ namespace Noesis { namespace Javascript { //////////////////////////////////////////////////////////////////////////////////////////////////// +// This callback is invoked by V8 when it wants to garbage collect the JavaScript function +void JavascriptFunctionGCCallback(const WeakCallbackInfo>& data) +{ + auto handle = data.GetParameter(); + + // V8 requires THIS EXACT HANDLE to be reset in the first-pass callback + handle->Reset(); + + auto context = JavascriptContext::GetCurrent(); + if (context != nullptr && !context->IsDisposed()) + { + for each (auto kvp in context->mFunctions) + { + auto wrapper = kvp.Value.Pointer; + if (wrapper && wrapper->handle == handle) + { + // Freeing the weak GCHandle allows .NET to collect the managed JavascriptFunction + if (wrapper->managedHandle.IsAllocated) + wrapper->managedHandle.Free(); + + delete wrapper->handle; + wrapper->handle = nullptr; + + delete wrapper; + + // CRITICAL: Remove from cache dictionary to prevent memory leak! + context->mFunctions->Remove(kvp.Key); + break; + } + } + } +} + //////////////////////////////////////////////////////////////////////////////////////////////////// JavascriptFunction::JavascriptFunction(v8::Local iFunction, JavascriptContext^ context) @@ -19,20 +52,40 @@ JavascriptFunction::JavascriptFunction(v8::Local iFunction, Javascri if(!context) throw gcnew System::ArgumentException("Must provide a JavascriptContext"); - mFuncHandle = new Persistent(context->GetCurrentIsolate(), Local::Cast(iFunction)); + auto isolate = context->GetCurrentIsolate(); + auto func = Local::Cast(iFunction); + + mFuncHandle = new Persistent(isolate, func); + // SetWeak allows V8 to garbage collect the JavaScript function when it's no longer referenced in JS. + // The callback notifies us so we can clean up the managed wrapper and remove it from the cache. + // Without this, the V8 function would be kept alive forever, causing a memory leak. + mFuncHandle->SetWeak(mFuncHandle, JavascriptFunctionGCCallback, WeakCallbackType::kParameter); + mContextHandle = gcnew System::WeakReference(context); } JavascriptFunction::~JavascriptFunction() { - if(mFuncHandle) + if (mFuncHandle) { - if (IsAlive()) + auto context = GetContext(); + if (context && !context->IsDisposed() && !mFuncHandle->IsEmpty()) { - JavascriptScope scope(GetContext()); - mFuncHandle->Reset(); + JavascriptScope scope(context); + auto isolate = context->GetCurrentIsolate(); + HandleScope handleScope(isolate); + int identityHash = mFuncHandle->Get(isolate)->GetIdentityHash(); + + if (context->mFunctions->ContainsKey(identityHash)) + { + mFuncHandle->ClearWeak(); + mFuncHandle->Reset(); + delete mFuncHandle; + + context->mFunctions->Remove(identityHash); + } } - delete mFuncHandle; + mFuncHandle = nullptr; } } diff --git a/Source/Noesis.Javascript/JavascriptInterop.cpp b/Source/Noesis.Javascript/JavascriptInterop.cpp index e43c706..d45e669 100644 --- a/Source/Noesis.Javascript/JavascriptInterop.cpp +++ b/Source/Noesis.Javascript/JavascriptInterop.cpp @@ -104,6 +104,40 @@ ConvertedObjects::GetConverted(v8::Local o) } +//////////////////////////////////////////////////////////////////////////////////////////////////// + +JavascriptFunction^ +JavascriptInterop::ConvertFunctionFromV8(Local iValue) +{ + auto context = JavascriptContext::GetCurrent(); + auto func = Local::Cast(iValue); + int identityHash = func->GetIdentityHash(); + + // Check if we already have a wrapper for this function + if (context->mFunctions->ContainsKey(identityHash)) + { + auto wrapped = context->mFunctions[identityHash]; + auto wrapper = wrapped.Pointer; + if (wrapper && wrapper->managedHandle.IsAllocated) + { + auto target = wrapper->managedHandle.Target; + if (target != nullptr) + return safe_cast(target); // Reuse existing wrapper + } + // Stale entry (GC collected), remove it + context->mFunctions->Remove(identityHash); + } + + // Create new wrapper and add to cache + auto jsFunc = gcnew JavascriptFunction( + iValue->ToObject(context->GetCurrentIsolate()->GetCurrentContext()).ToLocalChecked(), + context + ); + auto wrapper = new JavascriptFunctionWrapper(jsFunc->mFuncHandle, jsFunc); + context->mFunctions[identityHash] = WrappedJavascriptFunction(wrapper); + return jsFunc; +} + //////////////////////////////////////////////////////////////////////////////////////////////////// System::Object^ @@ -142,7 +176,7 @@ JavascriptInterop::ConvertFromV8(Local iValue, ConvertedObjects &already_ if (iValue->IsRegExp()) return ConvertRegexFromV8(iValue); if (iValue->IsFunction()) - return gcnew JavascriptFunction(iValue->ToObject(JavascriptContext::GetCurrentIsolate()->GetCurrentContext()).ToLocalChecked(), JavascriptContext::GetCurrent()); + return ConvertFunctionFromV8(iValue); if (iValue->IsBigInt()) { auto stringRepresentation = iValue->ToString(JavascriptContext::GetCurrentIsolate()->GetCurrentContext()).ToLocalChecked(); @@ -391,7 +425,7 @@ JavascriptInterop::ConvertObjectFromV8(Local iObject, ConvertedObjects & * and assumes incorrectly that Germany observed UTC+2 during the summer time. * * V8 - * new Date(1978, 5, 15) // "Thu Jun 15 1978 00:00:00 GMT+0100 (Mitteleuropäische Normalzeit)" + * new Date(1978, 5, 15) // "Thu Jun 15 1978 00:00:00 GMT+0100 (Mitteleuropäische Normalzeit)" * * C# * If we get the ticks since 1970-01-01 from V8 to construct a UTC DateTime object we get @@ -414,13 +448,13 @@ double GetDateComponent(Isolate* isolate, Local date, const char* componen System::DateTime^ JavascriptInterop::ConvertDateFromV8(Local date) { auto isolate = JavascriptContext::GetCurrentIsolate(); - auto year = GetDateComponent(isolate, date, "getFullYear"); - auto month = GetDateComponent(isolate, date, "getMonth") + 1; - auto day = GetDateComponent(isolate, date, "getDate"); - auto hour = GetDateComponent(isolate, date, "getHours"); - auto minute = GetDateComponent(isolate, date, "getMinutes"); - auto second = GetDateComponent(isolate, date, "getSeconds"); - auto millisecond = GetDateComponent(isolate, date, "getMilliseconds"); + int year = static_cast(GetDateComponent(isolate, date, "getFullYear")); + int month = static_cast(GetDateComponent(isolate, date, "getMonth") + 1); + int day = static_cast(GetDateComponent(isolate, date, "getDate")); + int hour = static_cast(GetDateComponent(isolate, date, "getHours")); + int minute = static_cast(GetDateComponent(isolate, date, "getMinutes")); + int second = static_cast(GetDateComponent(isolate, date, "getSeconds")); + int millisecond = static_cast(GetDateComponent(isolate, date, "getMilliseconds")); return gcnew System::DateTime(year, month, day, hour, minute, second, millisecond, System::DateTimeKind::Local); } diff --git a/Source/Noesis.Javascript/JavascriptInterop.h b/Source/Noesis.Javascript/JavascriptInterop.h index 8662ae4..bbed8f0 100644 --- a/Source/Noesis.Javascript/JavascriptInterop.h +++ b/Source/Noesis.Javascript/JavascriptInterop.h @@ -41,6 +41,9 @@ namespace Noesis { namespace Javascript { using namespace v8; using namespace System::Collections::Generic; +// Forward declaration +ref class JavascriptFunction; + //////////////////////////////////////////////////////////////////////////////////////////////////// // Remembers which objects have been just converted, to avoid stack overflows when we are @@ -88,6 +91,8 @@ class JavascriptInterop private: static System::Object^ ConvertFromV8(Local iValue, ConvertedObjects &already_converted); + static JavascriptFunction^ ConvertFunctionFromV8(Local iValue); + static System::Object^ ConvertObjectFromV8(Local iObject, ConvertedObjects &already_converted); static System::DateTime^ ConvertDateFromV8(Local iValue); diff --git a/Tests/Noesis.Javascript.Tests/AccessToStackTraceTest.cs b/Tests/Noesis.Javascript.Tests/AccessToStackTraceTest.cs index af3ee87..58a6b76 100644 --- a/Tests/Noesis.Javascript.Tests/AccessToStackTraceTest.cs +++ b/Tests/Noesis.Javascript.Tests/AccessToStackTraceTest.cs @@ -2,6 +2,7 @@ using FluentAssertions; using System.Linq; using System.Collections.Generic; +using System; namespace Noesis.Javascript.Tests { @@ -10,11 +11,11 @@ public class AccessToStackTraceTest { private class StracktraceExporter { - public JavascriptContext context { get; set; } + public JavascriptContext? context { get; set; } public List frames(int depth) { - return context.GetCurrentStack(depth); + return context?.GetCurrentStack(depth) ?? throw new ApplicationException("No context set"); } } diff --git a/Tests/Noesis.Javascript.Tests/AccessorInterceptorTests.cs b/Tests/Noesis.Javascript.Tests/AccessorInterceptorTests.cs index 0aa2ca6..b3eeaca 100644 --- a/Tests/Noesis.Javascript.Tests/AccessorInterceptorTests.cs +++ b/Tests/Noesis.Javascript.Tests/AccessorInterceptorTests.cs @@ -9,7 +9,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class AccessorInterceptorTests { - private JavascriptContext _context; + private JavascriptContext _context = null!; [TestInitialize] public void SetUp() @@ -35,7 +35,7 @@ public void AccessAnElementInAManagedArray() class ClassWithIndexer { public int Index { get; set; } - public string Value { get; set; } + public string? Value { get; set; } public string this[int iIndex] { @@ -57,7 +57,7 @@ public void AccessingByIndexAPropertyInAManagedObject() class ClassWithDictionary { - public DictionaryLike prop { get; set; } + public DictionaryLike prop { get; set; } = new(); } @@ -65,7 +65,7 @@ class DictionaryLike { public Dictionary internalDict { get; set; } - public DictionaryLike(Dictionary internalDict = null) + public DictionaryLike(Dictionary? internalDict = null) { this.internalDict = internalDict ?? new Dictionary(); } @@ -142,7 +142,7 @@ public void AccessingDictionaryOverObjectInManagedObject2() class ClassWithProperty { - public string MyProperty { get; set; } + public string? MyProperty { get; set; } } [TestMethod] @@ -215,7 +215,7 @@ public void SettingUnknownPropertiesIsDisallowedIfRejectUnknownPropertiesIsSet() _context.SetParameter("myObject", new ClassWithProperty(), SetParameterOptions.RejectUnknownProperties); Action action = () => _context.Run("myObject.UnknownProperty = 77"); - action.ShouldThrowExactly(); + action.Should().ThrowExactly(); } [TestMethod] @@ -224,7 +224,7 @@ public void GettingUnknownPropertiesIsDisallowedIfRejectUnknownPropertiesIsSet() _context.SetParameter("myObject", new ClassWithProperty(), SetParameterOptions.RejectUnknownProperties); Action action = () => _context.Run("myObject.UnknownProperty"); - action.ShouldThrowExactly().Which.Message.Should().StartWith("Unknown member:"); + action.Should().ThrowExactly().Which.Message.Should().StartWith("Unknown member:"); } class ClassForTypeCoercion @@ -481,7 +481,7 @@ public void IEnumerableProperty_UsingSpreadOperator_ReturnsCorrectArray() var enumerable = new ClassWithEnumerableProperty(); _context.SetParameter("enumerable", enumerable); var result = _context.Run(@"[...enumerable.Items];"); - result.ShouldBeEquivalentTo(new int[] { 1, 2, 3 }); + result.Should().BeEquivalentTo(new int[] { 1, 2, 3 }); } [TestMethod] @@ -490,7 +490,7 @@ public void IEnumerableComplexObjectProperty_UsingSpreadOperator_ReturnsCorrectA var enumerable = new ClassWithEnumerableProperty(); _context.SetParameter("enumerable", enumerable); var result = _context.Run(@"[...enumerable.ComplexItems]"); - result.ShouldBeEquivalentTo(new ClassWithDecimalProperty[] + result.Should().BeEquivalentTo(new ClassWithDecimalProperty[] { new ClassWithDecimalProperty { D = 1 }, new ClassWithDecimalProperty { D = 2 }, @@ -516,7 +516,7 @@ public void PropertyThatIsNotIEnumerable_CallingSymbolIteratorFunction_ThrowsExc var enumerable = new ClassWithEnumerableProperty(); _context.SetParameter("enumerable", enumerable); Action action = () => _context.Run(@"enumerable[Symbol.iterator]()"); - action.ShouldThrow("TypeError: enumerable[Symbol.iterator] is not a function"); + action.Should().Throw("TypeError: enumerable[Symbol.iterator] is not a function"); } } } \ No newline at end of file diff --git a/Tests/Noesis.Javascript.Tests/ConvertFromJavascriptTests.cs b/Tests/Noesis.Javascript.Tests/ConvertFromJavascriptTests.cs index e5da683..dbe1372 100644 --- a/Tests/Noesis.Javascript.Tests/ConvertFromJavascriptTests.cs +++ b/Tests/Noesis.Javascript.Tests/ConvertFromJavascriptTests.cs @@ -4,13 +4,14 @@ using FluentAssertions; using System.Text.RegularExpressions; using System.Numerics; +using System.Diagnostics.CodeAnalysis; namespace Noesis.Javascript.Tests { [TestClass] public class ConvertFromJavascriptTests { - private JavascriptContext _context; + private JavascriptContext _context = null!; private class TypedPropertiesClass { @@ -162,7 +163,7 @@ public void ReadRegExpLiteral() _context.Run("var myRegExp = /abc/gim"); var regex = _context.GetParameter("myRegExp"); - regex.Should().BeOfType().Which.ShouldBeEquivalentTo(new Regex("abc", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Multiline)); + regex.Should().BeOfType().Which.Should().BeEquivalentTo(new Regex("abc", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Multiline)); } [TestMethod] @@ -171,7 +172,7 @@ public void ReadRegExpObject() _context.Run("var myRegExp = new RegExp('abc', 'gim')"); var regex = _context.GetParameter("myRegExp"); - regex.Should().BeOfType().Which.ShouldBeEquivalentTo(new Regex("abc", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Multiline)); + regex.Should().BeOfType().Which.Should().BeEquivalentTo(new Regex("abc", RegexOptions.ECMAScript | RegexOptions.IgnoreCase | RegexOptions.Multiline)); } [TestMethod] @@ -280,7 +281,7 @@ public void MethodCallWithBadEnumParameter() var obj = new TypedPropertiesClass(); _context.SetParameter("obj", obj); Action action = () => _context.Run("obj.methodWithEnumParameter('dog')"); - action.ShouldThrow("'dog' is not a member of the required enum") + action.Should().Throw("'dog' is not a member of the required enum") .Which.Message.Should().Contain("Argument mismatch"); } diff --git a/Tests/Noesis.Javascript.Tests/ConvertToJavascriptTests.cs b/Tests/Noesis.Javascript.Tests/ConvertToJavascriptTests.cs index e7ef8b4..ace12db 100644 --- a/Tests/Noesis.Javascript.Tests/ConvertToJavascriptTests.cs +++ b/Tests/Noesis.Javascript.Tests/ConvertToJavascriptTests.cs @@ -10,7 +10,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class ConvertToJavascriptTests { - private JavascriptContext _context; + private JavascriptContext _context = null!; [TestInitialize] public void SetUp() @@ -156,7 +156,7 @@ public void SetBoolean() public void SetRegexWithoutECMAScriptFlagThrowsException() { Action action = () => _context.SetParameter("val", new Regex("abc")); - action.ShouldThrow().WithMessage("Only regular expressions with the ECMAScript option can be converted."); + action.Should().Throw().WithMessage("Only regular expressions with the ECMAScript option can be converted."); } [TestMethod] diff --git a/Tests/Noesis.Javascript.Tests/DateTest.cs b/Tests/Noesis.Javascript.Tests/DateTest.cs index 9ea2bbb..891bed7 100644 --- a/Tests/Noesis.Javascript.Tests/DateTest.cs +++ b/Tests/Noesis.Javascript.Tests/DateTest.cs @@ -9,7 +9,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class DateTest { - private JavascriptContext _context; + private JavascriptContext _context = null!; [TestInitialize] public void SetUp() diff --git a/Tests/Noesis.Javascript.Tests/ExceptionTests.cs b/Tests/Noesis.Javascript.Tests/ExceptionTests.cs index 7410481..96cc0a6 100644 --- a/Tests/Noesis.Javascript.Tests/ExceptionTests.cs +++ b/Tests/Noesis.Javascript.Tests/ExceptionTests.cs @@ -10,7 +10,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class ExceptionTests { - private JavascriptContext _context; + private JavascriptContext _context = null!; [TestInitialize] public void SetUp() @@ -29,14 +29,14 @@ public void TearDown() public void ThrowNewError() { Action action = () => _context.Run("throw new Error('asdf');"); - action.ShouldThrowExactly().WithMessage("Error: asdf"); + action.Should().ThrowExactly().WithMessage("Error: asdf"); } [TestMethod] public void ThrowNewErrorWithZeroByte() { Action action = () => _context.Run("throw new Error('asdf\\0qwer');"); - action.ShouldThrowExactly().WithMessage("Error: asdf\0qwer"); + action.Should().ThrowExactly().WithMessage("Error: asdf\0qwer"); } class ClassWithIndexer @@ -54,7 +54,7 @@ public void HandleInvalidArgumentsInIndexerCall() _context.SetParameter("obj", new ClassWithIndexer()); Action action = () => _context.Run("obj[1] = 123 /* passing int when expecting string */"); - action.ShouldThrowExactly().WithMessage("Object of type 'System.Int32' cannot be converted to type 'System.String'."); + action.Should().ThrowExactly().WithMessage("Object of type 'System.Int32' cannot be converted to type 'System.String'."); } class ClassWithMethods @@ -70,7 +70,7 @@ public void HandleInvalidArgumentsInMethodCall() _context.SetParameter("obj", new ClassWithMethods()); Action action = () => _context.Run("obj.Method('hello') /* passing string when expecting int */"); - action.ShouldThrowExactly().WithMessage("Argument mismatch for method \"Method\"."); + action.Should().ThrowExactly().WithMessage("Argument mismatch for method \"Method\"."); } [TestMethod] @@ -79,7 +79,7 @@ public void HandleExceptionWhenInvokingMethodOnManagedObject() _context.SetParameter("obj", new ClassWithMethods()); Action action = () => _context.Run("obj.MethodThatThrows()"); - action.ShouldThrowExactly().WithMessage("Test C# exception"); + action.Should().ThrowExactly().WithMessage("Test C# exception"); } [TestMethod] @@ -88,21 +88,21 @@ public void HandleExceptionWhenInvokingMethodOnManagedObjectWithZeroByteInMessag _context.SetParameter("obj", new ClassWithMethods()); Action action = () => _context.Run("obj.MethodThatThrowsWithZeroByte()"); - action.ShouldThrowExactly().WithMessage("Test C#\0exception"); + action.Should().ThrowExactly().WithMessage("Test C#\0exception"); } [TestMethod] public void StackOverflow() { Action action = () => _context.Run("function f() { f(); }; f();"); - action.ShouldThrowExactly().WithMessage("RangeError: Maximum call stack size exceeded"); + action.Should().ThrowExactly().WithMessage("RangeError: Maximum call stack size exceeded"); } [TestMethod] public void ArgumentChecking() { Action action = () => _context.Run(null); - action.ShouldThrowExactly(); + action.Should().ThrowExactly(); } [TestMethod] @@ -114,7 +114,9 @@ public void TerminateExecutionHasNoRaceCondition() task.Start(); _context.TerminateExecution(true); Action action = () => task.Wait(10 * 1000); - action.ShouldThrowExactly("Because it was cancelled").WithMessage("Execution Terminated"); + action.Should().Throw("Because it was cancelled") + .WithInnerException() + .WithMessage("Execution Terminated"); } } } diff --git a/Tests/Noesis.Javascript.Tests/FlagsTest.cs b/Tests/Noesis.Javascript.Tests/FlagsTest.cs index e9a8140..ec6ca2a 100644 --- a/Tests/Noesis.Javascript.Tests/FlagsTest.cs +++ b/Tests/Noesis.Javascript.Tests/FlagsTest.cs @@ -16,7 +16,7 @@ public void CanUseEngineFlagsToSpecifyStrictMode() using (var context = new JavascriptContext()) context.Run("globalVariable = 1;"); }; - action.ShouldThrowExactly().WithMessage("ReferenceError: globalVariable is not defined"); + action.Should().ThrowExactly().WithMessage("ReferenceError: globalVariable is not defined"); JavascriptContext.SetFlags("--nouse_strict"); } } diff --git a/Tests/Noesis.Javascript.Tests/InstanceOfTest.cs b/Tests/Noesis.Javascript.Tests/InstanceOfTest.cs index bc0756c..3ad758e 100644 --- a/Tests/Noesis.Javascript.Tests/InstanceOfTest.cs +++ b/Tests/Noesis.Javascript.Tests/InstanceOfTest.cs @@ -8,7 +8,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class InstanceOfTest { - private JavascriptContext _context; + private JavascriptContext _context = null!; private class TestClass { @@ -59,7 +59,7 @@ public void UnregisteredObjectInstanceOfTest() { _context.SetParameter("test", new TestClass()); _context.Invoking(x => x.Run("test instanceof Test")) - .ShouldThrow().WithMessage("ReferenceError: Test is not defined"); + .Should().Throw().WithMessage("ReferenceError: Test is not defined"); } [TestMethod] diff --git a/Tests/Noesis.Javascript.Tests/JavascriptFunctionTests.cs b/Tests/Noesis.Javascript.Tests/JavascriptFunctionTests.cs index d415527..4602818 100644 --- a/Tests/Noesis.Javascript.Tests/JavascriptFunctionTests.cs +++ b/Tests/Noesis.Javascript.Tests/JavascriptFunctionTests.cs @@ -9,7 +9,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class JavascriptFunctionTests { - private JavascriptContext _context; + private JavascriptContext _context = null!; [TestInitialize] public void SetUp() @@ -28,7 +28,7 @@ public void GetFunctionExpressionFromJsContext() { _context.Run("a = function(a, b) { return a + b; }"); - JavascriptFunction funcObj = _context.GetParameter("a") as JavascriptFunction; + JavascriptFunction funcObj = (JavascriptFunction)_context.GetParameter("a"); funcObj.Should().NotBeNull(); funcObj.Call(1, 2).Should().BeOfType().Which.Should().Be(3); } @@ -38,7 +38,7 @@ public void EqualsOperatorWorksWithFunctionObjects() { _context.Run("a = function(a, b) { return a + b; }"); - JavascriptFunction funcObj = _context.GetParameter("a") as JavascriptFunction; + JavascriptFunction funcObj = (JavascriptFunction)_context.GetParameter("a"); (funcObj == null).Should().BeFalse(); (funcObj != null).Should().BeTrue(); (null == funcObj).Should().BeFalse(); @@ -50,7 +50,7 @@ public void GetNamedFunctionFromJsContext() { _context.Run("function test(a, b) { return a + b; }"); - JavascriptFunction funcObj = _context.GetParameter("test") as JavascriptFunction; + JavascriptFunction funcObj = (JavascriptFunction)_context.GetParameter("test"); funcObj.Should().NotBeNull(); funcObj.Call(1, 2).Should().BeOfType().Which.Should().Be(3); } @@ -60,7 +60,7 @@ public void GetArrowFunctionExpressionFromJsContext() { _context.Run("a = (a, b) => a + b"); - JavascriptFunction funcObj = _context.GetParameter("a") as JavascriptFunction; + JavascriptFunction funcObj = (JavascriptFunction)_context.GetParameter("a"); funcObj.Should().NotBeNull(); funcObj.Call(1, 2).Should().BeOfType().Which.Should().Be(3); } @@ -72,7 +72,7 @@ public void PassFunctionToMethodInManagedObjectAndUseItToFilterAList() var result = _context.Run("collection.Filter(x => x % 2 === 0)") as IEnumerable; result.Should().NotBeNull(); - result.Should().BeEquivalentTo(2, 4); + result.Should().BeEquivalentTo(new[] { 2, 4 }); } [TestMethod] @@ -80,15 +80,15 @@ public void ExceptionsAreHandledAndWrappedInAJavascriptExceptionObject() { var function = _context.Run("() => { throw new Error('test'); }") as JavascriptFunction; function.Should().NotBeNull(); - Action action = () => function.Call(); - action.ShouldThrowExactly().WithMessage("Error: test"); + Action action = () => function?.Call(); + action.Should().ThrowExactly().WithMessage("Error: test"); } [TestMethod] public void ToStringShouldReturnTheFunctionDefinition() { _context.Run("function test() { return 1; }"); - var funcObj = _context.GetParameter("test") as JavascriptFunction; + var funcObj = (JavascriptFunction)_context.GetParameter("test"); funcObj.Should().NotBeNull(); funcObj.ToString().Should().Be("function test() { return 1; }"); } @@ -97,7 +97,7 @@ public void ToStringShouldReturnTheFunctionDefinition() public void ToStringShouldReturnTheFunctionDefinitionForAnArrowFunction() { _context.Run("var test = (a, b) => a + b"); - var funcObj = _context.GetParameter("test") as JavascriptFunction; + var funcObj = (JavascriptFunction)_context.GetParameter("test"); funcObj.Should().NotBeNull(); funcObj.ToString().Should().Be("(a, b) => a + b"); } @@ -109,7 +109,7 @@ public void ToStringShouldReturnTheFunctionDefinitionWithLineBreaks() function test() { return 1; }"); - var funcObj = _context.GetParameter("test") as JavascriptFunction; + var funcObj = (JavascriptFunction)_context.GetParameter("test"); funcObj.Should().NotBeNull(); funcObj.ToString().Should().Be(@" function test() { @@ -126,17 +126,17 @@ public void CannotUseAFunctionWhenItsContextIsDisposed() { JavascriptFunction function; using (var context = new JavascriptContext()) { - function = context.Run("() => { throw new Error('test'); }") as JavascriptFunction; + function = (JavascriptFunction)context.Run("() => { throw new Error('test'); }"); } Action action = () => function.Call(); - action.ShouldThrowExactly().WithMessage("This function's owning JavascriptContext has been disposed"); + action.Should().ThrowExactly().WithMessage("This function's owning JavascriptContext has been disposed"); } [TestMethod] public void DisposingAFunction() { using (var context = new JavascriptContext()) { - var function = context.Run("() => { throw new Error('test'); }") as JavascriptFunction; + var function = (JavascriptFunction)context.Run("() => { throw new Error('test'); }"); function.Dispose(); } } diff --git a/Tests/Noesis.Javascript.Tests/MemoryLeakTests.cs b/Tests/Noesis.Javascript.Tests/MemoryLeakTests.cs index 4d34368..e0f7f00 100644 --- a/Tests/Noesis.Javascript.Tests/MemoryLeakTests.cs +++ b/Tests/Noesis.Javascript.Tests/MemoryLeakTests.cs @@ -9,29 +9,86 @@ namespace Noesis.Javascript.Tests public class MemoryLeakTests { [TestMethod] - public void RunMemoryLeakTest() + public void SingleLoadMemoryLeakTest() { MemoryUsageLoadInstance(); + + // Force aggressive GC for .NET 8 + for (int i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + GC.Collect(); + long mem = Process.GetCurrentProcess().PrivateMemorySize64; - for (int i = 0; i < 20; i++) { + decimal diffMBytes = (Process.GetCurrentProcess().PrivateMemorySize64 - mem) / 1048576m; + diffMBytes.Should().BeLessThan(1, $"Memory leak detected: {diffMBytes:0.00} MB still allocated"); + } + + [TestMethod] + public void MultipleLoadMemoryLeakTest() + { + MemoryUsageLoadInstance(); + + for (int i = 0; i < 20; i++) + { MemoryUsageLoadInstance(); } + + // Force aggressive GC for .NET 8 + for (int i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } GC.Collect(); - GC.Collect(); + + long mem = Process.GetCurrentProcess().PrivateMemorySize64; + decimal diffMBytes = (Process.GetCurrentProcess().PrivateMemorySize64 - mem) / 1048576m; + diffMBytes.Should().BeLessThan(1, $"Memory leak detected: {diffMBytes:0.00} MB still allocated"); + } - diffMBytes.Should().BeLessThan(1, String.Format("{0:0.00}MB left allocated", diffMBytes)); + [TestMethod] + public void X64MemoryLeakCheck() + { + if (IntPtr.Size != 8) + { + Assert.Inconclusive("Test only relevant for x64 builds"); + } + + MemoryUsageLoadInstance(); + + for (int i = 0; i < 50; i++) + { + MemoryUsageLoadInstance(); + } + + // Force aggressive GC for .NET 8 + for (int i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + GC.Collect(); + + long mem = Process.GetCurrentProcess().PrivateMemorySize64; + + decimal diffMBytes = (Process.GetCurrentProcess().PrivateMemorySize64 - mem) / 1048576m; + // .NET 8 has different GC characteristics, allow up to 3MB for x64 + diffMBytes.Should().BeLessThan(3, $"x64 memory leak detected: {diffMBytes:0.00} MB still allocated"); } private static void MemoryUsageLoadInstance() { - using (JavascriptContext ctx = new JavascriptContext()) { - ctx.Run( - @" + using (JavascriptContext ctx = new JavascriptContext()) + { + ctx.Run(@" buffer = []; -for (var i = 0; i < 100000; i++) { -buffer[i] = 'new string'; +for (var i = 0; i < 100_000; i++) { + buffer[i] = 'new string'; } "); } diff --git a/Tests/Noesis.Javascript.Tests/MultipleAppDomainsTest.cs b/Tests/Noesis.Javascript.Tests/MultipleAppDomainsTest.cs index 4070123..5365c1c 100644 --- a/Tests/Noesis.Javascript.Tests/MultipleAppDomainsTest.cs +++ b/Tests/Noesis.Javascript.Tests/MultipleAppDomainsTest.cs @@ -7,6 +7,7 @@ namespace Noesis.Javascript.Tests [TestClass] public class MultipleAppDomainsTest { +#if NETFRAMEWORK private void ConstructContextInNewDomain() { var domainSetup = new AppDomainSetup { ApplicationBase = AppDomain.CurrentDomain.BaseDirectory }; @@ -21,5 +22,14 @@ public void ConstructionContextInTwoDifferentAppDomainTests() ConstructContextInNewDomain(); ConstructContextInNewDomain(); } +#else + [TestMethod] + public void ConstructionContextInTwoDifferentAppDomainTests() + { + // AppDomain.CreateDomain is not supported in .NET Core/.NET 8 + // This test is only applicable to .NET Framework + Assert.Inconclusive("AppDomain.CreateDomain is not supported in .NET Core/.NET 8"); + } +#endif } } diff --git a/Tests/Noesis.Javascript.Tests/Noesis.Javascript.Tests.csproj b/Tests/Noesis.Javascript.Tests/Noesis.Javascript.Tests.csproj index 74385e8..c37d5c2 100644 --- a/Tests/Noesis.Javascript.Tests/Noesis.Javascript.Tests.csproj +++ b/Tests/Noesis.Javascript.Tests/Noesis.Javascript.Tests.csproj @@ -1,127 +1,40 @@ - - - + - Debug - AnyCPU - {1659A790-7DAC-4E1C-B2C8-B51AC6B0AF87} - Library - Properties - Noesis.Javascript.Tests - Noesis.Javascript.Tests - v4.7.2 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 15.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - + net8.0 + false + true + enable + false + AnyCPU;x64;x86 - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 + + + x64 x64 - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - x64 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full + + x86 - prompt - MinimumRecommendedRules.ruleset Win32 - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - Win32 + + + x64 + x64 + - - ..\..\packages\FluentAssertions.4.19.4\lib\net45\FluentAssertions.dll - - - ..\..\packages\FluentAssertions.4.19.4\lib\net45\FluentAssertions.Core.dll - - - ..\..\packages\MSTest.TestFramework.1.2.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll - - - ..\..\packages\MSTest.TestFramework.1.2.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll - - - - - - + + + + + - - - - - - - - - - - - - - - - + - - - - - - {7ecfed5e-8b33-4065-acbf-ab1050fb0f4c} - JavaScript.Net - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - + + + - - - copy $(ProjectDir)..\..\$(V8Platform)\$(Configuration)\v8.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\..\$(V8Platform)\$(Configuration)\v8_libbase.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\..\$(V8Platform)\$(Configuration)\v8_libplatform.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\..\$(V8Platform)\$(Configuration)\zlib.dll $(ProjectDir)$(OutDir) -copy $(ProjectDir)..\..\$(V8Platform)\$(Configuration)\icu*.* $(ProjectDir)$(OutDir) - - \ No newline at end of file diff --git a/Tests/Noesis.Javascript.Tests/app.config b/Tests/Noesis.Javascript.Tests/app.config deleted file mode 100644 index 37fee69..0000000 --- a/Tests/Noesis.Javascript.Tests/app.config +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Tests/Noesis.Javascript.Tests/packages.config b/Tests/Noesis.Javascript.Tests/packages.config deleted file mode 100755 index 2a63b3f..0000000 --- a/Tests/Noesis.Javascript.Tests/packages.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file