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