From 5a7571999c9beb36517ac2a4d66457876c98582e Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Mon, 5 Oct 2026 00:07:21 -0400 Subject: [PATCH] perf: faster .NET bridge calls; fix overloads, collections, params, dates and callbacks Performance (JS -> runtime -> DotNetBridge -> BCL): - The bridge makes a COM pointer eagerly only for C#/WinRT objects and JS subclass instances; other objects get one when first passed to a WinRT API. The wrapper was two thirds of the cost of constructing or returning an object. - Responses go to a reusable per-thread buffer instead of a fresh AllocHGlobal per call. - Type, assembly and member names come from a per-thread decode cache. Argument strings are no longer string.Intern'd, which took a global lock and kept every string passed to .NET alive. - Box, ResolveType and property lookups no longer reflect or allocate per call. - JS proxies look members up in per-type maps instead of scanning member lists, and reuse method functions and resolved assemblies. Responses are read in place in Rust. Bridge-only cost: property get 196 -> 98 ns, static call 397 -> 213 ns, overloaded call 4.3 us -> 424 ns, construct + release 1331 -> 284 ns. Fixes: - Overloads and constructors resolve by how well the arguments fit, not the first one they convert to: Math.Abs(-0.5) returned 0, Math.Max(3.5, 2) returned 4, Math.Abs(-128) could overflow and new StringBuilder('q') threw. - Collections other than arrays (List, ArrayList, Dictionary, ObservableCollection) stay .NET objects with their methods, and act like arrays in JS: length, list[i], for...of, map, filter, JSON.stringify. They used to come back as array copies, so list.Add was impossible. - DateTime, DateTimeOffset and TimeSpan are objects with their members (span.TotalMilliseconds) instead of ToString() strings; String(value) still gives that string. A JS Date converts to DateTime/DateTimeOffset parameters, and strings parse for those and for Guid. - params arrays work (String.Join(',', 'a', 'b')), and JS arrays convert to array and collection parameters instead of being stringified. - A JS function passed as a delegate gets .NET objects as arguments, not raw handles. - Plain JS fields set on .NET objects and namespaces are kept; '' + obj no longer throws. - get_Item(key) with an argument calls the indexer; indexers aren't listed as properties. --- dotnet-bridge-benchmarks/AbiBenchmarks.cs | 109 ++++++ dotnet-bridge-benchmarks/BridgeBenchmarks.cs | 2 +- dotnet-bridge-tests/MarshalingTests.cs | 231 +++++++++++ dotnet-bridge-tests/OverloadSelectionTests.cs | 149 ++++++++ dotnet-bridge/BinaryProtocol.cs | 93 ++++- dotnet-bridge/Bridge.BinaryDispatch.cs | 360 ++++++++++++++++-- dotnet-bridge/Bridge.Dispatch.cs | 167 ++++++-- dotnet-bridge/Bridge.JsDelegate.cs | 3 +- dotnet-bridge/Bridge.cs | 23 +- dotnet-bridge/DispatchTypes.cs | 4 +- integration-tests/tests/new_features.rs | 98 +++++ runtime/src/dotnet.rs | 20 +- runtime/src/global_fns.rs | 343 ++++++++++++----- runtime/src/napi_engine/dotnet.rs | 305 +++++++++++---- 14 files changed, 1665 insertions(+), 242 deletions(-) create mode 100644 dotnet-bridge-benchmarks/AbiBenchmarks.cs create mode 100644 dotnet-bridge-tests/MarshalingTests.cs create mode 100644 dotnet-bridge-tests/OverloadSelectionTests.cs diff --git a/dotnet-bridge-benchmarks/AbiBenchmarks.cs b/dotnet-bridge-benchmarks/AbiBenchmarks.cs new file mode 100644 index 0000000..3f2d2c9 --- /dev/null +++ b/dotnet-bridge-benchmarks/AbiBenchmarks.cs @@ -0,0 +1,109 @@ +using System; +using System.Buffers; +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using NativeScriptBridge; + +/// +/// Full managed cost of one bridge call as the runtime pays it: the request goes through the +/// [UnmanagedCallersOnly] InvokeBinary entry point (via an unmanaged function pointer, like Rust +/// calls it) and the unmanaged response buffer is freed through Free. +/// +/// dotnet run -c Release -- --filter *AbiBenchmarks* --job short +/// +[MemoryDiagnoser] +public unsafe class AbiBenchmarks +{ + private static readonly delegate* unmanaged[Cdecl] s_invoke = &Bridge.InvokeBinary; + private static readonly delegate* unmanaged[Cdecl] s_free = &Bridge.Free; + + private byte[] _staticCbrt = null!; + private byte[] _staticCbrtAsm = null!; + private byte[] _overloadMax = null!; + private byte[] _overloadGetExtension = null!; + private byte[] _stringArg = null!; + private byte[] _propGet = null!; + private byte[] _propSet = null!; + private byte[] _ctor = null!; + private byte[] _returnHandle = null!; + + [GlobalSetup] + public void Setup() + { + Bridge.ClearCaches(); + _staticCbrt = Static("System.Math", "", "Cbrt", w => { w.WriteByte(0x03); w.WriteI32(27); }); + _staticCbrtAsm = Static("System.Math", "System.Runtime", "Cbrt", w => { w.WriteByte(0x03); w.WriteI32(27); }); + _overloadMax = Static("System.Math", "", "Max", w => { w.WriteByte(0x03); w.WriteI32(300); w.WriteByte(0x03); w.WriteI32(7); }, 2); + _overloadGetExtension = Static("System.IO.Path", "", "GetExtension", w => { w.WriteByte(0x05); w.WriteString16("file.txt"); }); + _stringArg = Static("System.String", "", "IsNullOrEmpty", w => { w.WriteByte(0x05); w.WriteString16("abc"); }); + _ctor = Packet(w => { w.WriteByte(0x03); w.WriteString16("System.Text.StringBuilder"); w.WriteString16(""); w.WriteByte(0); }); + _returnHandle = Static("System.Diagnostics.Stopwatch", "", "StartNew", null, 0); + + var sb = Bridge.Dispatch(new InvokeRequest(null, "System.Text.StringBuilder", ".ctor", null, null)).HandleId(); + _propGet = Packet(w => { w.WriteByte(0x01); w.WriteI32(sb); w.WriteString16("get_Length"); w.WriteByte(0); }); + _propSet = Packet(w => { w.WriteByte(0x01); w.WriteI32(sb); w.WriteString16("set_Length"); w.WriteByte(1); w.WriteByte(0x03); w.WriteI32(0); }); + + foreach (var p in new[] { _staticCbrt, _staticCbrtAsm, _overloadMax, _overloadGetExtension, _stringArg, _propGet, _propSet }) + { + var resp = Call(p); + if (resp[0] == 0xFF) throw new InvalidOperationException("bridge error: " + System.Text.Encoding.UTF8.GetString(resp, 5, resp.Length - 5)); + } + } + + [Benchmark] public int StaticCall() => CallLen(_staticCbrt); + [Benchmark] public int StaticCallWithAssembly() => CallLen(_staticCbrtAsm); + [Benchmark] public int OverloadedInt() => CallLen(_overloadMax); + [Benchmark] public int OverloadedString() => CallLen(_overloadGetExtension); + [Benchmark] public int StringArg() => CallLen(_stringArg); + [Benchmark] public int PropertyGet() => CallLen(_propGet); + [Benchmark] public int PropertySet() => CallLen(_propSet); + + [Benchmark] + public void ConstructRelease() => Release(Call(_ctor)); + + [Benchmark] + public void ReturnHandleRelease() => Release(Call(_returnHandle)); + + private void Release(byte[] handleResponse) + { + var id = BitConverter.ToInt32(handleResponse, 1); + CallLen(Packet(w => { w.WriteByte(0x04); w.WriteI32(id); })); + } + + private static int CallLen(byte[] packet) + { + byte* resp = null; + int len = 0; + fixed (byte* p = packet) s_invoke(p, packet.Length, &resp, &len); + s_free(resp); + return len; + } + + private static byte[] Call(byte[] packet) + { + byte* resp = null; + int len = 0; + fixed (byte* p = packet) s_invoke(p, packet.Length, &resp, &len); + var bytes = new ReadOnlySpan(resp, len).ToArray(); + s_free(resp); + return bytes; + } + + private static byte[] Static(string type, string assembly, string method, Action? args, int argCount = 1) + => Packet(w => + { + w.WriteByte(0x02); + w.WriteString16(type); + w.WriteString16(assembly); + w.WriteString16(method); + w.WriteByte((byte)argCount); + args?.Invoke(w); + }); + + private static byte[] Packet(Action write) + { + var buf = new ArrayBufferWriter(64); + write(new BinWriter(buf)); + return buf.WrittenSpan.ToArray(); + } +} diff --git a/dotnet-bridge-benchmarks/BridgeBenchmarks.cs b/dotnet-bridge-benchmarks/BridgeBenchmarks.cs index cea42f0..fcb6dac 100644 --- a/dotnet-bridge-benchmarks/BridgeBenchmarks.cs +++ b/dotnet-bridge-benchmarks/BridgeBenchmarks.cs @@ -7,7 +7,7 @@ using BenchmarkDotNet.Running; using NativeScriptBridge; -BenchmarkRunner.Run(args: args); +BenchmarkSwitcher.FromTypes([typeof(BridgeBenchmarks), typeof(AbiBenchmarks)]).Run(args); /// /// Measures the hot-path dispatch cost for the JSON and binary protocols. diff --git a/dotnet-bridge-tests/MarshalingTests.cs b/dotnet-bridge-tests/MarshalingTests.cs new file mode 100644 index 0000000..e67dcda --- /dev/null +++ b/dotnet-bridge-tests/MarshalingTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using NativeScriptBridge; +using Xunit; + +namespace DotNetBridgeTests; + +public static class MarshalingFixtures +{ + public static int Sum(params int[] values) => values.Sum(); + public static string Describe(string label, params object[] values) => label + ":" + values.Length; + public static int Total(List values) => values.Sum(); + public static int Count(IEnumerable values) => values.Count(); + public static List Names() => ["a", "b"]; + public static string[] NameArray() => ["x", "y"]; + public static DateTime Epoch() => new(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + public static int YearOf(DateTime value) => value.Year; + public static double HoursOf(DateTimeOffset value) => value.UtcDateTime.Hour; + public static int GuidVersion(Guid value) => value.Version; +} + +[Collection("Bridge")] +/// Arrays and params arrays as arguments, collections and date/time values as results. +public sealed class MarshalingTests : IDisposable +{ + public MarshalingTests() => Bridge.ClearCaches(); + public void Dispose() => Bridge.ClearCaches(); + + private const string Fixtures = "DotNetBridgeTests.MarshalingFixtures"; + + [Fact] + public void ParamsArray_TakesTrailingArguments() + { + var result = Static("System.String", "Join", w => + { + w.WriteByte(0x05); w.WriteString16(","); + w.WriteByte(0x05); w.WriteString16("a"); + w.WriteByte(0x05); w.WriteString16("b"); + w.WriteByte(0x05); w.WriteString16("c"); + }, 4); + Assert.Equal("a,b,c", result.PrimitiveValue()); + } + + [Fact] + public void ParamsArray_ConvertsToElementType() + { + var result = Static(Fixtures, "Sum", w => + { + w.WriteByte(0x03); w.WriteI32(1); + w.WriteByte(0x03); w.WriteI32(2); + w.WriteByte(0x03); w.WriteI32(3); + }, 3); + Assert.Equal(6, result.PrimitiveValue()); + } + + [Fact] + public void ParamsArray_CanBeEmpty() + { + var result = Static(Fixtures, "Describe", w => { w.WriteByte(0x05); w.WriteString16("none"); }); + Assert.Equal("none:0", result.PrimitiveValue()); + } + + [Fact] + public void ArrayArgument_ForArrayParameter() + { + var result = Static("System.String", "Join", w => + { + w.WriteByte(0x05); w.WriteString16("-"); + WriteArray(w, "a", "b"); + }, 2); + Assert.Equal("a-b", result.PrimitiveValue()); + } + + [Fact] + public void ArrayArgument_ForListParameter() + { + var result = Static(Fixtures, "Total", w => + { + w.WriteByte(0x07); w.WriteU32(3); + w.WriteByte(0x03); w.WriteI32(4); + w.WriteByte(0x03); w.WriteI32(5); + w.WriteByte(0x04); w.WriteF64(6); + }); + Assert.Equal(15, result.PrimitiveValue()); + } + + [Fact] + public void ArrayArgument_ForEnumerableParameter() + { + var result = Static(Fixtures, "Count", w => WriteArray(w, "a", "b", "c")); + Assert.Equal(3, result.PrimitiveValue()); + } + + [Fact] + public void ListResult_IsAnObjectNotACopy() + { + var result = Static(Fixtures, "Names", null, 0); + Assert.Equal(DispatchKind.Handle, result.Kind()); + Assert.True(Bridge.s_handles.TryGetValue(result.HandleId(), out var list)); + Assert.IsType>(list); + } + + [Fact] + public void ArrayResult_IsStillACopy() + { + Assert.Equal(DispatchKind.Collection, Static(Fixtures, "NameArray", null, 0).Kind()); + } + + [Fact] + public void CollectionHelpers_ReadAndWriteAList() + { + var list = new List { 10, 20, 30 }; + Assert.Equal(7, Bridge.CollectionKind(list)); + Assert.Equal(3, Bridge.CollectionCount(list)); + Assert.Equal(20, Bridge.ItemAt(list, 1)); + Assert.Null(Bridge.ItemAt(list, 3)); + Bridge.SetItemAt(list, 1, 25.0); + Assert.Equal(25, list[1]); + Assert.Equal(new object?[] { 10, 25, 30 }, Bridge.CollectionItems(list)); + } + + [Fact] + public void CollectionHelpers_ReadOnlyAndEnumerables() + { + IReadOnlyList readOnly = new[] { "a" }.AsReadOnly(); + Assert.Equal(7, Bridge.CollectionKind(readOnly)); + Assert.Equal("a", Bridge.ItemAt(readOnly, 0)); + Assert.Equal(1, Bridge.CollectionKind(Enumerable.Range(0, 3).Select(i => i))); + Assert.Equal(3, Bridge.CollectionCount(Enumerable.Range(0, 3).Select(i => i))); + Assert.Equal(0, Bridge.CollectionKind("text")); + Assert.Equal(0, Bridge.CollectionKind(new object())); + } + + [Fact] + public void DateTimeResult_IsAnObject() + { + var result = Static(Fixtures, "Epoch", null, 0); + Assert.Equal(DispatchKind.Handle, result.Kind()); + Assert.True(Bridge.s_handles.TryGetValue(result.HandleId(), out var value)); + Assert.Equal(2020, ((DateTime)value!).Year); + } + + [Fact] + public void IsoString_ForDateTimeParameter() + { + var result = Static(Fixtures, "YearOf", w => { w.WriteByte(0x05); w.WriteString16("2031-05-06T07:08:09.000Z"); }); + Assert.Equal(2031, result.PrimitiveValue()); + } + + [Fact] + public void IsoString_ForDateTimeOffsetParameter() + { + var result = Static(Fixtures, "HoursOf", w => { w.WriteByte(0x05); w.WriteString16("2031-05-06T07:08:09.000Z"); }); + Assert.Equal(7.0, result.PrimitiveValue()); + } + + [Fact] + public void String_ForGuidParameter() + { + var result = Static(Fixtures, "GuidVersion", w => { w.WriteByte(0x05); w.WriteString16(Guid.NewGuid().ToString()); }); + Assert.Equal(4, result.PrimitiveValue()); + } + + [Fact] + public void IndexerAccessor_WithArgument_IsAMethodCall() + { + var list = new List { "first" }; + var id = 900001; + Bridge.s_handles[id] = list; + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x01); + w.WriteI32(id); + w.WriteString16("get_Item"); + w.WriteByte(1); + w.WriteByte(0x03); w.WriteI32(0); + var r = new BinReader(buf.WrittenSpan); + Assert.Equal("first", Bridge.DispatchBin(ref r).PrimitiveValue()); + } + + [Fact] + public void Indexers_AreNotListedAsProperties() + { + var buf = new ArrayBufferWriter(64); + Bridge.BuildMembersResult(typeof(List)).WriteAsBin(buf); + Assert.Equal(0x08, buf.WrittenSpan[0]); + var methods = ReadStringArray(buf.WrittenSpan[1..], out var consumed); + var properties = ReadStringArray(buf.WrittenSpan[(1 + consumed)..], out _); + Assert.Contains("Count", properties); + Assert.DoesNotContain("Item", properties); + Assert.Contains("Add", methods); + } + + private static string[] ReadStringArray(ReadOnlySpan span, out int consumed) + { + var count = BitConverter.ToUInt16(span); + var pos = 2; + var names = new string[count]; + for (int i = 0; i < count; i++) + { + var len = BitConverter.ToUInt16(span[pos..]); + names[i] = System.Text.Encoding.UTF8.GetString(span.Slice(pos + 2, len)); + pos += 2 + len; + } + consumed = pos; + return names; + } + + private static void WriteArray(BinWriter w, params string[] items) + { + w.WriteByte(0x07); + w.WriteU32((uint)items.Length); + foreach (var item in items) { w.WriteByte(0x05); w.WriteString16(item); } + } + + private static DispatchResult Static(string type, string method, Action? args, int argCount = 1) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x02); + w.WriteString16(type); + w.WriteString16(""); + w.WriteString16(method); + w.WriteByte((byte)argCount); + args?.Invoke(w); + var r = new BinReader(buf.WrittenSpan); + return Bridge.DispatchBin(ref r); + } +} diff --git a/dotnet-bridge-tests/OverloadSelectionTests.cs b/dotnet-bridge-tests/OverloadSelectionTests.cs new file mode 100644 index 0000000..dea0e80 --- /dev/null +++ b/dotnet-bridge-tests/OverloadSelectionTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Buffers; +using System.Text; +using NativeScriptBridge; +using Xunit; + +namespace DotNetBridgeTests; + +public static class OverloadFixtures +{ + public static string Pick(int value) => "int"; + public static string Pick(long value) => "long"; + public static string Pick(double value) => "double"; + public static string Pick(string value) => "string"; + public static string Pick(object value) => "object"; + + public static string Narrow(byte value) => "byte"; + public static string Narrow(short value) => "short"; + + public static string Echo(string value) => value; +} + +[Collection("Bridge")] +/// Overloads with the same parameter count resolve to the one whose parameter types best fit the +/// arguments, not the first one they happen to convert to. +public sealed class OverloadSelectionTests : IDisposable +{ + public OverloadSelectionTests() => Bridge.ClearCaches(); + public void Dispose() => Bridge.ClearCaches(); + + [Fact] + public void MathAbs_Double_PicksDoubleOverload() + { + var result = Static("System.Math", "Abs", w => { w.WriteByte(0x04); w.WriteF64(-0.5); }); + Assert.Equal(0.5, result.PrimitiveValue()); + } + + [Fact] + public void MathAbs_IntAtSByteMin_PicksIntOverload() + { + // Abs(sbyte) would throw OverflowException for -128. + var result = Static("System.Math", "Abs", w => { w.WriteByte(0x03); w.WriteI32(-128); }); + Assert.Equal(128, result.PrimitiveValue()); + } + + [Fact] + public void MathMax_Ints_PicksIntOverload() + { + var result = Static("System.Math", "Max", w => + { + w.WriteByte(0x03); w.WriteI32(300); + w.WriteByte(0x03); w.WriteI32(7); + }, argCount: 2); + Assert.Equal(300, result.PrimitiveValue()); + } + + [Theory] + [InlineData((byte)0x03, "int")] + [InlineData((byte)0x04, "double")] + [InlineData((byte)0x05, "string")] + [InlineData((byte)0x02, "object")] + public void ExactArgumentTypeWins(byte tag, string expected) + { + var result = Static("DotNetBridgeTests.OverloadFixtures", "Pick", w => + { + w.WriteByte(tag); + if (tag == 0x03) w.WriteI32(5); + else if (tag == 0x04) w.WriteF64(5.5); + else if (tag == 0x05) w.WriteString16("five"); + }); + Assert.Equal(expected, result.PrimitiveValue()); + } + + [Fact] + public void LargeIntegralDouble_PicksLong() + { + var result = Static("DotNetBridgeTests.OverloadFixtures", "Pick", w => { w.WriteByte(0x04); w.WriteF64(3e9); }); + // double is still the exact match for a double argument. + Assert.Equal("double", result.PrimitiveValue()); + } + + [Fact] + public void NarrowingPicksTheTypeTheValueFits() + { + var small = Static("DotNetBridgeTests.OverloadFixtures", "Narrow", w => { w.WriteByte(0x03); w.WriteI32(200); }); + Assert.Equal("byte", small.PrimitiveValue()); + var large = Static("DotNetBridgeTests.OverloadFixtures", "Narrow", w => { w.WriteByte(0x03); w.WriteI32(-200); }); + Assert.Equal("short", large.PrimitiveValue()); + } + + [Fact] + public void StringOverload_SkipsSpanOverload() + { + var result = Static("System.IO.Path", "GetExtension", w => { w.WriteByte(0x05); w.WriteString16("file.txt"); }); + Assert.Equal(".txt", result.PrimitiveValue()); + } + + [Fact] + public void Constructor_StringArgument_PicksStringOverload() + { + // StringBuilder(int) comes first by declaration; "q" must reach StringBuilder(string). + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x03); + w.WriteString16("System.Text.StringBuilder"); + w.WriteString16(""); + w.WriteByte(1); + w.WriteByte(0x05); w.WriteString16("q"); + var r = new BinReader(buf.WrittenSpan); + var handle = Bridge.DispatchBin(ref r).HandleId(); + Assert.True(Bridge.s_handles.TryGetValue(handle, out var sb)); + Assert.Equal("q", sb!.ToString()); + } + + [Fact] + public void StringArguments_AreNotInterned() + { + var unique = "arg-" + Guid.NewGuid().ToString("N"); + var result = Static("DotNetBridgeTests.OverloadFixtures", "Echo", w => { w.WriteByte(0x05); w.WriteString16(unique); }); + Assert.Equal(unique, result.PrimitiveValue()); + Assert.Null(string.IsInterned(unique)); + } + + [Fact] + public void NameCache_ReturnsTheDecodedName() + { + var a = Encoding.UTF8.GetBytes("System.Math"); + var b = Encoding.UTF8.GetBytes("System.Text"); + Assert.Equal("System.Math", NameCache.Get(a)); + Assert.Equal("System.Text", NameCache.Get(b)); + Assert.Same(NameCache.Get(a), NameCache.Get(a)); + Assert.Equal("Größe", NameCache.Get(Encoding.UTF8.GetBytes("Größe"))); + Assert.Equal("", NameCache.Get(ReadOnlySpan.Empty)); + } + + private static DispatchResult Static(string type, string method, Action args, int argCount = 1) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x02); + w.WriteString16(type); + w.WriteString16(""); + w.WriteString16(method); + w.WriteByte((byte)argCount); + args(w); + var r = new BinReader(buf.WrittenSpan); + return Bridge.DispatchBin(ref r); + } +} diff --git a/dotnet-bridge/BinaryProtocol.cs b/dotnet-bridge/BinaryProtocol.cs index 11c26c8..83e34be 100644 --- a/dotnet-bridge/BinaryProtocol.cs +++ b/dotnet-bridge/BinaryProtocol.cs @@ -31,6 +31,13 @@ internal sealed class JsJsonValue(string json) public readonly string Json = json; } +// A JS array (tag 0x07): its items, each read like an argument, converted to the array or +// collection type the parameter expects. +internal sealed class JsArrayValue(object?[] items) +{ + public readonly object?[] Items = items; +} + internal ref struct BinReader(ReadOnlySpan buf) { private readonly ReadOnlySpan _buf = buf; @@ -73,9 +80,17 @@ public string ReadString16() var len = ReadU16(); var s = Encoding.UTF8.GetString(_buf.Slice(_pos, len)); _pos += len; - // Intern so repeated method/type names reuse the same heap string. - // Eliminates the allocation on every subsequent warm-path call. - return string.Intern(s); + return s; + } + + // A type, assembly or member name: the same few names arrive on every call, so they come from + // NameCache instead of being decoded (and allocated) again. + public string ReadName16() + { + var len = ReadU16(); + var s = NameCache.Get(_buf.Slice(_pos, len)); + _pos += len; + return s; } public string ReadString32() @@ -84,7 +99,7 @@ public string ReadString32() _pos += 4; var s = Encoding.UTF8.GetString(_buf.Slice(_pos, (int)len)); _pos += (int)len; - return string.Intern(s); + return s; } public uint ReadU32() @@ -100,24 +115,64 @@ public uint ReadU32() if (count == 0) return []; var args = new object?[count]; for (int i = 0; i < count; i++) + args[i] = ReadArg(); + return args; + } + + private object? ReadArg() + { + var tag = ReadByte(); + switch (tag) { - var tag = ReadByte(); - args[i] = tag switch + case 0x00: return null; + case 0x01: return false; + case 0x02: return true; + case 0x03: return ReadI32(); + case 0x04: return ReadF64(); + case 0x05: return ReadString16(); + case 0x06: return new HandleRef(ReadI32()); + case 0x07: { - 0x00 => null, - 0x01 => (object)false, - 0x02 => (object)true, - 0x03 => (object)ReadI32(), - 0x04 => (object)ReadF64(), - 0x05 => (object)ReadString16(), - 0x06 => (object)new HandleRef(ReadI32()), - 0x0A => (object)new WinRtRef(ReadI64()), - 0x0C => (object)new JsFunctionRef(ReadI32()), - 0x0D => (object)new JsJsonValue(ReadString32()), - _ => null, - }; + var items = new object?[ReadU32()]; + for (int i = 0; i < items.Length; i++) items[i] = ReadArg(); + return new JsArrayValue(items); + } + case 0x0A: return new WinRtRef(ReadI64()); + case 0x0C: return new JsFunctionRef(ReadI32()); + case 0x0D: return new JsJsonValue(ReadString32()); + default: return null; } - return args; + } +} + +// Decoded names by their UTF-8 bytes. Per thread and direct-mapped: a lookup takes no lock and a +// collision just decodes the name again. Only names land here, never argument values, so unlike +// string.Intern it can't grow without bound. +internal static class NameCache +{ + private const int Size = 512; + + [ThreadStatic] + private static Entry[]? t_entries; + + private sealed class Entry(byte[] utf8, string value) + { + public readonly byte[] Utf8 = utf8; + public readonly string Value = value; + } + + public static string Get(ReadOnlySpan utf8) + { + if (utf8.IsEmpty) return string.Empty; + var entries = t_entries ??= new Entry[Size]; + var hash = new HashCode(); + hash.AddBytes(utf8); + ref var slot = ref entries[hash.ToHashCode() & (Size - 1)]; + var entry = slot; + if (entry is not null && utf8.SequenceEqual(entry.Utf8)) return entry.Value; + var value = Encoding.UTF8.GetString(utf8); + slot = new Entry(utf8.ToArray(), value); + return value; } } diff --git a/dotnet-bridge/Bridge.BinaryDispatch.cs b/dotnet-bridge/Bridge.BinaryDispatch.cs index a1ca9eb..4596468 100644 --- a/dotnet-bridge/Bridge.BinaryDispatch.cs +++ b/dotnet-bridge/Bridge.BinaryDispatch.cs @@ -27,7 +27,7 @@ public static unsafe int InvokeBinary( var buf = t_responseWriter ??= new ArrayBufferWriter(256); buf.ResetWrittenCount(); res.WriteAsBin(buf); - WriteUnmanaged(buf.WrittenSpan, responsePtr, responseLenPtr); + WriteResponse(buf.WrittenSpan, responsePtr, responseLenPtr); } catch (Exception ex) { @@ -36,6 +36,39 @@ public static unsafe int InvokeBinary( return 0; } + // Per-thread unmanaged buffer InvokeBinary responses are written to, so a call allocates none. + // The runtime reads a response and hands it to Free (which leaves this buffer alone) before it + // makes another call on the thread, including a nested one from a JS callback, so reusing it + // is safe. Large responses still get their own allocation, keeping the buffer small. + [ThreadStatic] + private static unsafe byte* t_responseBuffer; + [ThreadStatic] + private static int t_responseCapacity; + private const int MaxReusedResponse = 64 * 1024; + + private static unsafe void WriteResponse(ReadOnlySpan bytes, byte** outPtr, int* outLen) + { + var needed = bytes.Length + 1; + if (needed > MaxReusedResponse) + { + WriteUnmanaged(bytes, outPtr, outLen); + return; + } + if (needed > t_responseCapacity) + { + var capacity = Math.Max(256, (int)System.Numerics.BitOperations.RoundUpToPowerOf2((uint)needed)); + t_responseBuffer = (byte*)NativeMemory.Realloc(t_responseBuffer, (nuint)capacity); + t_responseCapacity = capacity; + } + var p = t_responseBuffer; + bytes.CopyTo(new Span(p, bytes.Length)); + p[bytes.Length] = 0; + *outPtr = p; + *outLen = bytes.Length; + } + + internal static unsafe bool IsResponseBuffer(byte* ptr) => ptr == t_responseBuffer; + internal static DispatchResult DispatchBin(ref BinReader r) { var op = r.ReadByte(); @@ -69,8 +102,8 @@ internal static DispatchResult DispatchBin(ref BinReader r) if (op == 0x06) // members by type { - var typeName = r.ReadString16(); - var assembly = r.ReadString16(); + var typeName = r.ReadName16(); + var assembly = r.ReadName16(); var type = ResolveType(NullIfEmpty(assembly), typeName) ?? throw new TypeLoadException($"Type not found: {typeName} (assembly: {assembly})"); return BuildMembersResult(type); @@ -82,7 +115,7 @@ internal static DispatchResult DispatchBin(ref BinReader r) if (!s_handles.TryGetValue(handle, out var target)) throw new KeyNotFoundException($"Invalid handle {handle}"); var type = target?.GetType() ?? throw new InvalidOperationException("Handle is null"); - var method = r.ReadString16(); + var method = r.ReadName16(); var args = r.ReadArgs(); if (method == "__dotnet_await__" && args.Length == 2 @@ -105,23 +138,23 @@ internal static DispatchResult DispatchBin(ref BinReader r) if (op == 0x09) // create JS delegate { - var delTypeName = r.ReadString16(); // "" → System.Action + var delTypeName = r.ReadName16(); // "" → System.Action var callbackId = r.ReadI32(); return CreateJsDelegate(delTypeName, callbackId); } if (op == 0x0A) // create JS-backed subclass instance { - var assembly = r.ReadString16(); - var typeName = r.ReadString16(); + var assembly = r.ReadName16(); + var typeName = r.ReadName16(); var interfaceCount = r.ReadI32(); var interfaceNames = interfaceCount > 0 ? new string[interfaceCount] : []; - for (int i = 0; i < interfaceCount; i++) interfaceNames[i] = r.ReadString16(); + for (int i = 0; i < interfaceCount; i++) interfaceNames[i] = r.ReadName16(); var memberCount = r.ReadI32(); var memberNames = memberCount > 0 ? new string[memberCount] : []; - for (int i = 0; i < memberCount; i++) memberNames[i] = r.ReadString16(); + for (int i = 0; i < memberCount; i++) memberNames[i] = r.ReadName16(); var callbackId = r.ReadI32(); var ctorArgs = r.HasMore ? r.ReadArgs() : []; @@ -131,19 +164,34 @@ internal static DispatchResult DispatchBin(ref BinReader r) if (op == 0x0B) // get CLR-only property by raw IInspectable ptr (CLR reflection fallback) { var instancePtr = new IntPtr(r.ReadI64()); - var propName = r.ReadString16(); + var propName = r.ReadName16(); return ClrGetProperty(instancePtr, propName); } // Static ops: 0x02 = call, 0x03 = constructor - var typeNameS = r.ReadString16(); - var assemblyS = r.ReadString16(); + var typeNameS = r.ReadName16(); + var assemblyS = r.ReadName16(); var typeS = ResolveType(NullIfEmpty(assemblyS), typeNameS) ?? throw new TypeLoadException($"Type not found: {typeNameS} (assembly: {assemblyS})"); if (op == 0x03) // constructor { var args = r.ReadArgs(); + // Constructors with the same parameter count (StringBuilder(int) / StringBuilder(string)): + // the one whose parameter types best fit the arguments. + var ctors = GetCtorOverloads(typeS, args.Length); + if (ctors.Ctors.Length > 1) + { + var best = SelectOverload(ctors.Parameters, args); + object?[]? built = null; + if (best >= 0) + { + try { built = BuildArgsBinExact(args, ctors.Parameters[best]); } + catch { /* fall back to the first constructor by arity below */ } + } + if (built is not null && ArgsFit(built, ctors.Parameters[best])) + return Box(ctors.Ctors[best].Invoke(built)); + } var entry = GetCachedCtor(typeS, args.Length); if (entry.Ctor is null) throw new MissingMethodException( @@ -154,7 +202,7 @@ internal static DispatchResult DispatchBin(ref BinReader r) } // op == 0x02: static call - var methodS = r.ReadString16(); + var methodS = r.ReadName16(); var argsS = r.ReadArgs(); return DispatchCallBin(null, typeS, methodS, argsS, isStatic: true); } @@ -166,7 +214,7 @@ private static DispatchResult DispatchCallBin( - if (method.Length > 4 + if (method.Length > 4 && args.Length == 0 && method[0] == 'g' && method[1] == 'e' && method[2] == 't' && method[3] == '_') { var prop = GetCachedProp(type, method, 4, flags); @@ -185,12 +233,31 @@ private static DispatchResult DispatchCallBin( } } - // Overloads with the same parameter count (Describe(Animal) / Describe(Shape)): pick the - // first whose parameter types the arguments convert to. + // Overloads with the same parameter count (Abs(int) / Abs(double), Describe(Animal) / + // Describe(Shape)): the one whose parameter types best fit the arguments. var overloads = GetOverloads(type, method, args.Length, flags); - if (overloads.Length > 1) + var best = overloads.Methods.Length > 0 ? overloads.Select(args) : -1; + // No method with this many parameters takes these arguments: a params method may + // (String.Join(",", "a", "b", "c")). + if (best < 0 && TryParamsCall(target, type, method, args, flags, out var paramsResult)) + return paramsResult; + if (overloads.Methods.Length > 1) { - foreach (var overload in overloads) + if (best >= 0) + { + var chosen = overloads.Entry(best, type); + object?[]? built = null; + try { built = BuildArgsBin(args, chosen.Parameters); } + catch { /* an argument didn't convert: try the overloads one by one below */ } + if (built is not null) + { + if (ArgsFit(built, chosen.Parameters)) + return InvokeBuilt(target, type, method, chosen, built); + if (built.Length > 0) ReturnArgs(built); + } + } + // Nothing fits by type: the first overload the arguments convert to. + foreach (var overload in overloads.Methods) { var ps = overload.GetParameters(); object?[] built; @@ -252,36 +319,216 @@ private static DispatchResult DispatchCallBin( $"Method '{method}' ({args.Length} args) not found on {type.FullName}"); } - var builtArgs = BuildArgsBin(args, entry.Parameters); - try { + return InvokeBuilt(target, type, method, entry, BuildArgsBin(args, entry.Parameters)); + } + + // Runs a compiled invoker on arguments from BuildArgsBin (returned to the pool afterwards), + // retrying on the UI thread when a COM object rejects the calling thread. + private static DispatchResult InvokeBuilt(object? target, Type type, string method, DispatchEntry entry, object?[] builtArgs) + { + try { try { - var res = (entry.Invoke(target, builtArgs)); + var res = (entry.Invoke!(target, builtArgs)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke!(target, builtArgs))); return Box(res); } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke!(target, builtArgs))); return Box(res); } } finally { if (builtArgs.Length > 0) ReturnArgs(builtArgs); } } - private static readonly System.Collections.Concurrent.ConcurrentDictionary s_overloadCache = new(); + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_overloadCache = new(); - private static MethodInfo[] GetOverloads(Type type, string name, int argCount, BindingFlags flags) + private static OverloadSet GetOverloads(Type type, string name, int argCount, BindingFlags flags) => s_overloadCache.GetOrAdd(new MethodKey(type, name, argCount, flags), static k => - k.Type.GetMethods(k.Flags) + new OverloadSet(k.Type.GetMethods(k.Flags) .Where(m => m.Name == k.Name && !m.IsGenericMethodDefinition && m.GetParameters().Length == k.ArgCount) + .ToArray())); + + // The public methods of a type sharing a name and parameter count, with their parameters and + // (built on first use) compiled invokers. + private sealed class OverloadSet(MethodInfo[] methods) + { + public readonly MethodInfo[] Methods = methods; + private readonly ParameterInfo[][] _parameters = Array.ConvertAll(methods, m => m.GetParameters()); + private readonly DispatchEntry?[] _entries = new DispatchEntry?[methods.Length]; + + public DispatchEntry Entry(int index, Type type) => _entries[index] ??= BuildDispatchEntry(type, Methods[index]); + + public int Select(object?[] args) => SelectOverload(_parameters, args); + } + + // The public constructors of a type with a given parameter count. + private sealed class CtorSet(ConstructorInfo[] ctors) + { + public readonly ConstructorInfo[] Ctors = ctors; + public readonly ParameterInfo[][] Parameters = Array.ConvertAll(ctors, c => c.GetParameters()); + } + + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_ctorOverloadCache = new(); + + private static CtorSet GetCtorOverloads(Type type, int argCount) + => s_ctorOverloadCache.GetOrAdd(new CtorKey(type, argCount), static k => + new CtorSet(k.Type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Where(c => c.GetParameters().Length == k.ArgCount) + .ToArray())); + + // The candidate (by its parameter list) the arguments fit best, or -1 when none can take them. + // Ties go to the first. + private static int SelectOverload(ParameterInfo[][] candidates, object?[] args) + { + int best = -1, bestScore = -1; + for (int m = 0; m < candidates.Length; m++) + { + var ps = candidates[m]; + int total = 0; + for (int i = 0; i < ps.Length; i++) + { + var score = MatchScore(i < args.Length ? args[i] : null, ps[i].ParameterType); + if (score < 0) { total = -1; break; } + total += score; + } + if (total > bestScore) { bestScore = total; best = m; } + } + return best; + } + + // How well a bridged argument fits a parameter type: higher is better, -1 when it can't be + // passed. An exact type beats a widening conversion, which beats a narrowing one the value + // fits, which beats object. So Abs(-0.5) is Abs(double) and Abs(-128) is Abs(int), never + // Abs(sbyte). + private static int MatchScore(object? arg, Type p) + { + if (p.IsByRef || p.IsByRefLike || p.IsPointer) return -1; + var underlying = Nullable.GetUnderlyingType(p); + if (arg is null) return !p.IsValueType || underlying is not null ? 1 : -1; + if (underlying is not null) p = underlying; + switch (arg) + { + case int i: + if (p == typeof(int)) return 10; + if (p == typeof(long)) return 9; + if (p == typeof(double)) return 8; + if (p == typeof(float) || p == typeof(decimal)) return 7; + if (p.IsEnum) return 6; + if (IsIntegerType(p)) return FitsInteger(i, p) ? 4 : -1; + return p.IsInstanceOfType(arg) ? 2 : -1; + case double d: + if (p == typeof(double)) return 10; + if (p == typeof(float)) return 8; + if (p == typeof(decimal)) return 7; + // Integral numbers that fit an int arrive as int; this is a larger one (3e9). + if (IsIntegerType(p)) return Math.Floor(d) == d && FitsInteger(d, p) ? 4 : -1; + return p.IsInstanceOfType(arg) ? 2 : -1; + case bool: + return p == typeof(bool) ? 10 : p.IsInstanceOfType(arg) ? 2 : -1; + case string s: + if (p == typeof(string)) return 10; + if (p == typeof(char)) return s.Length == 1 ? 6 : -1; + if (IsParsedFromString(p)) return 5; + return p.IsInstanceOfType(arg) ? 2 : -1; + case JsArrayValue: + if (p.IsArray) return 8; + if (p == typeof(string)) return -1; + if (typeof(System.Collections.IEnumerable).IsAssignableFrom(p)) return 6; + return p == typeof(object) ? 1 : -1; + case HandleRef h: + s_handles.TryGetValue(h.Id, out var obj); + if (obj is null) return p.IsValueType ? -1 : 1; + if (obj.GetType() == p) return 10; + return p.IsInstanceOfType(obj) ? (p == typeof(object) ? 2 : 8) : -1; + case JsFunctionRef: + if (typeof(Delegate).IsAssignableFrom(p)) + return p == typeof(Delegate) || p == typeof(MulticastDelegate) ? 4 : 8; + return p == typeof(object) ? 1 : -1; + case WinRtRef: + if (p == typeof(object)) return 2; + return p.IsInterface || (p.IsClass && p != typeof(string)) ? 4 : -1; + case JsJsonValue: + if (p == typeof(object)) return 1; + if (p == typeof(string)) return 2; + return p.IsPrimitive || p.IsEnum || typeof(Delegate).IsAssignableFrom(p) ? -1 : 5; + default: + return p.IsInstanceOfType(arg) ? 2 : -1; + } + } + + // Methods whose last parameter is a params array, by type, name and binding flags. + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, string, BindingFlags), MethodInfo[]> s_paramsCache = new(); + + // Calls the params method (`Join(string, params string[])`) the arguments fit best, the + // trailing ones packed into its params array. False when there is none they fit. + private static bool TryParamsCall(object? target, Type type, string method, object?[] args, BindingFlags flags, out DispatchResult result) + { + result = default; + var candidates = s_paramsCache.GetOrAdd((type, method, flags), static k => + k.Item1.GetMethods(k.Item3) + .Where(m => m.Name == k.Item2 && !m.IsGenericMethodDefinition + && m.GetParameters() is { Length: > 0 } ps + && ps[^1].ParameterType.IsArray + && ps[^1].IsDefined(typeof(ParamArrayAttribute), false)) .ToArray()); + if (candidates.Length == 0) return false; + + MethodInfo? best = null; + int bestScore = -1; + foreach (var m in candidates) + { + var ps = m.GetParameters(); + var fixedCount = ps.Length - 1; + if (args.Length < fixedCount) continue; + var elementType = ps[^1].ParameterType.GetElementType()!; + int total = 0; + for (int i = 0; i < args.Length && total >= 0; i++) + { + var score = MatchScore(args[i], i < fixedCount ? ps[i].ParameterType : elementType); + total = score < 0 ? -1 : total + score; + } + if (total > bestScore) { bestScore = total; best = m; } + } + if (best is null) return false; + + var parameters = best.GetParameters(); + var fixedParams = parameters.Length - 1; + var element = parameters[^1].ParameterType.GetElementType()!; + var built = new object?[parameters.Length]; + for (int i = 0; i < fixedParams; i++) + built[i] = CoerceBin(args[i], parameters[i].ParameterType); + var rest = Array.CreateInstance(element, args.Length - fixedParams); + for (int i = 0; i < rest.Length; i++) + rest.SetValue(CoerceBin(args[fixedParams + i], element), i); + built[^1] = rest; + result = Box(best.Invoke(target, built)); + return true; + } + + private static bool IsIntegerType(Type t) => Type.GetTypeCode(t) is + TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or + TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64; + + private static bool FitsInteger(double v, Type t) => Type.GetTypeCode(t) switch + { + TypeCode.SByte => v >= sbyte.MinValue && v <= sbyte.MaxValue, + TypeCode.Byte => v >= 0 && v <= byte.MaxValue, + TypeCode.Int16 => v >= short.MinValue && v <= short.MaxValue, + TypeCode.UInt16 => v >= 0 && v <= ushort.MaxValue, + TypeCode.Int32 => v >= int.MinValue && v <= int.MaxValue, + TypeCode.UInt32 => v >= 0 && v <= uint.MaxValue, + TypeCode.Int64 => v >= long.MinValue && v < 9223372036854775808.0, + TypeCode.UInt64 => v >= 0 && v < 18446744073709551616.0, + _ => false, + }; private static object?[] BuildArgsBin(object?[] binArgs, ParameterInfo[] parameters) { @@ -380,6 +627,10 @@ private static MethodInfo[] GetOverloads(Type type, string name, int argCount, B var target = targetType == typeof(object) ? typeof(System.Text.Json.JsonElement) : targetType; return System.Text.Json.JsonSerializer.Deserialize(json.Json, target, s_jsJsonOptions); } + if (value is JsArrayValue array) + return CoerceArray(array.Items, targetType); + if (value is string text && IsParsedFromString(Nullable.GetUnderlyingType(targetType) ?? targetType)) + return ParseString(text, Nullable.GetUnderlyingType(targetType) ?? targetType); if (value is JsFunctionRef fn) { var delegateType = typeof(Delegate).IsAssignableFrom(targetType) @@ -388,7 +639,7 @@ private static MethodInfo[] GetOverloads(Type type, string name, int argCount, B : typeof(Action); return MakeJsDelegate(delegateType, fn.Id); } - if (value.GetType() == targetType) return value; + if (value.GetType() == targetType || targetType.IsInstanceOfType(value)) return value; var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; if (underlying.IsEnum) { @@ -399,6 +650,61 @@ private static MethodInfo[] GetOverloads(Type type, string name, int argCount, B catch { return value; } } + private static bool IsParsedFromString(Type t) => + t == typeof(DateTime) || t == typeof(DateTimeOffset) || t == typeof(TimeSpan) || t == typeof(Guid); + + // A string for a DateTime, DateTimeOffset, TimeSpan or Guid parameter, parsed culture-invariantly + // (a JS Date is sent as its ISO 8601 string). Left as is when it doesn't parse. + private static object ParseString(string text, Type t) + { + var invariant = System.Globalization.CultureInfo.InvariantCulture; + var roundtrip = System.Globalization.DateTimeStyles.RoundtripKind; + if (t == typeof(DateTime) && DateTime.TryParse(text, invariant, roundtrip, out var dt)) return dt; + if (t == typeof(DateTimeOffset) && DateTimeOffset.TryParse(text, invariant, roundtrip, out var dto)) return dto; + if (t == typeof(TimeSpan) && TimeSpan.TryParse(text, invariant, out var ts)) return ts; + if (t == typeof(Guid) && Guid.TryParse(text, out var g)) return g; + return text; + } + + // A JS array's items as the array or collection type a parameter expects: T[], a collection + // interface (IEnumerable, IList, IReadOnlyList) or a concrete collection with Add + // (List, ObservableCollection). Anything else gets object[]. + private static object? CoerceArray(object?[] items, Type targetType) + { + var target = Nullable.GetUnderlyingType(targetType) ?? targetType; + var element = target.IsArray ? target.GetElementType()! : CollectionElementType(target); + if (target.IsArray || (target.IsInterface && element is not null)) + { + var array = Array.CreateInstance(element!, items.Length); + for (int i = 0; i < items.Length; i++) array.SetValue(CoerceBin(items[i], element!), i); + return array; + } + if (element is not null && !target.IsAbstract && target.GetConstructor(Type.EmptyTypes) is not null) + { + var collection = Activator.CreateInstance(target)!; + var add = target.GetMethod("Add", [element]); + if (add is not null) + { + foreach (var item in items) add.Invoke(collection, [CoerceBin(item, element)]); + return collection; + } + } + var objects = new object?[items.Length]; + for (int i = 0; i < items.Length; i++) objects[i] = CoerceBin(items[i], typeof(object)); + return objects; + } + + // T of the IEnumerable a type is or implements, or null. + internal static Type? CollectionElementType(Type t) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return t.GetGenericArguments()[0]; + foreach (var i in t.GetInterfaces()) + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return i.GetGenericArguments()[0]; + return null; + } + // CLR reflection fallback for properties that exist only in managed code and are // therefore invisible to the WinRT metadata layer (e.g. App.MainWindow on a class // that derives from Microsoft.UI.Xaml.Application but adds CLR-only members). diff --git a/dotnet-bridge/Bridge.Dispatch.cs b/dotnet-bridge/Bridge.Dispatch.cs index 323d95b..c7d9fdc 100644 --- a/dotnet-bridge/Bridge.Dispatch.cs +++ b/dotnet-bridge/Bridge.Dispatch.cs @@ -175,8 +175,11 @@ private static DispatchEntry GetCachedMethod(Type type, string name, int argCoun private static DispatchEntry BuildDispatchEntry(Type type, string name, int argCount, BindingFlags flags) { var mi = FindMethodCore(type, name, argCount, flags); - if (mi is null) return DispatchEntry.Empty; + return mi is null ? DispatchEntry.Empty : BuildDispatchEntry(type, mi); + } + internal static DispatchEntry BuildDispatchEntry(Type type, MethodInfo mi) + { var parameters = mi.GetParameters(); try @@ -222,10 +225,12 @@ private static CtorEntry GetCachedCtor(Type type, int argCount) return new CtorEntry(ctor, ctor?.GetParameters() ?? []); }); + // Keyed by the accessor name as called ("get_Length") plus the prefix length, so a warm lookup + // allocates no property-name substring. private static PropertyInfo? GetCachedProp(Type type, string method, int prefixLen, BindingFlags flags) => s_propCache.GetOrAdd( - new PropKey(type, method[prefixLen..], flags), - static k => k.Type.GetProperty(k.Name, k.Flags)); + new PropKey(type, method, prefixLen, flags), + static k => k.Type.GetProperty(k.Name[k.PrefixLen..], k.Flags)); // Pooled: rented array passed to the compiled delegate (which accesses by index, // not by Length). Caller must return via ReturnArgs immediately after invoke. @@ -274,24 +279,19 @@ private static DispatchResult Box(object? value) return DispatchResult.Primitive(unchecked((long)up.ToUInt64()), typeof(long)); var t = value.GetType(); + var info = GetBoxInfo(t); - if (t.IsPrimitive || t == typeof(string) || t == typeof(decimal) - || t == typeof(DateTime) || t == typeof(DateTimeOffset) - || t == typeof(TimeSpan) || t == typeof(Guid)) + if (info.Primitive) return DispatchResult.Primitive(value, t); - // Arrays and other enumerable results should be marshalled as Collections - // (0x07) so the runtime receives the items directly instead of a handle. - if (value is System.Collections.IEnumerable enumerable && !(value is string)) - { - return DispatchResult.Collection(enumerable); - } + // Arrays are marshalled as Collections (0x07) so the runtime receives the items + // directly. Other collections (List, Dictionary, ObservableCollection) stay .NET + // objects, so their methods work and native code sees the same instance. + if (info.Collection) + return DispatchResult.Collection((System.Collections.IEnumerable)value); - if (t.IsEnum) - { - var ut = Enum.GetUnderlyingType(t); + if (info.EnumUnderlying is { } ut) return DispatchResult.Primitive(Convert.ChangeType(value, ut), ut); - } // An instance of a JS subclass goes back to JS as the handle its JS object holds, so the // runtime hands out that same JS object. If that object was collected (its handle released), @@ -312,9 +312,12 @@ private static DispatchResult Box(object? value) // the C#/WinRT inner object for managed WinRT subclasses (e.g. FlexboxLayout // subclasses), ensuring QI succeeds for all inherited WinRT interfaces. // The pointer is addref'd here and released on __release. + // C#/WinRT objects and JS subclass instances get it here, since the runtime wraps them + // as WinRT objects right away. Other objects get one when first passed to a WinRT API + // (GetNativePtrForHandle): making a COM wrapper costs more than the rest of the call. try { - if (value != null) + if (info.WinRTNativeObject is not null || jsHolder is not null) { var p = ObtainNativePtr(value); if (p != IntPtr.Zero) @@ -325,15 +328,52 @@ private static DispatchResult Box(object? value) { // Not a COM object or failed to obtain native pointer; ignore. } - var typeName = t.FullName ?? t.Name; - return IsAwaitable(value, t) - ? DispatchResult.TaskHandle(id, typeName) - : DispatchResult.Handle(id, typeName); + return info.Awaitable + ? DispatchResult.TaskHandle(id, info.TypeName) + : DispatchResult.Handle(id, info.TypeName); } - private static bool IsAwaitable(object? value, Type t) + // What Box needs to know about a returned value's type, worked out once per type rather than + // by reflection on every call. + internal sealed class BoxInfo + { + public string TypeName = ""; + public bool Primitive; + public bool Collection; + public Type? EnumUnderlying; + public bool Awaitable; + // WinRT.IWinRTObject.NativeObject when the type is a C#/WinRT projection (see ObtainNativePtr). + public PropertyInfo? WinRTNativeObject; + } + + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_boxInfo = new(); + + internal static BoxInfo GetBoxInfo(Type t) => s_boxInfo.GetOrAdd(t, static t => { - if (value is Task || value is ValueTask) return true; + var info = new BoxInfo { TypeName = t.FullName ?? t.Name }; + if (t.IsPrimitive || t == typeof(string) || t == typeof(decimal) || t == typeof(Guid)) + { + info.Primitive = true; + return info; + } + if (t.IsArray) + { + info.Collection = true; + return info; + } + if (t.IsEnum) + { + info.EnumUnderlying = Enum.GetUnderlyingType(t); + return info; + } + info.Awaitable = IsAwaitable(t); + try { info.WinRTNativeObject = t.GetInterface("WinRT.IWinRTObject")?.GetProperty("NativeObject"); } catch { } + return info; + }); + + private static bool IsAwaitable(Type t) + { + if (typeof(Task).IsAssignableFrom(t) || t == typeof(ValueTask)) return true; if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ValueTask<>)) return true; if ((t.FullName ?? "").StartsWith("Windows.Foundation.IAsync", StringComparison.Ordinal)) return true; foreach (var iface in t.GetInterfaces()) @@ -360,6 +400,79 @@ public static string TypeKind(string typeName) return "class"; } + // What a .NET collection supports, for the JS side to make it array-like: 1 = enumerable + // (for...of, map, ...), 2 = has a count (length), 4 = indexed by position (list[0]). + public static int CollectionKind(object? value) + { + if (value is null or string || value is not System.Collections.IEnumerable) return 0; + var info = GetCollectionInfo(value.GetType()); + return 1 | (info.Count is not null ? 2 : 0) | (info.Indexer is not null ? 4 : 0); + } + + // A collection's items, as an array the runtime copies into a JS array. + public static object?[] CollectionItems(object? value) + { + if (value is not System.Collections.IEnumerable items) return []; + var list = new List(); + foreach (var item in items) list.Add(item); + return list.ToArray(); + } + + public static int CollectionCount(object? value) + { + if (value is System.Collections.ICollection c) return c.Count; + var count = value is null ? null : GetCollectionInfo(value.GetType()).Count; + return count?.GetValue(value) is int n ? n : CollectionItems(value).Length; + } + + // The item at `index`, or null past either end (as a JS array gives undefined). + public static object? ItemAt(object? value, int index) + { + if (index < 0 || index >= CollectionCount(value)) return null; + if (value is System.Collections.IList list) return list[index]; + var indexer = value is null ? null : GetCollectionInfo(value.GetType()).Indexer; + if (indexer is null) throw new NotSupportedException($"{value?.GetType().FullName} is not indexed by position"); + return indexer.GetValue(value, [index]); + } + + public static void SetItemAt(object? value, int index, object? item) + { + var info = value is null ? null : GetCollectionInfo(value.GetType()); + if (info?.Indexer is not { CanWrite: true } indexer) + throw new NotSupportedException($"{value?.GetType().FullName} has no settable items"); + indexer.SetValue(value, CoerceBin(item, indexer.PropertyType), [index]); + } + + // The count property and positional indexer of a collection type, from IList / + // IReadOnlyList / ICollection / IReadOnlyCollection (or their non-generic forms). + internal sealed class CollectionInfo + { + public PropertyInfo? Count; + public PropertyInfo? Indexer; + } + + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_collectionInfo = new(); + + private static CollectionInfo GetCollectionInfo(Type t) => s_collectionInfo.GetOrAdd(t, static t => + { + var info = new CollectionInfo(); + Type[] interfaces = t.IsInterface ? [t, .. t.GetInterfaces()] : t.GetInterfaces(); + foreach (var i in interfaces) + { + var definition = i.IsGenericType ? i.GetGenericTypeDefinition() : i; + if (info.Indexer is null && (definition == typeof(IList<>) || definition == typeof(IReadOnlyList<>) || definition == typeof(System.Collections.IList))) + info.Indexer = i.GetProperty("Item"); + if (info.Count is null && (definition == typeof(ICollection<>) || definition == typeof(IReadOnlyCollection<>) || definition == typeof(System.Collections.ICollection))) + info.Count = i.GetProperty("Count"); + } + // A writable IList indexer beats a read-only IReadOnlyList one. + if (info.Indexer is { CanWrite: false }) + foreach (var i in interfaces) + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IList<>)) + info.Indexer = i.GetProperty("Item"); + return info; + }); + /// `value instanceof SomeType` for a .NET object that was not constructed from JS. public static bool IsInstanceOf(object? value, string typeName) { @@ -373,8 +486,8 @@ internal static DispatchResult BuildMembersResult(Type t) const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance; const BindingFlags stat = BindingFlags.Public | BindingFlags.Static; - var instProps = t.GetProperties(inst); - var statProps = t.GetProperties(stat); + var instProps = Array.FindAll(t.GetProperties(inst), p => p.GetIndexParameters().Length == 0); + var statProps = Array.FindAll(t.GetProperties(stat), p => p.GetIndexParameters().Length == 0); return DispatchResult.Members( methods: t.GetMethods(inst).Where(m => !m.IsSpecialName).Select(m => m.Name).Distinct().ToArray(), @@ -391,8 +504,8 @@ internal static DispatchResult BuildMembersResult(Type t) internal static Type? ResolveType(string? assemblyName, string? typeName) { if (string.IsNullOrEmpty(typeName)) return null; - var key = string.IsNullOrEmpty(assemblyName) ? typeName : $"{assemblyName}|{typeName}"; - return s_typeCache.GetOrAdd(key, _ => ResolveTypeCore(assemblyName, typeName)); + return s_typeCache.GetOrAdd((assemblyName ?? "", typeName), + static k => ResolveTypeCore(k.Assembly.Length == 0 ? null : k.Assembly, k.Type)); } private static Type? ResolveTypeInAssembly(Assembly asm, string typeName, int lastDot, string shortName) diff --git a/dotnet-bridge/Bridge.JsDelegate.cs b/dotnet-bridge/Bridge.JsDelegate.cs index c0cf68b..9b895ae 100644 --- a/dotnet-bridge/Bridge.JsDelegate.cs +++ b/dotnet-bridge/Bridge.JsDelegate.cs @@ -222,7 +222,8 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) if (arg is Enum e) { w.WriteByte(0x04); w.WriteF64(Convert.ToDouble(e)); return; } if (arg is decimal m){ w.WriteByte(0x04); w.WriteF64((double)m); return; } // A struct (Windows.Foundation.Size, a C# record struct, ...) arrives as a plain JS object. - if (arg.GetType().IsValueType && !arg.GetType().IsPrimitive) + if (arg.GetType().IsValueType && !arg.GetType().IsPrimitive + && arg is not DateTime && arg is not DateTimeOffset && arg is not TimeSpan) { string? json = null; try { json = System.Text.Json.JsonSerializer.Serialize(arg, arg.GetType(), s_jsJsonOptions); } catch { } diff --git a/dotnet-bridge/Bridge.cs b/dotnet-bridge/Bridge.cs index bd556fd..914caa4 100644 --- a/dotnet-bridge/Bridge.cs +++ b/dotnet-bridge/Bridge.cs @@ -31,8 +31,8 @@ public static partial class Bridge internal static unsafe delegate* unmanaged[Cdecl] s_jsInvoker; - private static readonly ConcurrentDictionary s_typeCache - = new(StringComparer.Ordinal); + // Keyed by (assembly name or "", type name): no key string is built per lookup. + private static readonly ConcurrentDictionary<(string Assembly, string Type), Type?> s_typeCache = new(); private static readonly ConcurrentDictionary s_methodCache = new(); private static readonly ConcurrentDictionary s_propCache = new(); private static readonly ConcurrentDictionary s_ctorCache = new(); @@ -53,6 +53,11 @@ internal static void ClearCaches() s_methodCache.Clear(); s_propCache.Clear(); s_ctorCache.Clear(); + s_overloadCache.Clear(); + s_ctorOverloadCache.Clear(); + s_boxInfo.Clear(); + s_paramsCache.Clear(); + s_collectionInfo.Clear(); s_handles.Clear(); s_nativePtrs.Clear(); s_nativePtrsReverse.Clear(); @@ -612,14 +617,13 @@ private static IntPtr ObtainNativePtr(object obj) // QueryInterface will succeed for all WinRT interfaces inherited by the type. try { - var winrtObjType = obj.GetType().GetInterface("WinRT.IWinRTObject"); - if (winrtObjType != null) + var nativeObjProp = GetBoxInfo(obj.GetType()).WinRTNativeObject; + if (nativeObjProp != null) { - var nativeObjProp = winrtObjType.GetProperty("NativeObject"); - var nativeObj = nativeObjProp?.GetValue(obj); + var nativeObj = nativeObjProp.GetValue(obj); if (nativeObj != null) { - var thisPtrProp = nativeObj.GetType().GetProperty("ThisPtr"); + var thisPtrProp = s_thisPtrProps.GetOrAdd(nativeObj.GetType(), static t => t.GetProperty("ThisPtr")); if (thisPtrProp?.GetValue(nativeObj) is IntPtr thisPtr && thisPtr != IntPtr.Zero) { Marshal.AddRef(thisPtr); @@ -656,6 +660,9 @@ private static IntPtr ObtainNativePtr(object obj) return iunknown; } + // ThisPtr of C#/WinRT's IObjectReference types, by type. + private static readonly ConcurrentDictionary s_thisPtrProps = new(); + private static MethodInfo? s_marshalInspectableFromManaged; // WinRT.MarshalInspectable.FromManaged(obj), found by reflection (the bridge has no @@ -759,6 +766,6 @@ public static unsafe int Invoke( CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] public static unsafe void Free(byte* ptr) { - if (ptr != null) Marshal.FreeHGlobal((IntPtr)ptr); + if (ptr != null && !IsResponseBuffer(ptr)) Marshal.FreeHGlobal((IntPtr)ptr); } } diff --git a/dotnet-bridge/DispatchTypes.cs b/dotnet-bridge/DispatchTypes.cs index 213f7a9..82e1057 100644 --- a/dotnet-bridge/DispatchTypes.cs +++ b/dotnet-bridge/DispatchTypes.cs @@ -30,7 +30,7 @@ internal sealed record InvokeRequest( // equality and reference hash codes are sufficient and allocation-free. internal readonly record struct MethodKey(Type Type, string Name, int ArgCount, BindingFlags Flags); -internal readonly record struct PropKey(Type Type, string Name, BindingFlags Flags); +internal readonly record struct PropKey(Type Type, string Name, int PrefixLen, BindingFlags Flags); internal readonly record struct CtorKey(Type Type, int ArgCount); internal readonly struct DispatchEntry(Func? invoke, ParameterInfo[] parameters) @@ -272,7 +272,7 @@ private static void WriteValueBin(ref BinWriter w, object? value) WritePrimitiveBin(ref w, value); return; } - if (value is IEnumerable enumerable) + if (value is Array enumerable) { var items = new List(); foreach (var item in enumerable) items.Add(item); diff --git a/integration-tests/tests/new_features.rs b/integration-tests/tests/new_features.rs index a5d962b..d94d5fd 100644 --- a/integration-tests/tests/new_features.rs +++ b/integration-tests/tests/new_features.rs @@ -737,3 +737,101 @@ fn event_supports_in_operator() { "'EventName' in instance should be true for declared WinRT events", ); } + +/// Runs `expr` against the published .NET bridge and asserts it evaluates to `true`; skips when +/// the bridge isn't published. +fn assert_dotnet_js(expr: &str, msg: &str) { + if !dotnet_bridge_available() { + eprintln!("SKIP: dotnet-bridge not published, run `dotnet publish` in dotnet-bridge/"); + return; + } + let mut rt = Runtime::new("."); + assert_js(&mut rt, expr, msg); +} + +#[test] +fn dotnet_overloads_pick_the_best_fitting_parameter_types() { + assert_dotnet_js( + r#" + System.Math.Abs(-0.5) === 0.5 + && System.Math.Abs(-128) === 128 + && System.Math.Max(3.5, 2) === 3.5 + && new System.Text.StringBuilder('q').ToString() === 'q' + "#, + "overloads should resolve by argument type, not first convertible", + ); +} + +#[test] +fn dotnet_collections_are_objects_that_act_like_arrays() { + assert_dotnet_js( + r#" + (function(){ + var list = new System.Collections.ArrayList(); + list.Add(1); list.Add('x'); + list[0] = 7; + var seen = []; + for (var v of list) seen.push(v); + return list.Count === 2 && list.length === 2 && list[1] === 'x' && list[5] === null + && list.map(String).join('|') === '7|x' && seen.join('|') === '7|x' + && JSON.stringify(list) === '[7,"x"]' + && Array.isArray(System.IO.Path.GetInvalidPathChars()); + })() + "#, + "a constructed collection should keep its .NET methods and be array-like", + ); +} + +#[test] +fn dotnet_date_and_time_values_are_objects() { + assert_dotnet_js( + r#" + (function(){ + var span = System.TimeSpan.FromSeconds(2); + var year = System.Convert.ToDateTime(new Date(Date.UTC(2031, 4, 6, 12))).ToUniversalTime().Year; + return span.TotalMilliseconds === 2000 && String(span) === '00:00:02' + && System.DateTime.Now.Year > 2000 && year === 2031; + })() + "#, + "TimeSpan/DateTime results should expose their members, JS Dates should convert", + ); +} + +#[test] +fn dotnet_callback_arguments_are_dotnet_objects() { + assert_dotnet_js( + r#" + System.Text.RegularExpressions.Regex.Replace('a1b2', '\d', function (m) { return '[' + m.Value + ']'; }) === 'a[1]b[2]' + "#, + "a JS function passed as a delegate should receive .NET object proxies", + ); +} + +#[test] +fn dotnet_params_and_array_arguments() { + assert_dotnet_js( + r#" + System.String.Join(',', 'a', 'b', 'c') === 'a,b,c' + && System.String.Join('-', ['a', 'b']) === 'a-b' + && System.String.Format('{0}+{1}={2}', 1, 2, 3) === '1+2=3' + "#, + "params arrays and JS array arguments should marshal", + ); +} + +#[test] +fn dotnet_plain_js_fields_are_kept() { + assert_dotnet_js( + r#" + (function(){ + var sb = new System.Text.StringBuilder(); + sb.tag = 3; + sb.Capacity = 99; + System.Text.tag = 'ns'; + return sb.tag === 3 && Object.keys(sb).join() === 'tag' && sb.Capacity >= 99 + && System.Text.tag === 'ns' && ('' + sb).indexOf('DotNetObject') >= 0; + })() + "#, + "plain JS fields on .NET objects and namespaces should be kept", + ); +} diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 57793c8..b7b67eb 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -467,17 +467,27 @@ pub(crate) fn init_js_callbacks(callback: FnJsCallback) { /// Callback ids whose managed delegate was garbage-collected, queued from the finalizer thread /// and dropped on the JS thread by [`take_released_js_callbacks`]. static RELEASED_JS_CALLBACKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +/// Whether `RELEASED_JS_CALLBACKS` may be non-empty (only changed under its lock), so the check +/// made before every bridge call takes no lock when nothing was released. +static RELEASED_JS_CALLBACKS_PENDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); unsafe extern "C" fn queue_js_callback_release(callback_id: i32) { if let Ok(mut ids) = RELEASED_JS_CALLBACKS.lock() { ids.push(callback_id); + RELEASED_JS_CALLBACKS_PENDING.store(true, std::sync::atomic::Ordering::Release); } } /// Callback ids released since the last call. Called on the JS thread, which owns the functions. pub(crate) fn take_released_js_callbacks() -> Vec { + if !RELEASED_JS_CALLBACKS_PENDING.load(std::sync::atomic::Ordering::Acquire) { + return Vec::new(); + } match RELEASED_JS_CALLBACKS.lock() { - Ok(mut ids) if !ids.is_empty() => std::mem::take(&mut *ids), + Ok(mut ids) => { + RELEASED_JS_CALLBACKS_PENDING.store(false, std::sync::atomic::Ordering::Release); + std::mem::take(&mut *ids) + } _ => Vec::new(), } } @@ -485,6 +495,12 @@ pub(crate) fn take_released_js_callbacks() -> Vec { /// Calls the managed bridge with a pre-built binary request packet and returns /// the raw binary response bytes. No JSON involved on either side. pub(crate) fn call_dotnet_binary(request: &[u8]) -> Result, String> { + call_dotnet_binary_with(request, |response| response.to_vec()) +} + +/// [`call_dotnet_binary`] that hands the response to `read` in the bridge's own buffer (freed +/// afterwards) instead of copying it out first. +pub(crate) fn call_dotnet_binary_with(request: &[u8], read: impl FnOnce(&[u8]) -> R) -> Result { ensure_dotnet_initialized(); let host = DOTNET_HOST .get() @@ -513,7 +529,7 @@ pub(crate) fn call_dotnet_binary(request: &[u8]) -> Result, String> { } let slice = unsafe { std::slice::from_raw_parts(resp_ptr, resp_len as usize) }; - let result = slice.to_vec(); + let result = read(slice); unsafe { (host.free)(resp_ptr) }; Ok(result) } diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index 4cbfa7d..8f36fe4 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -3242,35 +3242,92 @@ const HELPER_SOURCE: &str = r#" // throws on error, returns the result value directly. function _invoke(req) { var handle = (req.handle !== undefined && req.handle !== null) ? req.handle : -1; - var args = req.args || []; - return globalThis.__nsDotNetInvokeBin( - handle, - req.typeName || '', - req.assembly || '', - req.method || '', - ...args - ); + var args = req.args; + var typeName = req.typeName || '', assembly = req.assembly || '', method = req.method || ''; + if (!args || args.length === 0) return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method); + if (args.length === 1) return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method, args[0]); + return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method, ...args); + } + + // _invoke with a function's own `arguments`, unwrapped straight into the native call (no + // request object or intermediate arrays). + function _invokeWith(handle, typeName, assembly, method, args) { + var invoke = globalThis.__nsDotNetInvokeBin; + switch (args.length) { + case 0: return invoke(handle, typeName, assembly, method); + case 1: return invoke(handle, typeName, assembly, method, _unwrap(args[0])); + case 2: return invoke(handle, typeName, assembly, method, _unwrap(args[0]), _unwrap(args[1])); + case 3: return invoke(handle, typeName, assembly, method, _unwrap(args[0]), _unwrap(args[1]), _unwrap(args[2])); + } + // Built by push so the array stays packed: apply on a holey array takes V8's slow path. + var call = [handle, typeName, assembly, method]; + for (var i = 0; i < args.length; i++) call.push(_unwrap(args[i])); + return invoke.apply(null, call); } // Populated lazily on first access; avoids repeated bridge round-trips. var _typeInfoCache = {}; - var _emptyInfo = { methods: [], properties: [], staticMethods: [], staticProperties: [], readonlyProperties: [], readonlyStaticProperties: [], writeonlyProperties: [], writeonlyStaticProperties: [] }; + // A type's member lists get `kinds` (member name -> _K_* flags), so the proxy traps look a name up + // instead of scanning the lists on every access. + var _K_PROP = 1, _K_WO = 2, _K_RO = 4, _K_SPROP = 8, _K_SMETHOD = 16, _K_SWO = 32, _K_SRO = 64; + function _withKinds(info) { + var kinds = Object.create(null); + function add(list, flag) { + if (!list) return; + for (var i = 0; i < list.length; i++) kinds[list[i]] = (kinds[list[i]] | 0) | flag; + } + add(info.properties, _K_PROP); + add(info.writeonlyProperties, _K_WO); + add(info.readonlyProperties, _K_RO); + add(info.staticProperties, _K_SPROP); + add(info.staticMethods, _K_SMETHOD); + add(info.writeonlyStaticProperties, _K_SWO); + add(info.readonlyStaticProperties, _K_SRO); + info.kinds = kinds; + return info; + } + function _kindsOf(info) { return info.kinds || _withKinds(info).kinds; } + + // "get_X" / "set_X" accessor names, made once per property name. + var _getterNames = Object.create(null), _setterNames = Object.create(null); + function _getterName(prop) { return _getterNames[prop] || (_getterNames[prop] = 'get_' + prop); } + function _setterName(prop) { return _setterNames[prop] || (_setterNames[prop] = 'set_' + prop); } + + // The native COM pointer of a handle's object as the BigInt the runtime reads from `__native_ptr`, + // or undefined when it has none. The bridge makes it for a plain .NET object only on request. + function _nativePtrOf(handle) { + try { + var p = _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'GetNativePtrForHandle', args: [handle] }); + if (typeof p !== 'number' || p === 0) return undefined; + return typeof BigInt === 'function' ? BigInt(p) : p; + } catch (_) { + return undefined; + } + } + var _emptyInfo = _withKinds({ methods: [], properties: [], staticMethods: [], staticProperties: [], readonlyProperties: [], readonlyStaticProperties: [], writeonlyProperties: [], writeonlyStaticProperties: [] }); // Optional mapping for namespace prefixes -> assembly simple-name. // Exact namespaces are preferred first, then progressively shorter // prefixes are tried as a fallback. var _namespaceAssemblyMap = Object.create(null); + // _resolveAssembly results by type name, cleared whenever the namespace map changes. + var _assemblyCache = Object.create(null); + function _resolveAssembly(typeName) { if (!typeName || typeof typeName !== 'string') return ''; - var probe = String(typeName); + var cached = _assemblyCache[typeName]; + if (cached !== undefined) return cached; + var resolved = ''; + var probe = typeName; while (probe) { var assembly = _namespaceAssemblyMap[probe]; - if (typeof assembly === 'string' && assembly) return assembly; + if (typeof assembly === 'string' && assembly) { resolved = assembly; break; } var lastDot = probe.lastIndexOf('.'); if (lastDot < 0) break; probe = probe.substring(0, lastDot); } - return ''; + _assemblyCache[typeName] = resolved; + return resolved; } // When the JS GC collects a DotNet proxy the registry fires the @@ -3304,39 +3361,117 @@ const HELPER_SOURCE: &str = r#" if (!typeName) return _emptyInfo; var cached = _typeInfoCache[typeName]; if (cached !== undefined) return cached; + var info = _emptyInfo; try { // Respect an explicitly-provided assembly name. When empty, // let the managed side attempt resolution (BCL types via Type.GetType). var asm = (typeof assembly === 'string') ? assembly : ''; - var info = _invoke({ assembly: asm, typeName: typeName, method: '__members__', args: [] }); - _typeInfoCache[typeName] = (info && typeof info === 'object') ? info : _emptyInfo; - } catch (e) { - _typeInfoCache[typeName] = _emptyInfo; - } - return _typeInfoCache[typeName]; + var members = _invoke({ assembly: asm, typeName: typeName, method: '__members__', args: [] }); + if (members && typeof members === 'object') info = _withKinds(members); + } catch (e) {} + _typeInfoCache[typeName] = info; + return info; } function _unwrap(v) { if (v && typeof v === 'object' && typeof v.__handle === 'number') return { __handle: v.__handle }; + if (Array.isArray(v)) return v.map(_unwrap); + // A JS Date reaches DateTime / DateTimeOffset parameters as its ISO 8601 string. + if (v instanceof Date) return isNaN(v.getTime()) ? null : v.toISOString(); + // A JS function passed where .NET expects a delegate gets .NET objects as arguments, the same + // proxies a method result would be, rather than raw handles. + if (typeof v === 'function' && typeof v.__dotnetType__ !== 'string' && typeof v.__handle !== 'number') + return _callbackFor(v); return v; } + // The wrapper sent for a JS callback: one per function, so passing it again sends the same one. + var _callbacks = typeof WeakMap === 'function' ? new WeakMap() : null; + function _callbackFor(fn) { + var wrapped = _callbacks && _callbacks.get(fn); + if (wrapped) return wrapped; + wrapped = function () { return fn.apply(this, Array.prototype.map.call(arguments, _wrap)); }; + if (_callbacks) _callbacks.set(fn, wrapped); + return wrapped; + } + + // .NET collections (List, Dictionary, ObservableCollection) act like JS arrays: `length`, + // `list[i]`, for...of, and the array methods (map, filter, ...) on a copy of their items. + // _collectionKind is the bridge's CollectionKind flags (1 enumerable, 2 count, 4 indexed) by type. + var _collectionKinds = Object.create(null); + var _arrayMethods = Object.create(null); + ['forEach', 'map', 'filter', 'reduce', 'reduceRight', 'some', 'every', 'find', 'findIndex', 'findLast', + 'findLastIndex', 'indexOf', 'lastIndexOf', 'includes', 'join', 'slice', 'concat', 'flat', 'flatMap', + 'entries', 'keys', 'values', 'at', 'toJSON'].forEach(function (name) { _arrayMethods[name] = 1; }); + + function _bridgeCall(method, args) { + return _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: method, args: args }); + } + + function _isIndex(prop) { + var c = prop.charCodeAt(0); + return c >= 48 && c <= 57 && /^(0|[1-9]\d*)$/.test(prop); + } + + function _collectionKind(handle, typeName) { + var kind = typeName ? _collectionKinds[typeName] : undefined; + if (kind !== undefined) return kind; + try { kind = _bridgeCall('CollectionKind', [{ __handle: handle }]) | 0; } catch (_) { kind = 0; } + if (typeName) _collectionKinds[typeName] = kind; + return kind; + } + + function _collectionItems(handle) { + return _wrap(_bridgeCall('CollectionItems', [{ __handle: handle }])) || []; + } + + // `prop` of a collection proxy when it is one of the array-like members, else undefined. + function _collectionMember(handle, typeName, prop) { + var kind = _collectionKind(handle, typeName); + if (prop === 'length') return (kind & 2) ? _bridgeCall('CollectionCount', [{ __handle: handle }]) : undefined; + if (_arrayMethods[prop] === 1) { + if (!(kind & 1)) return undefined; + return function () { + var items = _collectionItems(handle); + return prop === 'toJSON' ? items : Array.prototype[prop].apply(items, arguments); + }; + } + return (kind & 4) ? _wrap(_bridgeCall('ItemAt', [{ __handle: handle }, +prop])) : undefined; + } + + // Value types whose JS string is their .NET ToString(), so `${DateTime.Now}` reads as a date. + var _netToString = { 'System.DateTime': 1, 'System.DateTimeOffset': 1, 'System.TimeSpan': 1 }; + // Makes sw.Stop() and sw.Elapsed both work naturally. // The proxy is registered with _dotNetFinalizers so the CLR reference // is released automatically when JS GC collects the proxy. function _makeDotNetInstance(handle, assembly, typeName, isTask, nativePtr) { - var info = _getTypeInfo(assembly, typeName); + _getTypeInfo(assembly, typeName); + // Method functions by name, made on first access. + var methods = null; + var ptrKnown = nativePtr !== undefined; + // The proxy target holds the plain JS fields set on the object. var proxy = new Proxy({}, { - get: function (_, prop) { - if (typeof prop === 'symbol') return undefined; + get: function (target, prop) { + if (typeof prop === 'symbol') { + if (prop === Symbol.iterator && (_collectionKind(handle, typeName) & 1)) + return function () { return _collectionItems(handle)[Symbol.iterator](); }; + return target[prop]; + } if (prop === '__handle') return handle; if (prop === '__type') return typeName; if (prop === '__isTask') return isTask === true; - if (prop === '__native_ptr') return nativePtr; + if (prop === '__native_ptr') { + if (!ptrKnown) { ptrKnown = true; nativePtr = _nativePtrOf(handle); } + return nativePtr; + } if (prop === 'release') return function () { _invoke({ handle: handle, method: '__release', args: [] }); }; + // Like a plain object, so `'' + obj` and comparisons fall through to toString. + if (prop === 'valueOf') return function () { return proxy; }; if (prop === 'toString') return function () { + if (_netToString[typeName] === 1) return String(_invoke({ handle: handle, method: 'ToString', args: [] })); return '[DotNetObject ' + typeName + ' #' + handle + ']'; }; // A Task/ValueTask (awaitable) result is thenable, so `await obj.SomethingAsync()` works; @@ -3347,30 +3482,42 @@ const HELPER_SOURCE: &str = r#" return globalThis.NSWinRT.dotnet.taskToPromise({ __handle: handle, __isTask: true }).then(onFulfilled, onRejected); }; } + if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop]; // Re-read info in case it was populated after construction. - var i = _typeInfoCache[typeName] || _emptyInfo; - // Write-only: has setter but no getter — reading it is an error. - if (i.writeonlyProperties && i.writeonlyProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_typeInfoCache[typeName] || _emptyInfo)[prop] | 0; + // Write-only (setter, no getter): reading it is an error. + if (kind & _K_WO) throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); - if (i.properties && i.properties.indexOf(prop) >= 0) - return _wrap(_invoke({ handle: handle, method: 'get_' + prop, args: [] })); - // Not a native property — return a callable for method dispatch. - return function () { - var args = Array.prototype.slice.call(arguments).map(_unwrap); - return _wrap(_invoke({ handle: handle, method: prop, args: args })); - }; + if (kind & _K_PROP) + return _wrap(_invoke({ handle: handle, method: _getterName(prop), args: [] })); + if (prop === 'length' || _arrayMethods[prop] === 1 || _isIndex(prop)) { + var member = _collectionMember(handle, typeName, prop); + if (member !== undefined) return member; + } + // Not a native property: a function for method dispatch. + if (methods === null) methods = Object.create(null); + return methods[prop] || (methods[prop] = function () { + return _wrap(_invokeWith(handle, '', '', prop, arguments)); + }); }, - set: function (_, prop, value) { - if (typeof prop === 'symbol') return true; - var i = _typeInfoCache[typeName] || _emptyInfo; - // Read-only: has getter but no setter — assignment is an error. - if (i.readonlyProperties && i.readonlyProperties.indexOf(prop) >= 0) - throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); - // Writable (read-write or write-only) — invoke the setter. - if ((i.properties && i.properties.indexOf(prop) >= 0) || - (i.writeonlyProperties && i.writeonlyProperties.indexOf(prop) >= 0)) - _invoke({ handle: handle, method: 'set_' + prop, args: [_unwrap(value)] }); - // Not a native property — don't intercept, let JS do its thing. + set: function (target, prop, value) { + if (typeof prop !== 'symbol') { + var kind = _kindsOf(_typeInfoCache[typeName] || _emptyInfo)[prop] | 0; + // Read-only (getter, no setter): assigning it is an error. + if (kind & _K_RO) + throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); + // Writable (read-write or write-only): invoke the setter. + if (kind & (_K_PROP | _K_WO)) { + _invoke({ handle: handle, method: _setterName(prop), args: [_unwrap(value)] }); + return true; + } + if (_isIndex(prop) && (_collectionKind(handle, typeName) & 4)) { + _bridgeCall('SetItemAt', [{ __handle: handle }, +prop, _unwrap(value)]); + return true; + } + } + // Not a native property: a plain JS field of this object. + target[prop] = value; return true; }, }); @@ -3419,6 +3566,7 @@ const HELPER_SOURCE: &str = r#" var name = String(rootName); var root = name.split('.')[0]; if (assemblyName && typeof assemblyName === 'string') { + _assemblyCache = Object.create(null); _namespaceAssemblyMap[name] = assemblyName; if (!_namespaceAssemblyMap[root]) { _namespaceAssemblyMap[root] = assemblyName; @@ -3931,6 +4079,10 @@ const HELPER_SOURCE: &str = r#" function _makeNamespaceProxy(path) { var cached = _nsProxyCache[path]; if (cached) return cached; + // Static method functions and child namespace/type proxies by name, made on first access. + var members = Object.create(null); + // Plain JS fields assigned to the namespace or type, made on first assignment. + var fields = null; function _node() {} var proxy = new Proxy(_node, { get: function (_, prop) { @@ -3944,43 +4096,47 @@ const HELPER_SOURCE: &str = r#" return function() { return '[.NET ' + path + ']'; }; var extras = _namespaceExtras[path]; if (extras && Object.prototype.hasOwnProperty.call(extras, prop)) return extras[prop]; + if (fields !== null && prop in fields) return fields[prop]; + var member = members[prop]; + if (member !== undefined) return member; if (prop === '__dotnetType__') return path; if (prop === 'prototype') return _typePrototype(path); // Not a WinRT class: TypeScript's __extends must take the standard path. if (prop === '__nsWinRTClass__' || prop === 'then') return undefined; var assembly = _resolveAssembly(path); - var info = _getTypeInfo(assembly, path); - // Write-only static property — reading it is an error. - if (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_getTypeInfo(assembly, path))[prop] | 0; + // Write-only static property: reading it is an error. + if (kind & _K_SWO) throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + path + '\''); // Readable static property: resolve value immediately. - if (info.staticProperties && info.staticProperties.indexOf(prop) >= 0) - return _wrap(_invoke({ assembly: assembly, typeName: path, method: 'get_' + prop, args: [] })); - // Static method: return a callable. - if (info.staticMethods && info.staticMethods.indexOf(prop) >= 0) { - return function () { - var args = Array.prototype.slice.call(arguments).map(_unwrap); - return _wrap(_invoke({ assembly: assembly, typeName: path, method: prop, args: args })); + if (kind & _K_SPROP) + return _wrap(_invoke({ assembly: assembly, typeName: path, method: _getterName(prop), args: [] })); + // Static method: a function, made once. + if (kind & _K_SMETHOD) { + return members[prop] = function () { + return _wrap(_invokeWith(-1, path, _resolveAssembly(path), prop, arguments)); }; } if (prop === 'extend') return function (nameOrMembers, maybeMembers) { return _extendClass(this, nameOrMembers, maybeMembers); }; if (prop === 'call') return function (thisArg) { return _es5SuperCall(path, thisArg, Array.prototype.slice.call(arguments, 1)); }; if (prop === 'apply') return function (thisArg, args) { return _es5SuperCall(path, thisArg, args ? Array.prototype.slice.call(args) : []); }; // Namespace / sub-type: keep descending. - return _makeNamespaceProxy(path + '.' + prop); + return members[prop] = _makeNamespaceProxy(path + '.' + prop); }, set: function (_, prop, value) { if (typeof prop === 'symbol') return true; var assembly = _resolveAssembly(path); - var info = _getTypeInfo(assembly, path); - // Read-only static property — assignment is an error. - if (info.readonlyStaticProperties && info.readonlyStaticProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_getTypeInfo(assembly, path))[prop] | 0; + // Read-only static property: assignment is an error. + if (kind & _K_SRO) throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + path + '\''); - // Writable (read-write or write-only) — invoke the setter. - if ((info.staticProperties && info.staticProperties.indexOf(prop) >= 0) || - (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0)) - _invoke({ assembly: assembly, typeName: path, method: 'set_' + prop, args: [_unwrap(value)] }); - // Not a native property — don't intercept. + // Writable (read-write or write-only): invoke the setter. + if (kind & (_K_SPROP | _K_SWO)) { + _invoke({ assembly: assembly, typeName: path, method: _setterName(prop), args: [_unwrap(value)] }); + return true; + } + // Not a native property: a plain JS field. + (fields || (fields = Object.create(null)))[prop] = value; return true; }, apply: function (_, _this, args) { @@ -4503,35 +4659,24 @@ pub(crate) fn handle_dotnet_invoke_binary( -1 }; - let type_name = args - .get(1) - .to_string(scope) - .map(|s| s.to_rust_string_lossy(scope)) - .unwrap_or_default(); - - let assembly = args - .get(2) - .to_string(scope) - .map(|s| s.to_rust_string_lossy(scope)) - .unwrap_or_default(); - - let method = args - .get(3) - .to_string(scope) - .map(|s| s.to_rust_string_lossy(scope)) - .unwrap_or_default(); + let mut type_buf = [std::mem::MaybeUninit::uninit(); 256]; + let mut assembly_buf = [std::mem::MaybeUninit::uninit(); 128]; + let mut method_buf = [std::mem::MaybeUninit::uninit(); 128]; + let type_name = v8_name(scope, args.get(1), &mut type_buf); + let assembly = v8_name(scope, args.get(2), &mut assembly_buf); + let method = v8_name(scope, args.get(3), &mut method_buf); let mut req: Vec = Vec::with_capacity(64); // Determine opcode. let op: u8 = if handle >= 0 { - match method.as_str() { + match &*method { "__release" => 0x04, "__members__" => 0x05, _ => 0x01, } } else { - match method.as_str() { + match &*method { "__members__" => 0x06, ".ctor" => 0x03, _ => 0x02, @@ -4561,12 +4706,21 @@ pub(crate) fn handle_dotnet_invoke_binary( } release_collected_js_callbacks(); - match crate::dotnet::call_dotnet_binary(&req) { - Ok(response) => match bin_read_response(scope, &response) { - Ok(v8_val) => retval.set(v8_val), - Err(e) => throw_js_error(scope, &e), - }, - Err(e) => throw_js_error(scope, &e), + match crate::dotnet::call_dotnet_binary_with(&req, |response| bin_read_response(scope, response)) { + Ok(Ok(v8_val)) => retval.set(v8_val), + Ok(Err(e)) | Err(e) => throw_js_error(scope, &e), + } +} + +/// A type, assembly or member name argument as UTF-8, in `buf` when it fits. +fn v8_name<'b, const N: usize>( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local, + buf: &'b mut [std::mem::MaybeUninit; N], +) -> std::borrow::Cow<'b, str> { + match value.to_string(scope) { + Some(s) => s.to_rust_cow_lossy(scope, buf), + None => std::borrow::Cow::Borrowed(""), } } @@ -5035,6 +5189,21 @@ fn bin_write_v8_arg( return; } + // A JS array (for an array, collection or params parameter): tag 0x07, a u32 count, then + // each item encoded like an argument. + if let Ok(array) = v8::Local::::try_from(arg) { + let len = array.length(); + buf.push(0x07); + buf.extend_from_slice(&len.to_le_bytes()); + for i in 0..len { + match array.get_index(scope, i) { + Some(item) => bin_write_v8_arg(buf, scope, item), + None => buf.push(0x00), + } + } + return; + } + // A JS function where a .NET method expects a delegate (Func<>, Action<>, EventHandler, // ...): sent as a callback id the bridge wraps in a delegate of the parameter's type, the way a // JS function passed for a Java interface or an Objective-C block becomes one on Android/iOS. diff --git a/runtime/src/napi_engine/dotnet.rs b/runtime/src/napi_engine/dotnet.rs index f06390d..d213a58 100644 --- a/runtime/src/napi_engine/dotnet.rs +++ b/runtime/src/napi_engine/dotnet.rs @@ -189,6 +189,19 @@ fn napi_bin_write_arg(env: &Env, buf: &mut Vec, arg: &JsUnknown) { crate::dotnet::bin_write_str16(buf, s.as_bytes()); } ValueType::Object | ValueType::Function => { + // A JS array: tag 0x07, a u32 count, then each item encoded like an argument (see + // `global_fns::bin_write_v8_arg`). + if let Some(len) = napi_array_length(env, arg) { + buf.push(0x07); + buf.extend_from_slice(&len.to_le_bytes()); + for i in 0..len { + match napi_array_get(env, arg, i) { + Some(item) => napi_bin_write_arg(env, buf, &item), + None => buf.push(0x00), + } + } + return; + } let obj: JsObject = unsafe { arg.cast() }; if let Ok(hval) = obj.get_named_property::("__handle") { if let Ok(ValueType::Number) = hval.get_type() { @@ -537,8 +550,8 @@ fn native_invoke_bin(ctx: &CallContext) -> napi::Result { } release_collected_js_callbacks(); - match crate::dotnet::call_dotnet_binary(&req) { - Ok(response) => napi_bin_read_response(env, &response).map_err(napi::Error::from_reason), + match crate::dotnet::call_dotnet_binary_with(&req, |response| napi_bin_read_response(env, response)) { + Ok(result) => result.map_err(napi::Error::from_reason), Err(e) => Err(napi::Error::from_reason(e)), } } @@ -575,8 +588,8 @@ fn native_create_delegate(ctx: &CallContext) -> napi::Result { crate::dotnet::bin_write_str16(&mut req, type_name.as_bytes()); req.extend_from_slice(&cb_id.to_le_bytes()); - match crate::dotnet::call_dotnet_binary(&req) { - Ok(response) => napi_bin_read_response(env, &response).map_err(napi::Error::from_reason), + match crate::dotnet::call_dotnet_binary_with(&req, |response| napi_bin_read_response(env, response)) { + Ok(result) => result.map_err(napi::Error::from_reason), Err(e) => Err(napi::Error::from_reason(e)), } } @@ -656,8 +669,8 @@ fn native_create_js_subclass(ctx: &CallContext) -> napi::Result { } release_collected_js_callbacks(); - match crate::dotnet::call_dotnet_binary(&req) { - Ok(response) => napi_bin_read_response(env, &response).map_err(napi::Error::from_reason), + match crate::dotnet::call_dotnet_binary_with(&req, |response| napi_bin_read_response(env, response)) { + Ok(result) => result.map_err(napi::Error::from_reason), Err(e) => Err(napi::Error::from_reason(e)), } } @@ -1974,35 +1987,92 @@ const DOTNET_HELPERS_JS: &str = r#" // throws on error, returns the result value directly. function _invoke(req) { var handle = (req.handle !== undefined && req.handle !== null) ? req.handle : -1; - var args = req.args || []; - return globalThis.__nsDotNetInvokeBin( - handle, - req.typeName || '', - req.assembly || '', - req.method || '', - ...args - ); + var args = req.args; + var typeName = req.typeName || '', assembly = req.assembly || '', method = req.method || ''; + if (!args || args.length === 0) return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method); + if (args.length === 1) return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method, args[0]); + return globalThis.__nsDotNetInvokeBin(handle, typeName, assembly, method, ...args); + } + + // _invoke with a function's own `arguments`, unwrapped straight into the native call (no + // request object or intermediate arrays). + function _invokeWith(handle, typeName, assembly, method, args) { + var invoke = globalThis.__nsDotNetInvokeBin; + switch (args.length) { + case 0: return invoke(handle, typeName, assembly, method); + case 1: return invoke(handle, typeName, assembly, method, _unwrap(args[0])); + case 2: return invoke(handle, typeName, assembly, method, _unwrap(args[0]), _unwrap(args[1])); + case 3: return invoke(handle, typeName, assembly, method, _unwrap(args[0]), _unwrap(args[1]), _unwrap(args[2])); + } + // Built by push so the array stays packed: apply on a holey array takes V8's slow path. + var call = [handle, typeName, assembly, method]; + for (var i = 0; i < args.length; i++) call.push(_unwrap(args[i])); + return invoke.apply(null, call); } // Populated lazily on first access; avoids repeated bridge round-trips. var _typeInfoCache = {}; - var _emptyInfo = { methods: [], properties: [], staticMethods: [], staticProperties: [], readonlyProperties: [], readonlyStaticProperties: [], writeonlyProperties: [], writeonlyStaticProperties: [] }; + // A type's member lists get `kinds` (member name -> _K_* flags), so the proxy traps look a name up + // instead of scanning the lists on every access. + var _K_PROP = 1, _K_WO = 2, _K_RO = 4, _K_SPROP = 8, _K_SMETHOD = 16, _K_SWO = 32, _K_SRO = 64; + function _withKinds(info) { + var kinds = Object.create(null); + function add(list, flag) { + if (!list) return; + for (var i = 0; i < list.length; i++) kinds[list[i]] = (kinds[list[i]] | 0) | flag; + } + add(info.properties, _K_PROP); + add(info.writeonlyProperties, _K_WO); + add(info.readonlyProperties, _K_RO); + add(info.staticProperties, _K_SPROP); + add(info.staticMethods, _K_SMETHOD); + add(info.writeonlyStaticProperties, _K_SWO); + add(info.readonlyStaticProperties, _K_SRO); + info.kinds = kinds; + return info; + } + function _kindsOf(info) { return info.kinds || _withKinds(info).kinds; } + + // "get_X" / "set_X" accessor names, made once per property name. + var _getterNames = Object.create(null), _setterNames = Object.create(null); + function _getterName(prop) { return _getterNames[prop] || (_getterNames[prop] = 'get_' + prop); } + function _setterName(prop) { return _setterNames[prop] || (_setterNames[prop] = 'set_' + prop); } + + // The native COM pointer of a handle's object as the BigInt the runtime reads from `__native_ptr`, + // or undefined when it has none. The bridge makes it for a plain .NET object only on request. + function _nativePtrOf(handle) { + try { + var p = _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'GetNativePtrForHandle', args: [handle] }); + if (typeof p !== 'number' || p === 0) return undefined; + return typeof BigInt === 'function' ? BigInt(p) : p; + } catch (_) { + return undefined; + } + } + var _emptyInfo = _withKinds({ methods: [], properties: [], staticMethods: [], staticProperties: [], readonlyProperties: [], readonlyStaticProperties: [], writeonlyProperties: [], writeonlyStaticProperties: [] }); // Optional mapping for namespace prefixes -> assembly simple-name. // Exact namespaces are preferred first, then progressively shorter // prefixes are tried as a fallback. var _namespaceAssemblyMap = Object.create(null); + // _resolveAssembly results by type name, cleared whenever the namespace map changes. + var _assemblyCache = Object.create(null); + function _resolveAssembly(typeName) { if (!typeName || typeof typeName !== 'string') return ''; - var probe = String(typeName); + var cached = _assemblyCache[typeName]; + if (cached !== undefined) return cached; + var resolved = ''; + var probe = typeName; while (probe) { var assembly = _namespaceAssemblyMap[probe]; - if (typeof assembly === 'string' && assembly) return assembly; + if (typeof assembly === 'string' && assembly) { resolved = assembly; break; } var lastDot = probe.lastIndexOf('.'); if (lastDot < 0) break; probe = probe.substring(0, lastDot); } - return ''; + _assemblyCache[typeName] = resolved; + return resolved; } // When the JS GC collects a DotNet proxy the registry fires the @@ -2036,39 +2106,117 @@ const DOTNET_HELPERS_JS: &str = r#" if (!typeName) return _emptyInfo; var cached = _typeInfoCache[typeName]; if (cached !== undefined) return cached; + var info = _emptyInfo; try { // Respect an explicitly-provided assembly name. When empty, // let the managed side attempt resolution (BCL types via Type.GetType). var asm = (typeof assembly === 'string') ? assembly : ''; - var info = _invoke({ assembly: asm, typeName: typeName, method: '__members__', args: [] }); - _typeInfoCache[typeName] = (info && typeof info === 'object') ? info : _emptyInfo; - } catch (e) { - _typeInfoCache[typeName] = _emptyInfo; - } - return _typeInfoCache[typeName]; + var members = _invoke({ assembly: asm, typeName: typeName, method: '__members__', args: [] }); + if (members && typeof members === 'object') info = _withKinds(members); + } catch (e) {} + _typeInfoCache[typeName] = info; + return info; } function _unwrap(v) { if (v && typeof v === 'object' && typeof v.__handle === 'number') return { __handle: v.__handle }; + if (Array.isArray(v)) return v.map(_unwrap); + // A JS Date reaches DateTime / DateTimeOffset parameters as its ISO 8601 string. + if (v instanceof Date) return isNaN(v.getTime()) ? null : v.toISOString(); + // A JS function passed where .NET expects a delegate gets .NET objects as arguments, the same + // proxies a method result would be, rather than raw handles. + if (typeof v === 'function' && typeof v.__dotnetType__ !== 'string' && typeof v.__handle !== 'number') + return _callbackFor(v); return v; } + // The wrapper sent for a JS callback: one per function, so passing it again sends the same one. + var _callbacks = typeof WeakMap === 'function' ? new WeakMap() : null; + function _callbackFor(fn) { + var wrapped = _callbacks && _callbacks.get(fn); + if (wrapped) return wrapped; + wrapped = function () { return fn.apply(this, Array.prototype.map.call(arguments, _wrap)); }; + if (_callbacks) _callbacks.set(fn, wrapped); + return wrapped; + } + + // .NET collections (List, Dictionary, ObservableCollection) act like JS arrays: `length`, + // `list[i]`, for...of, and the array methods (map, filter, ...) on a copy of their items. + // _collectionKind is the bridge's CollectionKind flags (1 enumerable, 2 count, 4 indexed) by type. + var _collectionKinds = Object.create(null); + var _arrayMethods = Object.create(null); + ['forEach', 'map', 'filter', 'reduce', 'reduceRight', 'some', 'every', 'find', 'findIndex', 'findLast', + 'findLastIndex', 'indexOf', 'lastIndexOf', 'includes', 'join', 'slice', 'concat', 'flat', 'flatMap', + 'entries', 'keys', 'values', 'at', 'toJSON'].forEach(function (name) { _arrayMethods[name] = 1; }); + + function _bridgeCall(method, args) { + return _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: method, args: args }); + } + + function _isIndex(prop) { + var c = prop.charCodeAt(0); + return c >= 48 && c <= 57 && /^(0|[1-9]\d*)$/.test(prop); + } + + function _collectionKind(handle, typeName) { + var kind = typeName ? _collectionKinds[typeName] : undefined; + if (kind !== undefined) return kind; + try { kind = _bridgeCall('CollectionKind', [{ __handle: handle }]) | 0; } catch (_) { kind = 0; } + if (typeName) _collectionKinds[typeName] = kind; + return kind; + } + + function _collectionItems(handle) { + return _wrap(_bridgeCall('CollectionItems', [{ __handle: handle }])) || []; + } + + // `prop` of a collection proxy when it is one of the array-like members, else undefined. + function _collectionMember(handle, typeName, prop) { + var kind = _collectionKind(handle, typeName); + if (prop === 'length') return (kind & 2) ? _bridgeCall('CollectionCount', [{ __handle: handle }]) : undefined; + if (_arrayMethods[prop] === 1) { + if (!(kind & 1)) return undefined; + return function () { + var items = _collectionItems(handle); + return prop === 'toJSON' ? items : Array.prototype[prop].apply(items, arguments); + }; + } + return (kind & 4) ? _wrap(_bridgeCall('ItemAt', [{ __handle: handle }, +prop])) : undefined; + } + + // Value types whose JS string is their .NET ToString(), so `${DateTime.Now}` reads as a date. + var _netToString = { 'System.DateTime': 1, 'System.DateTimeOffset': 1, 'System.TimeSpan': 1 }; + // Makes sw.Stop() and sw.Elapsed both work naturally. // The proxy is registered with _dotNetFinalizers so the CLR reference // is released automatically when JS GC collects the proxy. function _makeDotNetInstance(handle, assembly, typeName, isTask, nativePtr) { - var info = _getTypeInfo(assembly, typeName); + _getTypeInfo(assembly, typeName); + // Method functions by name, made on first access. + var methods = null; + var ptrKnown = nativePtr !== undefined; + // The proxy target holds the plain JS fields set on the object. var proxy = new Proxy({}, { - get: function (_, prop) { - if (typeof prop === 'symbol') return undefined; + get: function (target, prop) { + if (typeof prop === 'symbol') { + if (prop === Symbol.iterator && (_collectionKind(handle, typeName) & 1)) + return function () { return _collectionItems(handle)[Symbol.iterator](); }; + return target[prop]; + } if (prop === '__handle') return handle; if (prop === '__type') return typeName; if (prop === '__isTask') return isTask === true; - if (prop === '__native_ptr') return nativePtr; + if (prop === '__native_ptr') { + if (!ptrKnown) { ptrKnown = true; nativePtr = _nativePtrOf(handle); } + return nativePtr; + } if (prop === 'release') return function () { _invoke({ handle: handle, method: '__release', args: [] }); }; + // Like a plain object, so `'' + obj` and comparisons fall through to toString. + if (prop === 'valueOf') return function () { return proxy; }; if (prop === 'toString') return function () { + if (_netToString[typeName] === 1) return String(_invoke({ handle: handle, method: 'ToString', args: [] })); return '[DotNetObject ' + typeName + ' #' + handle + ']'; }; // A Task/ValueTask (awaitable) result is thenable, so `await obj.SomethingAsync()` works; @@ -2079,30 +2227,42 @@ const DOTNET_HELPERS_JS: &str = r#" return globalThis.NSWinRT.dotnet.taskToPromise({ __handle: handle, __isTask: true }).then(onFulfilled, onRejected); }; } + if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop]; // Re-read info in case it was populated after construction. - var i = _typeInfoCache[typeName] || _emptyInfo; - // Write-only: has setter but no getter: reading it is an error. - if (i.writeonlyProperties && i.writeonlyProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_typeInfoCache[typeName] || _emptyInfo)[prop] | 0; + // Write-only (setter, no getter): reading it is an error. + if (kind & _K_WO) throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); - if (i.properties && i.properties.indexOf(prop) >= 0) - return _wrap(_invoke({ handle: handle, method: 'get_' + prop, args: [] })); - // Not a native property: return a callable for method dispatch. - return function () { - var args = Array.prototype.slice.call(arguments).map(_unwrap); - return _wrap(_invoke({ handle: handle, method: prop, args: args })); - }; + if (kind & _K_PROP) + return _wrap(_invoke({ handle: handle, method: _getterName(prop), args: [] })); + if (prop === 'length' || _arrayMethods[prop] === 1 || _isIndex(prop)) { + var member = _collectionMember(handle, typeName, prop); + if (member !== undefined) return member; + } + // Not a native property: a function for method dispatch. + if (methods === null) methods = Object.create(null); + return methods[prop] || (methods[prop] = function () { + return _wrap(_invokeWith(handle, '', '', prop, arguments)); + }); }, - set: function (_, prop, value) { - if (typeof prop === 'symbol') return true; - var i = _typeInfoCache[typeName] || _emptyInfo; - // Read-only: has getter but no setter: assignment is an error. - if (i.readonlyProperties && i.readonlyProperties.indexOf(prop) >= 0) - throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); - // Writable (read-write or write-only): invoke the setter. - if ((i.properties && i.properties.indexOf(prop) >= 0) || - (i.writeonlyProperties && i.writeonlyProperties.indexOf(prop) >= 0)) - _invoke({ handle: handle, method: 'set_' + prop, args: [_unwrap(value)] }); - // Not a native property: don't intercept, let JS do its thing. + set: function (target, prop, value) { + if (typeof prop !== 'symbol') { + var kind = _kindsOf(_typeInfoCache[typeName] || _emptyInfo)[prop] | 0; + // Read-only (getter, no setter): assigning it is an error. + if (kind & _K_RO) + throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + typeName + '\''); + // Writable (read-write or write-only): invoke the setter. + if (kind & (_K_PROP | _K_WO)) { + _invoke({ handle: handle, method: _setterName(prop), args: [_unwrap(value)] }); + return true; + } + if (_isIndex(prop) && (_collectionKind(handle, typeName) & 4)) { + _bridgeCall('SetItemAt', [{ __handle: handle }, +prop, _unwrap(value)]); + return true; + } + } + // Not a native property: a plain JS field of this object. + target[prop] = value; return true; }, }); @@ -2151,6 +2311,7 @@ const DOTNET_HELPERS_JS: &str = r#" var name = String(rootName); var root = name.split('.')[0]; if (assemblyName && typeof assemblyName === 'string') { + _assemblyCache = Object.create(null); _namespaceAssemblyMap[name] = assemblyName; if (!_namespaceAssemblyMap[root]) { _namespaceAssemblyMap[root] = assemblyName; @@ -2663,6 +2824,10 @@ const DOTNET_HELPERS_JS: &str = r#" function _makeNamespaceProxy(path) { var cached = _nsProxyCache[path]; if (cached) return cached; + // Static method functions and child namespace/type proxies by name, made on first access. + var members = Object.create(null); + // Plain JS fields assigned to the namespace or type, made on first assignment. + var fields = null; function _node() {} var proxy = new Proxy(_node, { get: function (_, prop) { @@ -2676,43 +2841,47 @@ const DOTNET_HELPERS_JS: &str = r#" return function() { return '[.NET ' + path + ']'; }; var extras = _namespaceExtras[path]; if (extras && Object.prototype.hasOwnProperty.call(extras, prop)) return extras[prop]; + if (fields !== null && prop in fields) return fields[prop]; + var member = members[prop]; + if (member !== undefined) return member; if (prop === '__dotnetType__') return path; if (prop === 'prototype') return _typePrototype(path); // Not a WinRT class: TypeScript's __extends must take the standard path. if (prop === '__nsWinRTClass__' || prop === 'then') return undefined; var assembly = _resolveAssembly(path); - var info = _getTypeInfo(assembly, path); - // Write-only static property — reading it is an error. - if (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_getTypeInfo(assembly, path))[prop] | 0; + // Write-only static property: reading it is an error. + if (kind & _K_SWO) throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + path + '\''); // Readable static property: resolve value immediately. - if (info.staticProperties && info.staticProperties.indexOf(prop) >= 0) - return _wrap(_invoke({ assembly: assembly, typeName: path, method: 'get_' + prop, args: [] })); - // Static method: return a callable. - if (info.staticMethods && info.staticMethods.indexOf(prop) >= 0) { - return function () { - var args = Array.prototype.slice.call(arguments).map(_unwrap); - return _wrap(_invoke({ assembly: assembly, typeName: path, method: prop, args: args })); + if (kind & _K_SPROP) + return _wrap(_invoke({ assembly: assembly, typeName: path, method: _getterName(prop), args: [] })); + // Static method: a function, made once. + if (kind & _K_SMETHOD) { + return members[prop] = function () { + return _wrap(_invokeWith(-1, path, _resolveAssembly(path), prop, arguments)); }; } if (prop === 'extend') return function (nameOrMembers, maybeMembers) { return _extendClass(this, nameOrMembers, maybeMembers); }; if (prop === 'call') return function (thisArg) { return _es5SuperCall(path, thisArg, Array.prototype.slice.call(arguments, 1)); }; if (prop === 'apply') return function (thisArg, args) { return _es5SuperCall(path, thisArg, args ? Array.prototype.slice.call(args) : []); }; // Namespace / sub-type: keep descending. - return _makeNamespaceProxy(path + '.' + prop); + return members[prop] = _makeNamespaceProxy(path + '.' + prop); }, set: function (_, prop, value) { if (typeof prop === 'symbol') return true; var assembly = _resolveAssembly(path); - var info = _getTypeInfo(assembly, path); - // Read-only static property — assignment is an error. - if (info.readonlyStaticProperties && info.readonlyStaticProperties.indexOf(prop) >= 0) + var kind = _kindsOf(_getTypeInfo(assembly, path))[prop] | 0; + // Read-only static property: assignment is an error. + if (kind & _K_SRO) throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + path + '\''); - // Writable (read-write or write-only) — invoke the setter. - if ((info.staticProperties && info.staticProperties.indexOf(prop) >= 0) || - (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0)) - _invoke({ assembly: assembly, typeName: path, method: 'set_' + prop, args: [_unwrap(value)] }); - // Not a native property — don't intercept. + // Writable (read-write or write-only): invoke the setter. + if (kind & (_K_SPROP | _K_SWO)) { + _invoke({ assembly: assembly, typeName: path, method: _setterName(prop), args: [_unwrap(value)] }); + return true; + } + // Not a native property: a plain JS field. + (fields || (fields = Object.create(null)))[prop] = value; return true; }, apply: function (_, _this, args) {