Repository navigation
feat(dotnet): C# interop parity, faster startup and fixes across the napi engines - #33
Merged
Merged
Conversation
The shim declared no BigInt support, so napi_create_bigint_* and napi_get_value_bigint_* were missing and any BigInt crossing the boundary (a .NET long, a WinRT __native_ptr) aborted the process. The JavaScriptCore.dll this package ships exports the BigInt C API, so the shim now implements them on it and napi_typeof reports napi_bigint.
- nested types in a namespace named after their outer type (Outer.Inner) - type parameters on generic classes and methods, plus static of() - long/ulong as number | bigint, byte[] as Uint8Array (and what JS can pass) - optional and params parameters, out/ref references, and the [result, ...outs] overload for out parameters left off - BCL delegates, Task and Nullable as JS function, PromiseLike and T | null - protected members, abstract classes, virtual properties as accessors so JS subclasses can override them - parameter names read by sequence number (a return row no longer shifts them)
…es from JS The .NET JS layer (NSWinRT.dotnet and JS subclasses of .NET/WinRT classes) was hand-copied into both engines; it now lives once in runtime/src/js and both include it. What C# code can now do from JS, on the classic and all napi engines: - fields, constants and enum members (DayOfWeek.Monday, tuple Item1, struct fields), read-only ones refusing assignment - nested types as Outer.Inner - one JS object per .NET object: the bridge hands out one handle per object and counts its exports, so a release racing a new export can't drop it - long/ulong beyond 2^53 as BigInt both ways; BigInt arguments were null - byte[] (and Memory<byte>, ArraySegment<byte>) as Uint8Array; typed arrays, ArrayBuffers and DataViews as arrays, Memory<T> or Stream - optional/default parameters, named arguments (NSWinRT.dotnet.named), ref/out through NSWinRT.interop.out() and [result, ...outs] when out parameters are left off, as WinRT methods do - generic types (List.of(System.String)) and methods (inferred, or .of(T)) - extension methods (list.Where(fn).ToList()) - events: obj.Event = fn replaces the handler as a WinRT event does, and add_X/remove_X remove the delegate that was added for a function - string-keyed dictionaries by key (dict.key) - errors carrying the .NET type, stack and exception (error.nativeException); faulted Tasks reject with an Error; JS errors reach C# with their stack, from void callbacks too; unhandled .NET exceptions reach __onUncaughtError - protected members and generic virtuals in JS subclasses - toString() is the object's ToString() - NSWinRT.dotnet.loadAssembly(path); a type in an assembly loaded later now resolves (failed lookups were cached forever) Also fixes the fallback for an overload that isn't cached, which passed a pooled oversized array to MethodInfo.Invoke and always threw. Tests: dotnet-bridge-tests/InteropTests.cs (136 xunit), test-app tests/csharp-interop.js (42 specs; classic 131/131, napi V8 123/123; QuickJS, Hermes and JSC fail only pre-existing Lifetime specs).
- The namespace map JS asks for at startup is read from assembly metadata instead of loading every DLL of the app and listing its types: the first bridge call of the test app went from ~505 ms to ~124 ms (CLR start included). Paths the map lists as namespaces no longer ask the bridge for type members (each miss scanned every assembly). - Methods start on MethodInvoker and get a compiled delegate after 32 calls: a method's first call went from ~80 us (Expression.Compile) to ~0.3 us; warm calls cost 1-2 ns more. - Calls from .NET into JS reuse a per-thread request buffer and write the instance and member of an overridden member directly: 200/176/424 B -> 80/56/80 B per delegate / void delegate / override call, overrides 233 -> 167 ns (dotnet-bridge-benchmarks CallbackBenchmarks). - The app build republishes the bridge when any of its sources changes, not only Bridge.cs.
The classic engine keeps V8's code cache for app scripts; the napi engines compiled every script from source on every launch. Each now keeps compiled code in the app's local folder, keyed by script name and source (and engine build), so a later launch skips compiling the bundle: - V8: ScriptCompiler code cache, made after the first run (covers the functions run at startup); V8 rejects a stale one and it is rewritten. - QuickJS: bytecode from a compile-only eval (JS_WriteObject/JS_ReadObject), checksummed, keyed to the engine build. - Hermes: its own jsr script cache. Hermes compiles large sources lazily and doesn't store those, so it helps module-sized scripts; big bundles need hermesc at build time. CommonJS modules compile through a new __nsCompileScript(code, filename) instead of new Function, so required modules (vendor.js) are cached too. require() of a 9 MB script in the test app, first launch -> later launches: V8 232 -> ~64 ms, QuickJS 871 -> ~350 ms.
What the Android runtime's static binding generator gives Java code:
constructing a JS-implemented class by type, the JS object made on demand.
- NSWinRT.dotnet.typeOf(JsClass) returns the class's .NET type, named
NSWinRTDynamicProxies.<ClassName> (new JsClass() from JS uses the same
type). Activator.CreateInstance, a DI container or
Type.GetType("NSWinRTDynamicProxies.X, NSWinRTDynamicProxies") can create
it; the emitted constructor asks the JS class for the instance's JS object
(its init() runs, as for an object revived after collection).
- Emitted types are named after their JS class instead of
<Base>_JsProxy_<n> when the class has a name.
Specs: csharp-interop.js "JS classes created by .NET code" (classic 134/134,
napi V8 126/126).
…n QuickJS
- Hermes and JSC (in this host) never run FinalizationRegistry callbacks, so
.NET objects JS had dropped were never released there. Their WeakRefs do
clear: every 250 ms at most, a bridge call also releases the handles of
collected proxies and JS subclass instances (a callback that does run finds
nothing left to do).
- QuickJS frees an object as soon as nothing holds it strongly, so the JS
object made for an instance only native code held (after its first one was
collected, or one .NET created) was gone before the caller got it. It is
now held until handed out.
Fixes the Lifetime specs that failed on QuickJS ("keeps working when only
native code holds the instance") and Hermes ("releases instances neither JS
nor native code references"): classic 134/134, napi V8 126/126, QuickJS and
Hermes 129/129. JSC still fails that release spec (its gc() only hints at a
collection) and intermittently crashes in extend-winrt, both pre-existing.
A weak napi_ref tracked its object by address in a set, cleared when a holder object attached to the target was finalized. Holder and target die together but are swept separately, so a dead object's address could be reused (by an external, a new wrapper) while still in the set: the ref then returned whatever lived there. The runtime's WinRT identity cache is built on weak refs, so after a collection a WinRT object could come back as a bare external, or as a fresh wrapper without its JS subclass prototype (XAML then called the native MeasureOverride from super.MeasureOverride(): the intermittent 0xC000027B crash in extend-winrt). References now hold their object through JavaScriptCore's JSWeak handles, which the collector clears before the memory is reused; no holder object or property is added per referenced object anymore. gc() runs a synchronous full collection (JSGarbageCollect only hinted at one, so the Lifetime specs never really collected on JSC). JSC test app: 129/129, three runs. NSWIN_BENCH ~10% faster across the board.
Hermes compiles big sources lazily and its script cache keeps none of them, so the Hermes runtime compiled the app's bundles on every launch. - Release builds of an @nativescript/windows-hermes app run hermesc over the app's JavaScript (tools/hermes-precompile.ps1, from the app csproj): <file>.hbc for scripts the host runs as is (runtime.js, vendor.js, bundle.js, the package.json main), <file>.cjs.hbc for modules, compiled inside the CommonJS wrapper. Unchanged files are skipped. - The runtime runs that bytecode (jsr_run_script_buffer) when it is at least as new as the script, so a livesync push runs from source. Entry scripts the host names by file name are found in the app folder. - hermesc.exe is vendored from the same Microsoft.JavaScript.Hermes package as hermes.dll (bytecode is version-specific) and staged into the framework tools by build.ps1 -Engine hermes; the test app picks it up too. require() of a 9 MB script in the test app: ~530-660 ms -> ~33 ms. Hermes test app as a Release build, running from bytecode: 129/129.
The metadata crate depends on metadata-filter; the engine packages' lockfiles predated that.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
C# interop from JavaScript now covers what the Android runtime offers for Java, plus the C# features Java has no counterpart for. The .NET bridge and the napi engines also start faster. Each commit stands on its own; they are listed in order below.
Changes
fix(jsc): BigInt values in the JSC Node-API shim. The shim had no BigInt functions, so any BigInt crossing the boundary (a .NETlong, a WinRT__native_ptr) aborted the process. They are now implemented on the BigInt C API that the bundledJavaScriptCore.dllexports.feat(typings): .NET typings that match the runtime. Nested types asOuter.Inner, type parameters andof()for generics,number | bigintfor longs,Uint8Arrayforbyte[], optional,paramsand out/ref parameters, BCL delegates and Tasks as JS function and promise types, protected members, and virtual properties as accessors so subclasses can override them.feat(dotnet): fields, generics, events and out parameters of .NET types from JS. TheNSWinRT.dotnetJS layer was hand-copied into both engines; it now lives once inruntime/src/jsand both include it. New on both engines:DayOfWeek.Monday, tupleItem1), and nested typeslong/ulongbeyond 2^53 as BigInt in both directions (BigInt arguments used to arrive asnull), andbyte[]asUint8Array, with typed arrays, ArrayBuffers and DataViews accepted as argumentsNSWinRT.dotnet.named), ref/out throughNSWinRT.interop.out(), and[result, ...outs]when out arguments are left off, as WinRT methods doList.of(System.String)), generic methods (inferred, or.of(T)) and extension methods (list.Where(fn).ToList())obj.Event = fnreplaces the handler like a WinRT event, andremove_X(fn)removes the delegate added forfnerror.nativeException; faulted Tasks reject with an Error; JS errors reach C# with their stack; unhandled .NET exceptions reach__onUncaughtErrortoString()asToString(), string-keyed dictionaries asdict.key, andNSWinRT.dotnet.loadAssembly(path)It also fixes the fallback for an overload that isn't cached, which passed an oversized pooled array to
MethodInfo.Invokeand always threw.perf(dotnet): faster bridge startup, first calls and calls into JS.MethodInvokerand get a compiled delegate after 32 calls. A method's first call went from ~80 µs to ~0.3 µs.Bridge.cs.perf(napi): compiled-script caches. V8 (code cache) and QuickJS (bytecode) keep compiled scripts in the app's local folder, and Hermes's own script cache is turned on.require()compiles modules through a new__nsCompileScriptinstead ofnew Function, so required chunks are cached too. A 9 MB script'srequire()on later launches: V8 232 → ~64 ms, QuickJS 871 → ~350 ms.feat(dotnet): .NET code can create instances of JS classes.NSWinRT.dotnet.typeOf(JsClass)returns the class's .NET type, namedNSWinRTDynamicProxies.<ClassName>.Activator.CreateInstance, a DI container orType.GetTypeby name can create it, and the bridge asks the JS class for each instance's JS object. This covers what Android's static binding generator is for.fix(dotnet): release .NET handles on Hermes/JSC; revived JS objects on QuickJS. Hermes and JSC never runFinalizationRegistrycallbacks in this host, so .NET handles were never released there; the JS layer now also sweeps collected proxies. QuickJS frees an object as soon as nothing holds it strongly, so a revived subclass instance was gone before the caller got it. That fixes Lifetime specs that already failed onmain.fix(jsc): weak references that survive a garbage collection. A weaknapi_reftracked its object by address, so after a collection it could return whatever object reused that memory. That lost WinRT object identity and caused the intermittent0xC000027Bcrash in the WinRT subclass specs. References now use JavaScriptCore'sJSWeakhandles, and the test-onlygc()runs a real collection. The JSC benchmark is ~10% faster.perf(hermes): precompile the app to Hermes bytecode. Hermes compiles big sources lazily and its cache keeps none of them. Release builds of a Hermes app now runhermescover the app (tools/hermes-precompile.ps1, from the app csproj), and the runtime runs the bytecode when it isn't older than the script, so a livesync edit runs from source. A 9 MB script'srequire(): ~530–660 ms → ~33 ms.chore: the engine packages' lockfiles gainmetadata-filter, which themetadatacrate already depends on.Testing
Windows 11, x64, test app (
pwsh test-app/run.ps1, with-EngineDllfor the napi engines):The new specs are in
test-app/app/tests/csharp-interop.js, with fixtures inTestFixtures/Interop.cs.dotnet-bridge-tests: 136/136, 16 of them new (InteropTests.cs).windows-napinpm test: 427/427. The generated typings type-check undertsc --strictagainst code mirroring the specs.Timings come from the test app and from
dotnet-bridge-benchmarks(the newCallbackBenchmarksandEntryBenchmarks).Notes
packages/windows-hermes/vendor/x64/tools/hermesc.exe(3.6 MB) is vendored from the sameMicrosoft.JavaScript.Hermespackage version ashermes.dll, because bytecode is version-specific. The vendor README has the refresh steps.template/framework/dotnet-bridgeand the other built framework files aren't regenerated here;template/build.ps1does that.