diff --git a/dotnet-bridge-benchmarks/BridgeBenchmarks.cs b/dotnet-bridge-benchmarks/BridgeBenchmarks.cs index fcb6dac..4623ca3 100644 --- a/dotnet-bridge-benchmarks/BridgeBenchmarks.cs +++ b/dotnet-bridge-benchmarks/BridgeBenchmarks.cs @@ -7,7 +7,7 @@ using BenchmarkDotNet.Running; using NativeScriptBridge; -BenchmarkSwitcher.FromTypes([typeof(BridgeBenchmarks), typeof(AbiBenchmarks)]).Run(args); +BenchmarkSwitcher.FromTypes([typeof(BridgeBenchmarks), typeof(AbiBenchmarks), typeof(CallbackBenchmarks), typeof(EntryBenchmarks)]).Run(args); /// /// Measures the hot-path dispatch cost for the JSON and binary protocols. diff --git a/dotnet-bridge-benchmarks/CallbackBenchmarks.cs b/dotnet-bridge-benchmarks/CallbackBenchmarks.cs new file mode 100644 index 0000000..1402acc --- /dev/null +++ b/dotnet-bridge-benchmarks/CallbackBenchmarks.cs @@ -0,0 +1,69 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using NativeScriptBridge; + +public class CallbackBase +{ + public virtual int Compute(int value) => value; + + public int Run(int value) => Compute(value); +} + +/// +/// Managed cost of .NET calling into JS: a JS function passed as a delegate, and a JS subclass +/// override called from C#. The "JS side" is an unmanaged function that answers at once, so +/// what's measured is the bridge's own work (argument encoding, response handling). +/// +/// dotnet run -c Release -- --filter *CallbackBenchmarks* --job short +/// +[MemoryDiagnoser] +public unsafe class CallbackBenchmarks +{ + private Func _func = null!; + private Action _action = null!; + private CallbackBase _override = null!; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void FakeJs(int id, byte* args, int len, byte** resp, int* respLen) + { + // Every callback answers with the int 42, allocated the way the runtime allocates it. + var p = (byte*)Marshal.AllocHGlobal(5); + p[0] = 0x03; + *(int*)(p + 1) = 42; + *resp = p; + *respLen = 5; + } + + [GlobalSetup] + public void Setup() + { + Bridge.ClearCaches(); + Bridge.s_jsInvoker = &FakeJs; + _func = (Func)Bridge.MakeJsDelegate(typeof(Func), 1); + _action = (Action)Bridge.MakeJsDelegate(typeof(Action), 2); + + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x0A); + w.WriteString16(""); + w.WriteString16(typeof(CallbackBase).FullName!); + w.WriteI32(0); + w.WriteI32(1); + w.WriteString16("Compute"); + w.WriteI32(3); + w.WriteByte(0); + var r = new BinReader(buf.WrittenSpan); + var handle = Bridge.DispatchBin(ref r).HandleId(); + _override = (CallbackBase)Bridge.s_handles[handle]!; + if (_override.Run(1) != 42) throw new InvalidOperationException("override did not reach the fake JS side"); + } + + [Benchmark] public int DelegateWithResult() => _func(5); + + [Benchmark] public void VoidDelegate() => _action("hello"); + + [Benchmark] public int OverrideFromCSharp() => _override.Run(5); +} diff --git a/dotnet-bridge-benchmarks/EntryBenchmarks.cs b/dotnet-bridge-benchmarks/EntryBenchmarks.cs new file mode 100644 index 0000000..25a9494 --- /dev/null +++ b/dotnet-bridge-benchmarks/EntryBenchmarks.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; +using BenchmarkDotNet.Attributes; +using NativeScriptBridge; + +/// +/// What a method costs the first time JS calls it (building its invoker) and after that. +/// +/// dotnet run -c Release -- --filter *EntryBenchmarks* --job short +/// +[MemoryDiagnoser] +public class EntryBenchmarks +{ + private static readonly MethodInfo s_max = typeof(Math).GetMethod(nameof(Math.Max), [typeof(int), typeof(int)])!; + private static readonly MethodInfo s_append = typeof(System.Text.StringBuilder).GetMethod(nameof(System.Text.StringBuilder.Append), [typeof(string)])!; + private readonly object?[] _maxArgs = [3, 7]; + private readonly System.Text.StringBuilder _sb = new(); + private readonly object?[] _appendArgs = [""]; + private DispatchEntry _warmMax; + private DispatchEntry _warmAppend; + + [GlobalSetup] + public void Setup() + { + _warmMax = Bridge.BuildDispatchEntry(typeof(Math), s_max); + _warmAppend = Bridge.BuildDispatchEntry(typeof(System.Text.StringBuilder), s_append); + } + + [Benchmark] public object? FirstCallStatic() => Bridge.BuildDispatchEntry(typeof(Math), s_max).Invoke!(null, _maxArgs); + + [Benchmark] public object? FirstCallInstance() => Bridge.BuildDispatchEntry(typeof(System.Text.StringBuilder), s_append).Invoke!(_sb, _appendArgs); + + [Benchmark] public object? WarmStatic() => _warmMax.Invoke!(null, _maxArgs); + + [Benchmark] public object? WarmInstance() => _warmAppend.Invoke!(_sb, _appendArgs); +} diff --git a/dotnet-bridge-tests/InteropTests.cs b/dotnet-bridge-tests/InteropTests.cs new file mode 100644 index 0000000..d423a8f --- /dev/null +++ b/dotnet-bridge-tests/InteropTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NativeScriptBridge; +using Xunit; + +namespace DotNetBridgeTests; + +public static class InteropFixtures +{ + public const int Limit = 10; + public static readonly string Fixed = "fixed"; + public static int Counter; + + public static string Optional(string a, string b = "B", int c = 3) => $"{a}{b}{c}"; + public static bool TryDouble(string text, out int result) => (result = int.TryParse(text, out var v) ? v * 2 : 0) != 0; + public static void Swap(ref int a, ref int b) => (a, b) = (b, a); + public static string Point(int x = 0, int y = 0) => $"({x},{y})"; + public static T Identity(T value) => value; + public static string NameOf() => typeof(T).Name; + public static int CountOf(IEnumerable items) => items.Count(); + public static long Echo(long value) => value; + public static int Sum(byte[] data) => data.Sum(b => (int)b); + public static int SumInts(int[] data) => data.Sum(); + public static long StreamLength(Stream s) => s.Length; + public static object Shared { get; } = new object(); + + public class Nested + { + public static string Where() => "nested"; + } +} + +public sealed class FieldHolder +{ + public int Value = 7; + public readonly int Fixed = 1; +} + +public static class InteropExtensions +{ + public static int Doubled(this FieldHolder holder) => holder.Value * 2; +} + +[Collection("Bridge")] +/// Handle identity, fields, the slow-path binder (optional, out/ref, named, generic, extension +/// methods), longs, binary data and type names. +public sealed class InteropTests : IDisposable +{ + public InteropTests() => Bridge.ClearCaches(); + public void Dispose() => Bridge.ClearCaches(); + + private const string Fixtures = "DotNetBridgeTests.InteropFixtures"; + + [Fact] + public void SameObject_GetsTheSameHandle_UntilEveryExportIsGivenBack() + { + var first = Static(Fixtures, "get_Shared", null, 0).HandleId(); + var second = Static(Fixtures, "get_Shared", null, 0).HandleId(); + Assert.Equal(first, second); + + Release(first, 1); + Assert.True(Bridge.s_handles.ContainsKey(first)); + Release(first, 1); + Assert.False(Bridge.s_handles.ContainsKey(first)); + + var third = Static(Fixtures, "get_Shared", null, 0).HandleId(); + Assert.NotEqual(first, third); + } + + [Fact] + public void ReleaseWithoutACount_DropsTheHandle() + { + var h = Static(Fixtures, "get_Shared", null, 0).HandleId(); + Static(Fixtures, "get_Shared", null, 0); + Release(h, null); + Assert.False(Bridge.s_handles.ContainsKey(h)); + } + + [Fact] + public void Fields_ReadAndWrite() + { + Assert.Equal(10, Static(Fixtures, "get_Limit", null, 0).PrimitiveValue()); + Assert.Equal("fixed", Static(Fixtures, "get_Fixed", null, 0).PrimitiveValue()); + Static(Fixtures, "set_Counter", w => { w.WriteByte(0x03); w.WriteI32(4); }); + Assert.Equal(4, InteropFixtures.Counter); + } + + [Fact] + public void ReadOnlyField_RefusesAssignment() + { + var h = Construct(typeof(FieldHolder).FullName!); + Assert.Throws(() => Instance(h, "set_Fixed", w => { w.WriteByte(0x03); w.WriteI32(2); })); + Instance(h, "set_Value", w => { w.WriteByte(0x03); w.WriteI32(9); }); + Assert.Equal(9, Instance(h, "get_Value", null, 0).PrimitiveValue()); + } + + [Fact] + public void MembersList_IncludesFieldsAsProperties() + { + var result = Members(typeof(FieldHolder).FullName!); + var buf = new ArrayBufferWriter(); + result.WriteAsBin(buf); + var text = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan); + Assert.Contains("Value", text); + Assert.Contains("Fixed", text); + Assert.Contains("nestedTypes", text); + } + + [Fact] + public void OptionalParameters_AreFilledIn() + { + Assert.Equal("aB3", Static(Fixtures, "Optional", w => { w.WriteByte(0x05); w.WriteString16("a"); }).PrimitiveValue()); + Assert.Equal("ax3", Static(Fixtures, "Optional", w => { w.WriteByte(0x05); w.WriteString16("a"); w.WriteByte(0x05); w.WriteString16("x"); }, 2).PrimitiveValue()); + } + + [Fact] + public void OmittedOutParameters_ComeBackWithTheResult() + { + var result = Static(Fixtures, "TryDouble", w => { w.WriteByte(0x05); w.WriteString16("21"); }); + Assert.Equal(new object?[] { true, 42 }, Items(result)); + } + + [Fact] + public void ByRefCall_ReturnsTheArgumentsAfterTheCall() + { + var result = Static(Fixtures, "&Swap", w => { w.WriteByte(0x03); w.WriteI32(1); w.WriteByte(0x03); w.WriteI32(2); }, 2); + Assert.Equal(new object?[] { null, 2, 1 }, Items(result)); + } + + [Fact] + public void NamedArguments_GoToTheirParameters() + { + var result = Static(Fixtures, "Point#y", w => { w.WriteByte(0x03); w.WriteI32(5); }); + Assert.Equal("(0,5)", result.PrimitiveValue()); + } + + [Fact] + public void GenericMethods_InferOrTakeTypeArguments() + { + Assert.Equal("same", Static(Fixtures, "Identity", w => { w.WriteByte(0x05); w.WriteString16("same"); }).PrimitiveValue()); + Assert.Equal("Guid", Static(Fixtures, "NameOf", null, 0).PrimitiveValue()); + var count = Static(Fixtures, "CountOf", w => + { + w.WriteByte(0x07); w.WriteU32(3); + for (int i = 0; i < 3; i++) { w.WriteByte(0x03); w.WriteI32(i); } + }); + Assert.Equal(3, count.PrimitiveValue()); + } + + [Fact] + public void ExtensionMethods_AreCalledOnInstances() + { + var h = Construct(typeof(FieldHolder).FullName!); + Assert.Equal(14, Instance(h, "Doubled", null, 0).PrimitiveValue()); + } + + [Fact] + public void Int64Arguments_AndBigResults() + { + var result = Static(Fixtures, "Echo", w => { w.WriteByte(0x10); w.WriteI64(9007199254740993L); }); + var buf = new ArrayBufferWriter(); + result.WriteAsBin(buf); + Assert.Equal(0x10, buf.WrittenSpan[0]); + Assert.Equal(9007199254740993L, BitConverter.ToInt64(buf.WrittenSpan[1..])); + + var small = Static(Fixtures, "Echo", w => { w.WriteByte(0x10); w.WriteI64(5); }); + buf.ResetWrittenCount(); + small.WriteAsBin(buf); + Assert.Equal(0x04, buf.WrittenSpan[0]); + } + + [Fact] + public void TypedArrays_BecomeArraysAndStreams() + { + Assert.Equal(6, Static(Fixtures, "Sum", w => Bytes(w, 0, [1, 2, 3])).PrimitiveValue()); + Assert.Equal(3, Static(Fixtures, "SumInts", w => Bytes(w, 4, BitConverter.GetBytes(1).Concat(BitConverter.GetBytes(2)).ToArray())).PrimitiveValue()); + Assert.Equal(4L, Static(Fixtures, "StreamLength", w => Bytes(w, 0, [1, 2, 3, 4])).PrimitiveValue()); + } + + [Fact] + public void ByteArrayResults_AreBinary() + { + var result = Static("System.Convert", "FromBase64String", w => { w.WriteByte(0x05); w.WriteString16("AQL/"); }); + var buf = new ArrayBufferWriter(); + result.WriteAsBin(buf); + Assert.Equal(new byte[] { 0x12, 0, 3, 0, 0, 0, 1, 2, 255 }, buf.WrittenSpan.ToArray()); + } + + [Fact] + public void NestedAndGenericTypeNames_Resolve() + { + Assert.Equal("nested", Static(Fixtures + ".Nested", "Where", null, 0).PrimitiveValue()); + var list = Construct("System.Collections.Generic.List`1[System.String]"); + Assert.IsType>(Bridge.s_handles[list]); + var dict = Construct("System.Collections.Generic.Dictionary`2[System.String,DotNetBridgeTests.FieldHolder]"); + Assert.IsType>(Bridge.s_handles[dict]); + } + + [Fact] + public void CallNames_Parse() + { + var plain = Bridge.CallName.Parse("Add"); + Assert.False(plain.WantsByRef); + Assert.Null(plain.TypeArgs); + var full = Bridge.CallName.Parse("&Convert#a,b"); + Assert.True(full.WantsByRef); + Assert.Equal("Convert", full.Name); + Assert.Equal(new[] { "System.Collections.Generic.List`1[System.Int32]", "System.String" }, full.TypeArgs); + Assert.Equal(new[] { "a", "b" }, full.Named); + } + + private static object?[] Items(DispatchResult r) => ((System.Collections.IEnumerable)r.PrimitiveValue()!).Cast().ToArray(); + + private static void Bytes(BinWriter w, byte kind, byte[] data) + { + w.WriteByte(0x12); + w.WriteByte(kind); + w.WriteU32((uint)data.Length); + w.WriteBytes(data); + } + + 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); + } + + private static int Construct(string type) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x03); + w.WriteString16(type); + w.WriteString16(""); + w.WriteByte(0); + var r = new BinReader(buf.WrittenSpan); + return Bridge.DispatchBin(ref r).HandleId(); + } + + private static DispatchResult Members(string type) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x06); + w.WriteString16(type); + w.WriteString16(""); + var r = new BinReader(buf.WrittenSpan); + return Bridge.DispatchBin(ref r); + } + + private static DispatchResult Instance(int handle, string method, Action? args, int argCount = 1) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + w.WriteByte(0x01); + w.WriteI32(handle); + w.WriteString16(method); + w.WriteByte((byte)argCount); + args?.Invoke(w); + var r = new BinReader(buf.WrittenSpan); + return Bridge.DispatchBin(ref r); + } + + private static void Release(int handle, int? count) + { + var buf = new ArrayBufferWriter(16); + var w = new BinWriter(buf); + w.WriteByte(0x04); + w.WriteI32(handle); + if (count is { } n) w.WriteI32(n); + var r = new BinReader(buf.WrittenSpan); + Bridge.DispatchBin(ref r); + } +} diff --git a/dotnet-bridge/BinaryProtocol.cs b/dotnet-bridge/BinaryProtocol.cs index 83e34be..f32e2b4 100644 --- a/dotnet-bridge/BinaryProtocol.cs +++ b/dotnet-bridge/BinaryProtocol.cs @@ -19,7 +19,7 @@ internal readonly struct WinRtRef(long ptr) // A JS function passed where a delegate is expected (tag 0x0C): the runtime's callback id, wrapped // in a delegate of the parameter's type when the call's arguments are coerced. -internal readonly struct JsFunctionRef(int id) +public readonly struct JsFunctionRef(int id) { public readonly int Id = id; } @@ -140,9 +140,21 @@ public uint ReadU32() case 0x0A: return new WinRtRef(ReadI64()); case 0x0C: return new JsFunctionRef(ReadI32()); case 0x0D: return new JsJsonValue(ReadString32()); + case 0x10: return ReadI64(); + case 0x11: return unchecked((ulong)ReadI64()); + case 0x12: return ReadTypedArray(); default: return null; } } + + public JsTypedArray ReadTypedArray() + { + var kind = ReadByte(); + var len = (int)ReadU32(); + var bytes = _buf.Slice(_pos, len).ToArray(); + _pos += len; + return new JsTypedArray(kind, bytes); + } } // Decoded names by their UTF-8 bytes. Per thread and direct-mapped: a lookup takes no lock and a @@ -224,6 +236,12 @@ public void WriteString32(ReadOnlySpan s) _buf.Advance(byteCount); } + public void WriteBytes(ReadOnlySpan bytes) + { + bytes.CopyTo(_buf.GetSpan(bytes.Length)); + _buf.Advance(bytes.Length); + } + public void WriteString16(ReadOnlySpan s) { var byteCount = Encoding.UTF8.GetByteCount(s); diff --git a/dotnet-bridge/Bridge.Binary.cs b/dotnet-bridge/Bridge.Binary.cs new file mode 100644 index 0000000..77991a3 --- /dev/null +++ b/dotnet-bridge/Bridge.Binary.cs @@ -0,0 +1,154 @@ +using System; +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; + +namespace NativeScriptBridge; + +// A JS typed array, ArrayBuffer or DataView (tag 0x12). Kinds: 0 bytes (Uint8Array, +// Uint8ClampedArray, ArrayBuffer, DataView), 1 Int8, 2 Int16, 3 Uint16, 4 Int32, 5 Uint32, +// 6 Float32, 7 Float64, 8 BigInt64, 9 BigUint64. +internal sealed class JsTypedArray(byte kind, byte[] bytes) +{ + public readonly byte Kind = kind; + public readonly byte[] Bytes = bytes; + + public Type ElementType => ElementTypeOf(Kind); + + public static Type ElementTypeOf(byte kind) => kind switch + { + 1 => typeof(sbyte), + 2 => typeof(short), + 3 => typeof(ushort), + 4 => typeof(int), + 5 => typeof(uint), + 6 => typeof(float), + 7 => typeof(double), + 8 => typeof(long), + 9 => typeof(ulong), + _ => typeof(byte), + }; + + // The typed-array kind for a .NET array element type, or -1 when JS has none. + public static int KindOf(Type element) => + element == typeof(byte) ? 0 : element == typeof(sbyte) ? 1 : element == typeof(short) ? 2 + : element == typeof(ushort) ? 3 : element == typeof(int) ? 4 : element == typeof(uint) ? 5 + : element == typeof(float) ? 6 : element == typeof(double) ? 7 : element == typeof(long) ? 8 + : element == typeof(ulong) ? 9 : -1; + + // The items as an array of their own element type. + public Array ToNativeArray() + { + var element = ElementType; + if (element == typeof(byte)) return Bytes; + var size = Marshal.SizeOf(element); + var array = Array.CreateInstance(element, Bytes.Length / size); + Buffer.BlockCopy(Bytes, 0, array, 0, array.Length * size); + return array; + } + + // The items as an array of `element`, converted when the types differ. + public Array ToArray(Type element) + { + var native = ToNativeArray(); + if (native.GetType().GetElementType() == element) return native; + var array = Array.CreateInstance(element, native.Length); + for (int i = 0; i < native.Length; i++) + array.SetValue(Convert.ChangeType(native.GetValue(i), element), i); + return array; + } +} + +public static partial class Bridge +{ + private const double MaxSafeInteger = 9007199254740991; + + // How well a typed array fits a parameter type (see MatchScore). + private static int TypedArrayScore(JsTypedArray t, Type p) + { + var element = t.ElementType; + if (p.IsArray) + { + var pe = p.GetElementType()!; + if (pe == element) return 10; + if (pe == typeof(byte)) return 4; + return JsTypedArray.KindOf(pe) >= 0 ? 5 : pe == typeof(object) ? 2 : -1; + } + if (p.IsGenericType) + { + var definition = p.GetGenericTypeDefinition(); + var arg = p.GetGenericArguments()[0]; + if (definition == typeof(Memory<>) || definition == typeof(ReadOnlyMemory<>) || definition == typeof(ArraySegment<>)) + return arg == element ? 9 : arg == typeof(byte) ? 4 : -1; + } + if (element == typeof(byte) && p.IsAssignableFrom(typeof(MemoryStream))) return p == typeof(object) ? 1 : 5; + if (p.IsAssignableFrom(element.MakeArrayType())) return p == typeof(object) ? 1 : 7; + return -1; + } + + private static object? CoerceTypedArray(JsTypedArray t, Type targetType) + { + var p = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (p.IsArray) + { + var pe = p.GetElementType()!; + return pe == typeof(byte) ? t.Bytes : pe == typeof(object) ? ToObjects(t.ToNativeArray()) : t.ToArray(pe); + } + if (p.IsGenericType) + { + var definition = p.GetGenericTypeDefinition(); + if (definition == typeof(Memory<>) || definition == typeof(ReadOnlyMemory<>) || definition == typeof(ArraySegment<>)) + { + var arg = p.GetGenericArguments()[0]; + return Activator.CreateInstance(p, arg == typeof(byte) ? t.Bytes : t.ToArray(arg)); + } + } + if (t.ElementType == typeof(byte) && p.IsAssignableFrom(typeof(MemoryStream)) && p != typeof(object)) + return new MemoryStream(t.Bytes, writable: true); + return t.ToNativeArray(); + } + + private static object?[] ToObjects(Array array) + { + var objects = new object?[array.Length]; + for (int i = 0; i < objects.Length; i++) objects[i] = array.GetValue(i); + return objects; + } + + // Values JS gets as a typed array: byte[], sbyte[], Memory, ArraySegment. + internal static bool TryGetBytes(object value, out ReadOnlySpan bytes, out byte kind) + { + switch (value) + { + case byte[] b: bytes = b; kind = 0; return true; + case sbyte[] sb: bytes = MemoryMarshal.AsBytes(sb.AsSpan()); kind = 1; return true; + case Memory m: bytes = m.Span; kind = 0; return true; + case ReadOnlyMemory rm: bytes = rm.Span; kind = 0; return true; + case ArraySegment seg: bytes = seg.AsSpan(); kind = 0; return true; + } + bytes = default; + kind = 0; + return false; + } + + internal static void WriteTypedArray(ref BinWriter w, ReadOnlySpan bytes, byte kind) + { + w.WriteByte(0x12); + w.WriteByte(kind); + w.WriteU32((uint)bytes.Length); + w.WriteBytes(bytes); + } + + // A number when the value fits a double exactly, else a BigInt (tags 0x10 / 0x11). + internal static void WriteInt64(ref BinWriter w, long v) + { + if (v >= -MaxSafeInteger && v <= MaxSafeInteger) { w.WriteByte(0x04); w.WriteF64(v); } + else { w.WriteByte(0x10); w.WriteI64(v); } + } + + internal static void WriteUInt64(ref BinWriter w, ulong v) + { + if (v <= (ulong)MaxSafeInteger) { w.WriteByte(0x04); w.WriteF64(v); } + else { w.WriteByte(0x11); w.WriteI64(unchecked((long)v)); } + } +} diff --git a/dotnet-bridge/Bridge.BinaryDispatch.cs b/dotnet-bridge/Bridge.BinaryDispatch.cs index 4596468..d2a440b 100644 --- a/dotnet-bridge/Bridge.BinaryDispatch.cs +++ b/dotnet-bridge/Bridge.BinaryDispatch.cs @@ -31,7 +31,7 @@ public static unsafe int InvokeBinary( } catch (Exception ex) { - WriteBinError(Unwrap(ex).Message, responsePtr, responseLenPtr); + WriteBinError(Unwrap(ex), responsePtr, responseLenPtr); } return 0; } @@ -73,23 +73,12 @@ internal static DispatchResult DispatchBin(ref BinReader r) { var op = r.ReadByte(); - if (op == 0x04) // release - { - var handleToRemove = r.ReadI32(); - s_handles.TryRemove(handleToRemove, out _); - if (s_nativePtrs.TryRemove(handleToRemove, out var nativePtr)) - { - RemoveNativePtr(handleToRemove, nativePtr); - try - { - Marshal.Release(nativePtr); - } - catch - { - } - } - return DispatchResult.Void; - } + if (op == 0x04) // release: handle, then the number of exports given back (all when absent) + { + var handleToRemove = r.ReadI32(); + ReleaseHandle(handleToRemove, r.HasMore ? r.ReadI32() : -1); + return DispatchResult.Void; + } if (op == 0x05) // members by handle { @@ -158,7 +147,8 @@ internal static DispatchResult DispatchBin(ref BinReader r) var callbackId = r.ReadI32(); var ctorArgs = r.HasMore ? r.ReadArgs() : []; - return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames, ctorArgs); + var className = r.HasMore ? r.ReadName16() : ""; + return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames, ctorArgs, className); } if (op == 0x0B) // get CLR-only property by raw IInspectable ptr (CLR reflection fallback) @@ -180,6 +170,14 @@ internal static DispatchResult DispatchBin(ref BinReader r) // 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 == 0 || SelectOverload(ctors.Parameters, args) < 0) + { + // A struct's implicit parameterless constructor, or one with optional parameters. + if (args.Length == 0 && typeS.IsValueType) return Box(DefaultOf(typeS)); + var ctorCall = new CallName(".ctor", false, null, null); + if (TryBindCall(SlowCandidates(typeS, ".ctor", BindingFlags.Public | BindingFlags.Instance), null, args, ctorCall, out var bound, out var boundArgs, out var info)) + return InvokeBound(bound!, null, boundArgs!, info, ctorCall); + } if (ctors.Ctors.Length > 1) { var best = SelectOverload(ctors.Parameters, args); @@ -193,7 +191,7 @@ internal static DispatchResult DispatchBin(ref BinReader r) return Box(ctors.Ctors[best].Invoke(built)); } var entry = GetCachedCtor(typeS, args.Length); - if (entry.Ctor is null) + if (entry.Ctor is null || entry.Parameters.Length != args.Length) throw new MissingMethodException( $"No public ctor on {typeS.FullName} for {args.Length} args"); // ConstructorInfo.Invoke requires exact arg count — build a precise array. @@ -208,11 +206,17 @@ internal static DispatchResult DispatchBin(ref BinReader r) } private static DispatchResult DispatchCallBin( - object? target, Type type, string method, object?[] args, bool isStatic) + object? target, Type type, string rawMethod, object?[] args, bool isStatic) { var flags = (isStatic ? BindingFlags.Static : BindingFlags.Instance) | BindingFlags.Public; - - + var call = CallName.Parse(rawMethod); + var method = call.Name; + // `&`, `<...>` and `#...` calls (see CallName) only go through the slow path. + if (!ReferenceEquals(method, rawMethod)) + { + if (TrySlowCall(target, type, call, args, flags, out var special)) return special; + throw new MissingMethodException($"No method '{method}' on {type.FullName} takes these {args.Length} argument(s)"); + } if (method.Length > 4 && args.Length == 0 && method[0] == 'g' && method[1] == 'e' && method[2] == 't' && method[3] == '_') @@ -237,10 +241,13 @@ private static DispatchResult DispatchCallBin( // Describe(Shape)): the one whose parameter types best fit the arguments. var overloads = GetOverloads(type, method, args.Length, flags); 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; + // No overload of this arity fits: try params methods (String.Join(",", "a", "b")), then + // optional parameters, generic methods and extension methods. + if (best < 0) + { + if (TryParamsCall(target, type, method, args, flags, out var paramsResult)) return paramsResult; + if (TrySlowCall(target, type, call, args, flags, out var slowResult)) return slowResult; + } if (overloads.Methods.Length > 1) { if (best >= 0) @@ -269,59 +276,30 @@ private static DispatchResult DispatchCallBin( } var entry = GetCachedMethod(type, method, args.Length, flags); - if (entry.Invoke is null && target is not null && IsJsBacked(target)) - { - // A JS subclass may call the protected members it inherits. - var protectedFlags = BindingFlags.Instance | BindingFlags.NonPublic; - var inherited = type.GetMethods(protectedFlags) - .FirstOrDefault(m => m.Name == method && (m.IsFamily || m.IsFamilyOrAssembly) && m.GetParameters().Length == args.Length); - if (inherited is not null) - { - var built = BuildArgsBinExact(args, inherited.GetParameters()); - return Box(inherited.Invoke(target, built)); - } - } if (entry.Invoke is null) - { - var candidates = type.GetMethods(flags).Where(m => m.Name == method && !m.IsSpecialName); - foreach (var m in candidates) - { - var parameters = m.GetParameters(); - var built = BuildArgsBin(args, parameters); - try - { - var res = (m.Invoke(target, built)); - 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}.{m.Name} on UI thread"); - try - { - var res = InvokeOnUIThread(() => (m.Invoke(target, built))); - return Box(res); - } - catch { /* retry failed — try next candidate */ } - } - catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) - { - if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{m.Name} on UI thread"); - try - { - var res = InvokeOnUIThread(() => (m.Invoke(target, built))); - return Box(res); - } - catch { /* retry failed — try next candidate */ } - } - finally { if (built.Length > 0) ReturnArgs(built); } - } throw new MissingMethodException( $"Method '{method}' ({args.Length} args) not found on {type.FullName}"); - } return InvokeBuilt(target, type, method, entry, BuildArgsBin(args, entry.Parameters)); } + // Methods the overload cache skips: other arities, generic definitions, protected members (for + // JS subclasses) and extension methods. + private static bool TrySlowCall(object? target, Type type, CallName call, object?[] args, BindingFlags flags, out DispatchResult result) + { + result = default; + IEnumerable candidates = SlowCandidates(type, call.Name, flags); + if (target is not null && IsJsBacked(target)) + candidates = candidates.Concat(SlowCandidates(type, call.Name, BindingFlags.Instance | BindingFlags.NonPublic) + .Where(m => m.IsFamily || m.IsFamilyOrAssembly)); + if (TryBindCall(candidates, target, args, call, out var method, out var built, out var info)) + { + result = InvokeBound(method!, target, built!, info, call); + return true; + } + return target is not null && TryExtensionCall(target, args, call, out result); + } + // 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) @@ -449,12 +427,33 @@ private static int MatchScore(object? arg, Type p) if (obj.GetType() == p) return 10; return p.IsInstanceOfType(obj) ? (p == typeof(object) ? 2 : 8) : -1; case JsFunctionRef: + if (p == typeof(JsFunctionRef)) return 10; 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 long l: + if (p == typeof(long)) return 10; + if (p == typeof(ulong)) return l >= 0 ? 9 : -1; + if (p == typeof(decimal)) return 8; + if (p == typeof(double)) return 7; + if (p == typeof(float)) return 6; + if (p.IsEnum) return 5; + if (IsIntegerType(p)) return FitsInteger(l, p) ? 4 : -1; + return p.IsInstanceOfType(arg) ? 2 : -1; + case ulong u: + if (p == typeof(ulong)) return 10; + if (p == typeof(long)) return u <= long.MaxValue ? 9 : -1; + if (p == typeof(decimal)) return 8; + if (p == typeof(double)) return 7; + if (p == typeof(float)) return 6; + if (p.IsEnum) return 5; + if (IsIntegerType(p)) return FitsInteger(u, p) ? 4 : -1; + return p.IsInstanceOfType(arg) ? 2 : -1; + case JsTypedArray typed: + return TypedArrayScore(typed, p); case JsJsonValue: if (p == typeof(object)) return 1; if (p == typeof(string)) return 2; @@ -629,10 +628,13 @@ TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or } if (value is JsArrayValue array) return CoerceArray(array.Items, targetType); + if (value is JsTypedArray typed) + return CoerceTypedArray(typed, targetType); if (value is string text && IsParsedFromString(Nullable.GetUnderlyingType(targetType) ?? targetType)) return ParseString(text, Nullable.GetUnderlyingType(targetType) ?? targetType); if (value is JsFunctionRef fn) { + if ((Nullable.GetUnderlyingType(targetType) ?? targetType) == typeof(JsFunctionRef)) return fn; var delegateType = typeof(Delegate).IsAssignableFrom(targetType) && targetType != typeof(Delegate) && targetType != typeof(MulticastDelegate) ? targetType @@ -741,14 +743,4 @@ private static DispatchResult ClrGetProperty(IntPtr instancePtr, string propName } private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s; - - private static unsafe void WriteBinError(string msg, byte** outPtr, int* outLen) - { - var msgBytes = System.Text.Encoding.UTF8.GetByteCount(msg); - var buf = new ArrayBufferWriter(5 + msgBytes); - var w = new BinWriter(buf); - w.WriteByte(0xFF); - w.WriteString32(msg); - WriteUnmanaged(buf.WrittenSpan, outPtr, outLen); - } } diff --git a/dotnet-bridge/Bridge.Binder.cs b/dotnet-bridge/Bridge.Binder.cs new file mode 100644 index 0000000..1b772c8 --- /dev/null +++ b/dotnet-bridge/Bridge.Binder.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace NativeScriptBridge; + +// Calls the same-arity overload cache can't make: optional, named and ref/out parameters, generic +// methods and extension methods. Also resolves closed generic type names. +public static partial class Bridge +{ + // A member name as JS sends it, `&Name#a,b`: `&` returns ref/out values, `<...>` holds + // type arguments, `#...` names the trailing arguments. + internal readonly record struct CallName(string Name, bool WantsByRef, string[]? TypeArgs, string[]? Named) + { + public static CallName Parse(string raw) + { + if (raw.Length == 0 || (raw[0] != '&' && raw.IndexOf('#') < 0 && raw[^1] != '>')) + return new CallName(raw, false, null, null); + var byRef = raw[0] == '&'; + var name = byRef ? raw[1..] : raw; + string[]? named = null; + var hash = name.LastIndexOf('#'); + if (hash >= 0) + { + named = name[(hash + 1)..].Split(',', StringSplitOptions.RemoveEmptyEntries); + name = name[..hash]; + } + string[]? typeArgs = null; + var lt = name.IndexOf('<'); + if (lt > 0 && name[^1] == '>') + { + typeArgs = SplitTypeList(name[(lt + 1)..^1]); + name = name[..lt]; + } + return new CallName(name, byRef, typeArgs, named); + } + } + + // Comma-separated type names, ignoring commas inside brackets. + internal static string[] SplitTypeList(string list) + { + var parts = new List(); + int depth = 0, start = 0; + for (int i = 0; i < list.Length; i++) + { + var c = list[i]; + if (c is '[' or '<') depth++; + else if (c is ']' or '>') depth--; + else if (c == ',' && depth == 0) + { + parts.Add(list[start..i].Trim()); + start = i + 1; + } + } + if (start < list.Length) parts.Add(list[start..].Trim()); + return parts.ToArray(); + } + + private static Type? ResolveTypeArg(string name) + { + name = name.Trim(); + if (name.Length > 1 && name[0] == '[' && name[^1] == ']') name = name[1..^1]; + return ResolveType(null, name); + } + + // `List`1[System.String]` (or `List[System.String]`): the closed generic type. + private static Type? ResolveGenericType(string? assemblyName, string typeName) + { + var open = typeName.IndexOf('['); + if (open <= 0 || typeName[^1] != ']') return null; + var argNames = SplitTypeList(typeName[(open + 1)..^1]); + if (argNames.Length == 0) return null; + var args = new Type[argNames.Length]; + for (int i = 0; i < args.Length; i++) + if ((args[i] = ResolveTypeArg(argNames[i])!) is null) return null; + var definitionName = typeName[..open]; + if (definitionName.IndexOf('`') < 0) definitionName += "`" + args.Length; + var definition = ResolveType(assemblyName, definitionName); + if (definition is null || !definition.IsGenericTypeDefinition) return null; + try { return definition.MakeGenericType(args); } + catch (ArgumentException e) { throw new ArgumentException($"{definition.FullName} can't take those type arguments: {e.Message}", e); } + } + + // Picks the best-fitting candidate and builds its argument array; false when none fits. + private static bool TryBindCall(IEnumerable candidates, object? target, object?[] args, CallName call, out MethodBase? method, out object?[]? built, out BindInfo info) + { + method = null; + built = null; + info = default; + int bestScore = int.MinValue; + var namedCount = call.Named?.Length ?? 0; + var positional = args.Length - namedCount; + if (positional < 0) return false; + + foreach (var candidate in candidates) + { + var m = candidate; + if (m is MethodInfo { IsGenericMethodDefinition: true } generic) + { + m = call.TypeArgs is { } names ? CloseGenericMethod(generic, names) : InferGenericMethod(generic, args, positional); + if (m is null) continue; + } + else if (call.TypeArgs is not null) + { + continue; + } + + var ps = m.GetParameters(); + if (positional > ps.Length) continue; + var slots = new int[ps.Length]; + Array.Fill(slots, -1); + for (int i = 0; i < positional; i++) slots[i] = i; + var ok = true; + for (int n = 0; n < namedCount && ok; n++) + { + var index = Array.FindIndex(ps, p => string.Equals(p.Name, call.Named![n], StringComparison.OrdinalIgnoreCase)); + if (index < 0 || slots[index] >= 0) ok = false; + else slots[index] = positional + n; + } + if (!ok) continue; + + int score = 0; + var omittedOuts = new List(); + for (int i = 0; i < ps.Length && score >= 0; i++) + { + var p = ps[i]; + if (slots[i] < 0) + { + if (p.IsOut) { omittedOuts.Add(i); continue; } + if (p.HasDefaultValue || p.IsOptional || IsParamArray(p)) continue; + score = -1; + break; + } + var arg = args[slots[i]]; + var type = p.ParameterType.IsByRef ? p.ParameterType.GetElementType()! : p.ParameterType; + // JS passes null for an out parameter it only reads back. + var s = p.IsOut && arg is null ? 5 : MatchScore(arg, type); + score = s < 0 ? -1 : score + s; + } + if (score < 0) continue; + // Prefer methods that need fewer filled-in parameters. + score = score * 4 - (ps.Length - args.Length); + if (score <= bestScore) continue; + + object?[] values; + try { values = BuildBoundArgs(ps, slots, args); } + catch { continue; } + bestScore = score; + method = m; + built = values; + info = new BindInfo(omittedOuts.ToArray(), slots); + } + return method is not null; + } + + internal readonly record struct BindInfo(int[] OmittedOuts, int[] Slots); + + private static bool IsParamArray(ParameterInfo p) => + p.ParameterType.IsArray && p.IsDefined(typeof(ParamArrayAttribute), false); + + private static object?[] BuildBoundArgs(ParameterInfo[] ps, int[] slots, object?[] args) + { + var values = new object?[ps.Length]; + for (int i = 0; i < ps.Length; i++) + { + var p = ps[i]; + var type = p.ParameterType.IsByRef ? p.ParameterType.GetElementType()! : p.ParameterType; + if (slots[i] >= 0) + { + var v = CoerceBin(args[slots[i]], type); + values[i] = v is null && type.IsValueType && Nullable.GetUnderlyingType(type) is null ? DefaultOf(type) : v; + } + else if (IsParamArray(p)) + values[i] = Array.CreateInstance(type.GetElementType()!, 0); + else if (p.HasDefaultValue && p.DefaultValue is not DBNull && p.DefaultValue is not Missing) + values[i] = p.DefaultValue is null ? DefaultOf(type) : CoerceDefault(p.DefaultValue, type); + else + values[i] = DefaultOf(type); + } + return values; + } + + private static object? DefaultOf(Type t) => t.IsValueType ? RuntimeHelpers.GetUninitializedObject(t) : null; + + // Enum defaults are stored as their underlying number. + private static object? CoerceDefault(object value, Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + if (underlying.IsEnum) return Enum.ToObject(underlying, value); + return value; + } + + // `&` calls return [result, ...arguments after the call]; calls that leave out parameters off + // return [result, ...outs] (outs only for void), like WinRT methods. + private static DispatchResult InvokeBound(MethodBase method, object? target, object?[] built, BindInfo info, CallName call) + { + object? result; + try + { + result = method is ConstructorInfo ctor ? ctor.Invoke(built) : method.Invoke(target, built); + } + catch (TargetInvocationException tie) when (tie.InnerException is not null && IsMarshaledForDifferentThread(tie.InnerException)) + { + result = InvokeOnUIThread(() => method is ConstructorInfo c ? c.Invoke(built) : method.Invoke(target, built)); + } + var isVoid = method is MethodInfo mi && mi.ReturnType == typeof(void); + if (call.WantsByRef) + { + var byPosition = new object?[info.Slots.Length > 0 ? info.Slots.Max() + 1 : 0]; + for (int i = 0; i < info.Slots.Length; i++) + if (info.Slots[i] >= 0) byPosition[info.Slots[i]] = built[i]; + return DispatchResult.Collection(new object?[] { result }.Concat(byPosition).ToArray()); + } + if (info.OmittedOuts.Length > 0) + { + var outs = info.OmittedOuts.Select(i => built[i]); + return DispatchResult.Collection((isVoid ? outs : new[] { result }.Concat(outs)).ToArray()); + } + return Box(result); + } + + private static MethodInfo? CloseGenericMethod(MethodInfo definition, string[] typeArgNames) + { + if (definition.GetGenericArguments().Length != typeArgNames.Length) return null; + var types = new Type[typeArgNames.Length]; + for (int i = 0; i < types.Length; i++) + if ((types[i] = ResolveTypeArg(typeArgNames[i])!) is null) return null; + try { return definition.MakeGenericMethod(types); } + catch (ArgumentException) { return null; } + } + + // Infers type arguments from the arguments as C# does (ToList(list) of a List is + // ToList). Ones the arguments leave open, such as a JS callback's TResult, are object. + private static MethodInfo? InferGenericMethod(MethodInfo definition, object?[] args, int positional) + { + var genericArgs = definition.GetGenericArguments(); + var bound = new Type?[genericArgs.Length]; + var ps = definition.GetParameters(); + for (int i = 0; i < Math.Min(positional, ps.Length); i++) + { + var argType = BridgedType(args[i]); + if (argType is null) continue; + var p = ps[i].ParameterType; + Unify(p.IsByRef ? p.GetElementType()! : p, argType, genericArgs, bound); + } + var types = new Type[bound.Length]; + for (int i = 0; i < types.Length; i++) types[i] = bound[i] ?? typeof(object); + try { return definition.MakeGenericMethod(types); } + catch (ArgumentException) { return null; } + } + + // The .NET type a bridged argument stands for, when there is one to infer from. + private static Type? BridgedType(object? arg) => arg switch + { + null => null, + HandleRef h => s_handles.TryGetValue(h.Id, out var o) ? o?.GetType() : null, + JsArrayValue a => (CommonType(a.Items) ?? typeof(object)).MakeArrayType(), + JsTypedArray t => t.ElementType.MakeArrayType(), + JsFunctionRef or JsJsonValue or WinRtRef => null, + _ => arg.GetType(), + }; + + // The element type every item of a JS array shares (int, double, string, bool, a .NET type). + private static Type? CommonType(object?[] items) + { + Type? common = null; + foreach (var item in items) + { + var t = BridgedType(item); + if (t is null) continue; + if (common is null) common = t; + else if (common != t) + { + if ((common == typeof(int) && t == typeof(double)) || (common == typeof(double) && t == typeof(int))) common = typeof(double); + else return typeof(object); + } + } + return common; + } + + private static void Unify(Type parameter, Type argument, Type[] genericArgs, Type?[] bound) + { + if (parameter.IsGenericParameter) + { + var index = Array.IndexOf(genericArgs, parameter); + if (index >= 0 && bound[index] is null) bound[index] = argument; + return; + } + if (!parameter.ContainsGenericParameters) return; + if (parameter.IsArray) + { + if (argument.IsArray) Unify(parameter.GetElementType()!, argument.GetElementType()!, genericArgs, bound); + else if (CollectionElementType(argument) is { } element) Unify(parameter.GetElementType()!, element, genericArgs, bound); + return; + } + if (!parameter.IsGenericType) return; + var definition = parameter.GetGenericTypeDefinition(); + var match = FindGenericBase(argument, definition); + if (match is null && argument.IsArray && definition == typeof(IEnumerable<>)) + match = typeof(IEnumerable<>).MakeGenericType(argument.GetElementType()!); + if (match is null) return; + var pArgs = parameter.GetGenericArguments(); + var aArgs = match.GetGenericArguments(); + for (int i = 0; i < pArgs.Length; i++) Unify(pArgs[i], aArgs[i], genericArgs, bound); + } + + // `t` itself, a base class or an interface of it that is an instance of `definition`. + private static Type? FindGenericBase(Type t, Type definition) + { + for (var b = t; b is not null; b = b.BaseType) + if (b.IsGenericType && b.GetGenericTypeDefinition() == definition) return b; + if (definition.IsInterface) + foreach (var i in t.GetInterfaces()) + if (i.IsGenericType && i.GetGenericTypeDefinition() == definition) return i; + return null; + } + + // Every method (or constructor) with the call's name: any arity, generic definitions included. + private static readonly ConcurrentDictionary<(Type, string, BindingFlags), MethodBase[]> s_slowCandidates = new(); + + private static MethodBase[] SlowCandidates(Type type, string name, BindingFlags flags) => + s_slowCandidates.GetOrAdd((type, name, flags), static k => + k.Item2 == ".ctor" + ? k.Item1.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + : k.Item1.GetMethods(k.Item3).Where(m => m.Name == k.Item2).ToArray()); + + // Extension methods of every loaded assembly, by name; rebuilt after an assembly loads. + private static Dictionary? s_extensionIndex; + private static int s_extensionIndexGeneration = -1; + + private static MethodInfo[] ExtensionMethods(string name) + { + var generation = Volatile.Read(ref s_assemblyGeneration); + var index = s_extensionIndex; + if (index is null || s_extensionIndexGeneration != generation) + { + var built = new Dictionary>(StringComparer.Ordinal); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic) continue; + Type[] types; + try { types = asm.GetExportedTypes(); } + catch { continue; } + foreach (var t in types) + { + if (!t.IsSealed || !t.IsAbstract || t.IsGenericType || !t.IsDefined(typeof(ExtensionAttribute), false)) continue; + foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (!m.IsDefined(typeof(ExtensionAttribute), false)) continue; + if (!built.TryGetValue(m.Name, out var list)) built[m.Name] = list = new List(); + list.Add(m); + } + } + } + index = built.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal); + s_extensionIndex = index; + s_extensionIndexGeneration = generation; + } + return index.TryGetValue(name, out var methods) ? methods : []; + } + + // `obj.Where(fn)` for an extension method of the object's type: Enumerable.Where(obj, fn). + private static bool TryExtensionCall(object target, object?[] args, CallName call, out DispatchResult result) + { + result = default; + var candidates = ExtensionMethods(call.Name); + if (candidates.Length == 0) return false; + var targetType = target.GetType(); + var applicable = candidates.Where(m => + { + var first = m.GetParameters()[0].ParameterType; + if (!first.ContainsGenericParameters) return first.IsInstanceOfType(target); + if (first.IsGenericParameter) return true; + return first.IsGenericType && FindGenericBase(targetType, first.GetGenericTypeDefinition()) is not null; + }).ToArray(); + if (applicable.Length == 0) return false; + var withTarget = new object?[args.Length + 1]; + withTarget[0] = target; + Array.Copy(args, 0, withTarget, 1, args.Length); + // The target is the extension method's first argument. + if (!TryBindCall(applicable, null, withTarget, call, out var method, out var built, out var info)) return false; + result = InvokeBound(method!, null, built!, info, call with { WantsByRef = false }); + return true; + } +} diff --git a/dotnet-bridge/Bridge.Dispatch.cs b/dotnet-bridge/Bridge.Dispatch.cs index c7d9fdc..042a1ee 100644 --- a/dotnet-bridge/Bridge.Dispatch.cs +++ b/dotnet-bridge/Bridge.Dispatch.cs @@ -20,24 +20,11 @@ internal static DispatchResult Dispatch(InvokeRequest req) { var method = req.Method ?? throw new ArgumentException("Method is required"); - if (method == "__release" && req.Handle.HasValue) - { - var id = req.Handle.Value; - s_handles.TryRemove(id, out _); - if (s_nativePtrs.TryRemove(id, out var nativePtr)) - { - RemoveNativePtr(id, nativePtr); - try - { - Marshal.Release(nativePtr); - } - catch - { - } - } - - return DispatchResult.Void; - } + if (method == "__release" && req.Handle.HasValue) + { + ReleaseHandle(req.Handle.Value, -1); + return DispatchResult.Void; + } object? target = null; Type type; @@ -112,7 +99,11 @@ internal static DispatchResult Dispatch(InvokeRequest req) foreach (var m in candidates) { var parameters = m.GetParameters(); - var built = BuildArgs(args, parameters); + // MethodInfo.Invoke takes exactly as many arguments as the method has parameters. + if (parameters.Length != args.Length) continue; + object?[] built; + try { built = BuildArgsExact(args, parameters); } + catch { continue; } try { var res = (m.Invoke(target, built)); @@ -126,7 +117,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } - catch { /* retry failed — try next candidate */ } + catch { /* retry failed: try the next candidate */ } } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { @@ -136,9 +127,8 @@ internal static DispatchResult Dispatch(InvokeRequest req) var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } - catch { /* retry failed — try next candidate */ } + catch { /* retry failed: try the next candidate */ } } - finally { if (built.Length > 0) ReturnArgs(built); } } throw new MissingMethodException( $"Method '{method}' ({args.Length} args) not found on {type.FullName}"); @@ -181,7 +171,41 @@ private static DispatchEntry BuildDispatchEntry(Type type, string name, int argC internal static DispatchEntry BuildDispatchEntry(Type type, MethodInfo mi) { var parameters = mi.GetParameters(); + if (parameters.Any(p => p.ParameterType.IsByRef || p.ParameterType.IsByRefLike) || mi.ReturnType.IsByRefLike) + return new DispatchEntry(CompileInvoker(type, mi, parameters), parameters); + return new DispatchEntry(new TieredInvoker(type, mi, parameters).Invoke, parameters); + } + + // MethodInvoker (cheap to create) until the method is called often enough to pay for a compiled + // delegate (~80 us to build, a few ns per call). + private sealed class TieredInvoker(Type type, MethodInfo mi, ParameterInfo[] parameters) + { + private const int CompileAfter = 32; + private readonly MethodInvoker _invoker = MethodInvoker.Create(mi); + private Func? _compiled; + private int _calls; + + public object? Invoke(object? target, object?[] args) + { + var compiled = _compiled; + if (compiled is not null) return compiled(target, args); + if (Interlocked.Increment(ref _calls) == CompileAfter) + _compiled = CompileInvoker(type, mi, parameters); + try + { + return _invoker.Invoke(target, new Span(args, 0, parameters.Length)); + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + // Throw what the method threw, as the compiled delegate does. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; + } + } + } + private static Func CompileInvoker(Type type, MethodInfo mi, ParameterInfo[] parameters) + { try { var targetParam = Expression.Parameter(typeof(object), "t"); @@ -201,18 +225,14 @@ internal static DispatchEntry BuildDispatchEntry(Type type, MethodInfo mi) else if (body.Type != typeof(object)) body = Expression.Convert(body, typeof(object)); - var fn = Expression.Lambda>( + return Expression.Lambda>( body, targetParam, argsParam).Compile(); - - return new DispatchEntry(fn, parameters); } catch { // mi.Invoke validates args.Length == paramCount; slice the rented buffer if needed. int pc = parameters.Length; - return new DispatchEntry( - (t, a) => mi.Invoke(t, a.Length == pc ? a : a[..pc]), - parameters); + return (t, a) => mi.Invoke(t, a.Length == pc ? a : a[..pc]); } } @@ -227,10 +247,10 @@ private static CtorEntry GetCachedCtor(Type type, int argCount) // 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) + private static DataMember? 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.PrefixLen..], k.Flags)); + static k => DataMember.Find(k.Type, 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. @@ -278,6 +298,8 @@ private static DispatchResult Box(object? value) if (value is UIntPtr up) return DispatchResult.Primitive(unchecked((long)up.ToUInt64()), typeof(long)); + if (TryGetBytes(value, out _, out _)) return DispatchResult.Bytes(value); + var t = value.GetType(); var info = GetBoxInfo(t); @@ -302,9 +324,8 @@ private static DispatchResult Box(object? value) - var id = Interlocked.Increment(ref s_nextHandle); - s_handles[id] = value; - if (jsHolder is not null) jsHolder.HandleId = id; + var id = HandleFor(value); + if (jsHolder is not null) jsHolder.HandleId = id; // Try to obtain a canonical IInspectable/IUnknown pointer for COM/WinRT objects // so the runtime can call native vtable methods directly. @@ -317,7 +338,7 @@ private static DispatchResult Box(object? value) // (GetNativePtrForHandle): making a COM wrapper costs more than the rest of the call. try { - if (info.WinRTNativeObject is not null || jsHolder is not null) + if ((info.WinRTNativeObject is not null || jsHolder is not null) && !s_nativePtrs.ContainsKey(id)) { var p = ObtainNativePtr(value); if (p != IntPtr.Zero) @@ -401,12 +422,30 @@ public static string TypeKind(string typeName) } // 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]). + // (for...of, map, ...), 2 = has a count (length), 4 = indexed by position (list[0]), + // 8 = keyed by string (dict.key / dict['key']). 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); + return 1 | (info.Count is not null ? 2 : 0) | (info.Indexer is not null ? 4 : 0) | (info.KeyIndexer is not null ? 8 : 0); + } + + // [found, value] for `key` of a string-keyed dictionary. + public static object?[] DictionaryTryGet(object? value, string key) + { + var info = value is null ? null : GetCollectionInfo(value.GetType()); + if (info?.KeyIndexer is null || info.ContainsKey is null) return [false, null]; + if (info.ContainsKey.Invoke(value, [key]) is not true) return [false, null]; + return [true, info.KeyIndexer.GetValue(value, [key])]; + } + + public static void DictionarySet(object? value, string key, object? item) + { + var info = value is null ? null : GetCollectionInfo(value.GetType()); + if (info?.KeyIndexer is not { CanWrite: true } indexer) + throw new NotSupportedException($"{value?.GetType().FullName} has no settable string keys"); + indexer.SetValue(value, CoerceBin(item, indexer.PropertyType), [key]); } // A collection's items, as an array the runtime copies into a JS array. @@ -449,6 +488,9 @@ internal sealed class CollectionInfo { public PropertyInfo? Count; public PropertyInfo? Indexer; + // The `this[string]` of an IDictionary / IReadOnlyDictionary. + public PropertyInfo? KeyIndexer; + public MethodInfo? ContainsKey; } private static readonly System.Collections.Concurrent.ConcurrentDictionary s_collectionInfo = new(); @@ -464,6 +506,13 @@ private static CollectionInfo GetCollectionInfo(Type t) => s_collectionInfo.GetO 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"); + if ((info.KeyIndexer is null || !info.KeyIndexer.CanWrite) + && (definition == typeof(IDictionary<,>) || definition == typeof(IReadOnlyDictionary<,>)) + && i.GetGenericArguments()[0] == typeof(string)) + { + info.KeyIndexer = i.GetProperty("Item"); + info.ContainsKey = i.GetMethod("ContainsKey"); + } } // A writable IList indexer beats a read-only IReadOnlyList one. if (info.Indexer is { CanWrite: false }) @@ -481,31 +530,45 @@ public static bool IsInstanceOf(object? value, string typeName) return t is not null && t.IsInstanceOfType(value); } - internal static DispatchResult BuildMembersResult(Type t) + internal static Type? ResolveType(string? assemblyName, string? typeName) { - const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance; - const BindingFlags stat = BindingFlags.Public | BindingFlags.Static; + if (string.IsNullOrEmpty(typeName)) return null; + var key = (assemblyName ?? "", typeName); + if (s_typeCache.TryGetValue(key, out var cached)) return cached; + var generation = Volatile.Read(ref s_assemblyGeneration); + if (s_missingTypes.TryGetValue(key, out var missedAt) && missedAt == generation) return null; + var t = ResolveTypeCore(key.Item1.Length == 0 ? null : key.Item1, typeName) + ?? ResolveNestedType(key.Item1.Length == 0 ? null : key.Item1, typeName) + ?? ResolveGenericType(key.Item1.Length == 0 ? null : key.Item1, typeName); + if (t is not null) s_typeCache[key] = t; + else s_missingTypes[key] = generation; + return t; + } - var instProps = Array.FindAll(t.GetProperties(inst), p => p.GetIndexParameters().Length == 0); - var statProps = Array.FindAll(t.GetProperties(stat), p => p.GetIndexParameters().Length == 0); + // Type lookups that failed, with the assembly-load generation they failed in. + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(string, string), int> s_missingTypes = new(); + private static int s_assemblyGeneration; - return DispatchResult.Members( - methods: t.GetMethods(inst).Where(m => !m.IsSpecialName).Select(m => m.Name).Distinct().ToArray(), - props: instProps.Where(p => p.GetGetMethod() != null).Select(p => p.Name).Distinct().ToArray(), - staticMethods: t.GetMethods(stat).Where(m => !m.IsSpecialName).Select(m => m.Name).Distinct().ToArray(), - staticProps: statProps.Where(p => p.GetGetMethod() != null).Select(p => p.Name).Distinct().ToArray(), - readonlyProps: instProps.Where(p => p.GetGetMethod() != null && p.GetSetMethod() == null).Select(p => p.Name).Distinct().ToArray(), - readonlyStaticProps: statProps.Where(p => p.GetGetMethod() != null && p.GetSetMethod() == null).Select(p => p.Name).Distinct().ToArray(), - writeonlyProps: instProps.Where(p => p.GetGetMethod() == null && p.GetSetMethod() != null).Select(p => p.Name).Distinct().ToArray(), - writeonlyStaticProps: statProps.Where(p => p.GetGetMethod() == null && p.GetSetMethod() != null).Select(p => p.Name).Distinct().ToArray() - ); - } + private static void OnAssemblyLoad(object? sender, AssemblyLoadEventArgs args) => + Interlocked.Increment(ref s_assemblyGeneration); - internal static Type? ResolveType(string? assemblyName, string? typeName) + // `Outer.Inner` as JS spells a nested type: CLR names it `Outer+Inner`. + private static Type? ResolveNestedType(string? assemblyName, string typeName) { - if (string.IsNullOrEmpty(typeName)) return null; - return s_typeCache.GetOrAdd((assemblyName ?? "", typeName), - static k => ResolveTypeCore(k.Assembly.Length == 0 ? null : k.Assembly, k.Type)); + if (typeName.IndexOf('[') >= 0) return null; + for (var dot = typeName.LastIndexOf('.'); dot > 0; dot = typeName.LastIndexOf('.', dot - 1)) + { + var outer = ResolveType(assemblyName, typeName[..dot]); + if (outer is null) continue; + Type? t = outer; + foreach (var part in typeName[(dot + 1)..].Split('.')) + { + t = t.GetNestedType(part, BindingFlags.Public | BindingFlags.NonPublic); + if (t is null) break; + } + return t; + } + return null; } private static Type? ResolveTypeInAssembly(Assembly asm, string typeName, int lastDot, string shortName) diff --git a/dotnet-bridge/Bridge.Errors.cs b/dotnet-bridge/Bridge.Errors.cs new file mode 100644 index 0000000..add6b28 --- /dev/null +++ b/dotnet-bridge/Bridge.Errors.cs @@ -0,0 +1,57 @@ +using System; +using System.Buffers; +using System.Threading.Tasks; + +namespace NativeScriptBridge; + +/// Thrown into managed code when a JavaScript callback threw; StackTrace leads with the JS stack. +public sealed class JsException(string message, string? jsStack = null) : Exception(message) +{ + public string? JsStack { get; } = jsStack; + + public override string? StackTrace => + string.IsNullOrEmpty(JsStack) ? base.StackTrace : JsStack + Environment.NewLine + base.StackTrace; +} + +public static partial class Bridge +{ + // 0xFF, message, exception type, .NET stack trace, exception handle (`error.nativeException` in + // JS). Older readers stop after the message. + private static unsafe void WriteBinError(Exception ex, byte** outPtr, int* outLen) + { + var buf = new ArrayBufferWriter(256); + var w = new BinWriter(buf); + w.WriteByte(0xFF); + w.WriteString32(ex.Message); + w.WriteString16(ex.GetType().FullName ?? ex.GetType().Name); + w.WriteString32(ex.StackTrace ?? ""); + int handle = -1; + try { handle = HandleFor(ex); } catch { } + w.WriteI32(handle); + WriteUnmanaged(buf.WrittenSpan, outPtr, outLen); + } + + // The handler JS registered for exceptions nothing in .NET caught. + private static Action? s_unhandledHandler; + + /// Forwards unhandled .NET exceptions ("unhandled") and faulted tasks nobody awaited + /// ("unobservedTask") to `handler`, with the exception. + public static void RegisterUnhandledExceptionHandler(Action handler) + { + s_unhandledHandler = handler; + AppDomain.CurrentDomain.UnhandledException -= OnUnhandled; + AppDomain.CurrentDomain.UnhandledException += OnUnhandled; + TaskScheduler.UnobservedTaskException -= OnUnobserved; + TaskScheduler.UnobservedTaskException += OnUnobserved; + } + + private static void OnUnhandled(object? sender, UnhandledExceptionEventArgs e) + { + try { s_unhandledHandler?.Invoke("unhandled", e.ExceptionObject); } catch { } + } + + private static void OnUnobserved(object? sender, UnobservedTaskExceptionEventArgs e) + { + try { s_unhandledHandler?.Invoke("unobservedTask", (object?)e.Exception.InnerException ?? e.Exception); } catch { } + } +} diff --git a/dotnet-bridge/Bridge.Identity.cs b/dotnet-bridge/Bridge.Identity.cs new file mode 100644 index 0000000..fce85d2 --- /dev/null +++ b/dotnet-bridge/Bridge.Identity.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; + +namespace NativeScriptBridge; + +public static partial class Bridge +{ + // One handle per live object, so JS gets one proxy per object. Each handle sent out counts as + // an export; a collected proxy gives back the exports it saw, and the handle goes when none are + // left, so a release can't race a new export. + private static readonly Dictionary s_handleOf = new(ReferenceEqualityComparer.Instance); + private static readonly Dictionary s_exports = new(); + private static readonly object s_handleLock = new(); + + // The handle for `value`, counting an export. Value types get a new one every time. + internal static int HandleFor(object value) + { + var shared = !value.GetType().IsValueType; + lock (s_handleLock) + { + if (shared && s_handleOf.TryGetValue(value, out var existing)) + { + s_exports[existing] = s_exports.TryGetValue(existing, out var n) ? n + 1 : 1; + return existing; + } + var id = Interlocked.Increment(ref s_nextHandle); + s_handles[id] = value; + if (shared) + { + s_handleOf[value] = id; + s_exports[id] = 1; + } + return id; + } + } + + // Gives back `count` exports (all when negative); the handle goes when none remain. + internal static void ReleaseHandle(int id, int count) + { + lock (s_handleLock) + { + if (count >= 0 && s_exports.TryGetValue(id, out var n)) + { + if (n > count) + { + s_exports[id] = n - count; + return; + } + } + s_exports.Remove(id); + if (!s_handles.TryRemove(id, out var obj)) return; + if (obj is not null && s_handleOf.TryGetValue(obj, out var owner) && owner == id) + s_handleOf.Remove(obj); + } + if (s_nativePtrs.TryRemove(id, out var nativePtr)) + { + RemoveNativePtr(id, nativePtr); + try { Marshal.Release(nativePtr); } catch { } + } + } + + internal static void ClearHandles() + { + lock (s_handleLock) + { + s_handleOf.Clear(); + s_exports.Clear(); + s_handles.Clear(); + } + } +} diff --git a/dotnet-bridge/Bridge.JsDelegate.cs b/dotnet-bridge/Bridge.JsDelegate.cs index 9b895ae..838b296 100644 --- a/dotnet-bridge/Bridge.JsDelegate.cs +++ b/dotnet-bridge/Bridge.JsDelegate.cs @@ -131,35 +131,73 @@ private static DynamicMethod BuildJsDelegateInvoker(Type delegateType) internal static object? CallJsCallback(int id, object?[] args) => CallJsCallback(id, args, expectsResult: true); - // Calls the JS function registered under `id`. When it throws, a caller that expects a result - // gets a JsException (a delegate with a return value, an overridden member); a void delegate or - // event just returns, the error having been reported as an uncaught JS error. - internal static unsafe object? CallJsCallback(int id, object?[] args, bool expectsResult) + // Calls the JS function `id`. A JS throw becomes a JsException with the JS stack; a callback + // that couldn't run (its worker is gone) only throws when a result is expected. + internal static object? CallJsCallback(int id, object?[] args, bool expectsResult) => + CallJsCallback(id, -1, null, args, expectsResult); + + // A request buffer per thread; a nested call (JS calling back into .NET) gets its own. + [ThreadStatic] private static ArrayBufferWriter? t_callbackRequest; + [ThreadStatic] private static bool t_callbackRequestInUse; + + // `selfHandle` and `member`, when given, are written first: an override's instance and name. + internal static unsafe object? CallJsCallback(int id, int selfHandle, string? member, object?[] args, bool expectsResult) { if (s_jsInvoker == null) return null; - var buf = new ArrayBufferWriter(64); - var w = new BinWriter(buf); - w.WriteByte((byte)Math.Min(args.Length, 255)); - foreach (var arg in args) - WriteCallbackArg(ref w, arg); - - var bytes = buf.WrittenSpan; + var reuse = !t_callbackRequestInUse; + var buf = reuse ? (t_callbackRequest ??= new ArrayBufferWriter(128)) : new ArrayBufferWriter(64); + if (reuse) + { + buf.ResetWrittenCount(); + t_callbackRequestInUse = true; + } byte* respPtr = null; int respLen = 0; - fixed (byte* p = bytes) - s_jsInvoker(id, p, bytes.Length, &respPtr, &respLen); + try + { + var w = new BinWriter(buf); + var head = member is null ? 0 : 2; + w.WriteByte((byte)Math.Min(args.Length + head, 255)); + if (member is not null) + { + WriteHandleArg(ref w, selfHandle); + w.WriteByte(0x05); + w.WriteString32(member); + } + foreach (var arg in args) + WriteCallbackArg(ref w, arg); + + var bytes = buf.WrittenSpan; + fixed (byte* p = bytes) + s_jsInvoker(id, p, bytes.Length, &respPtr, &respLen); + } + finally + { + if (reuse) t_callbackRequestInUse = false; + } object? result = null; // Response is only set for non-void delegates; parse and free when present. if (respPtr != null && respLen > 0) { - string? jsError = null; + string? jsError = null, jsStack = null; + var couldNotRun = false; try { var span = new ReadOnlySpan(respPtr, respLen); if (span[0] == 0x0F) - jsError = span.Length > 5 ? new BinReader(span[1..]).ReadString32() : "the JavaScript callback could not run"; + { + // 0x0F, message, optional JS stack. An empty message means it couldn't run. + var r = new BinReader(span[1..]); + jsError = span.Length >= 5 ? r.ReadString32() : ""; + if (r.HasMore) jsStack = r.ReadString32(); + if (jsError.Length == 0) + { + couldNotRun = true; + jsError = "the JavaScript callback could not run"; + } + } else result = ParseCallbackResponse(span); } @@ -168,7 +206,7 @@ private static DynamicMethod BuildJsDelegateInvoker(Type delegateType) { Marshal.FreeHGlobal((IntPtr)respPtr); } - if (jsError != null && expectsResult) throw new JsException(jsError); + if (jsError != null && (expectsResult || !couldNotRun)) throw new JsException(jsError, jsStack); } return result; @@ -176,38 +214,29 @@ private static DynamicMethod BuildJsDelegateInvoker(Type delegateType) // Serialises a single delegate argument in response-binary tag format so // the Rust side can reuse its existing bin_read_value parser. + // An existing handle, forwarded as is (tag 0x06, its object's type and native pointer). + private static void WriteHandleArg(ref BinWriter w, int id) + { + w.WriteByte(0x06); + w.WriteI32(id); + var obj = s_handles.TryGetValue(id, out var o) ? o : null; + w.WriteString16(obj is null ? "" : GetBoxInfo(obj.GetType()).TypeName); + if (s_nativePtrs.TryGetValue(id, out var nativePtr)) + { + w.WriteByte(1); + w.WriteI64(nativePtr.ToInt64()); + } + else + { + w.WriteByte(0); + } + } + private static void WriteCallbackArg(ref BinWriter w, object? arg) { - // Allow existing handle references to be forwarded without re-boxing. if (arg is HandleRef hr) { - w.WriteByte(0x06); - w.WriteI32(hr.Id); - try - { - if (s_handles.TryGetValue(hr.Id, out var obj) && obj != null) - { - var objTypeName = obj.GetType().FullName ?? obj.GetType().Name; - w.WriteString16(objTypeName); - } - else - { - w.WriteString16(""); - } - } - catch - { - w.WriteString16(""); - } - if (Bridge.s_nativePtrs.TryGetValue(hr.Id, out var nativePtr)) - { - w.WriteByte(1); - w.WriteI64(nativePtr.ToInt64()); - } - else - { - w.WriteByte(0); - } + WriteHandleArg(ref w, hr.Id); return; } @@ -215,7 +244,9 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) if (arg is bool b) { w.WriteByte(b ? (byte)0x02 : (byte)0x01); return; } if (arg is int i) { w.WriteByte(0x03); w.WriteI32(i); return; } if (arg is uint u) { w.WriteByte(0x03); w.WriteI32((int)u); return; } - if (arg is long l) { w.WriteByte(0x04); w.WriteF64((double)l); return; } + if (arg is long l) { WriteInt64(ref w, l); return; } + if (arg is ulong ul) { WriteUInt64(ref w, ul); return; } + if (TryGetBytes(arg, out var bytes, out var kind)) { WriteTypedArray(ref w, bytes, kind); return; } if (arg is float f) { w.WriteByte(0x04); w.WriteF64((double)f); return; } if (arg is double d){ w.WriteByte(0x04); w.WriteF64(d); return; } if (arg is string s){ w.WriteByte(0x05); w.WriteString32(s); return; } @@ -233,8 +264,7 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) // Object: box in the handle map and send as a handle reference. // The JS side receives {__handle, __type} which can be turned into a // proxy via NSWinRT.dotnet.fromHandle(...). - var handleId = Interlocked.Increment(ref s_nextHandle); - s_handles[handleId] = arg; + var handleId = HandleFor(arg); var typeName = arg.GetType().FullName ?? arg.GetType().Name; w.WriteByte(0x06); w.WriteI32(handleId); @@ -263,6 +293,9 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) case 0x06: return (object)new HandleRef(r.ReadI32()); case 0x0A: return (object)new WinRtRef(r.ReadI64()); case 0x0D: return new JsJsonValue(r.ReadString32()); + case 0x10: return (object)r.ReadI64(); + case 0x11: return (object)unchecked((ulong)r.ReadI64()); + case 0x12: return r.ReadTypedArray(); case 0x07: // array: u32 count + N tagged items { var count = (int)r.ReadU32(); @@ -280,6 +313,8 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) case 0x05: arr[i] = r.ReadString32(); break; case 0x06: arr[i] = new HandleRef(r.ReadI32()); break; case 0x0A: arr[i] = new WinRtRef(r.ReadI64()); break; + case 0x10: arr[i] = r.ReadI64(); break; + case 0x11: arr[i] = unchecked((ulong)r.ReadI64()); break; default: arr[i] = null; break; } } @@ -297,5 +332,3 @@ internal static unsafe void CallJsCallbackVoid(int id, object?[] args) } -/// Thrown into managed code when a JavaScript callback that was expected to return a value threw. -public sealed class JsException(string message) : Exception(message); diff --git a/dotnet-bridge/Bridge.Members.cs b/dotnet-bridge/Bridge.Members.cs new file mode 100644 index 0000000..8d94adc --- /dev/null +++ b/dotnet-bridge/Bridge.Members.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace NativeScriptBridge; + +// A property or field: JS reads and writes both as properties. +internal sealed class DataMember +{ + private readonly PropertyInfo? _property; + private readonly FieldInfo? _field; + + public DataMember(PropertyInfo property) => _property = property; + public DataMember(FieldInfo field) => _field = field; + + public Type PropertyType => _property?.PropertyType ?? _field!.FieldType; + + public string Name => _property?.Name ?? _field!.Name; + + public object? GetValue(object? target) => _property is not null ? _property.GetValue(target) : _field!.GetValue(target); + + public void SetValue(object? target, object? value) + { + if (_property is not null) + { + _property.SetValue(target, value); + return; + } + if (_field!.IsInitOnly || _field.IsLiteral) + throw new FieldAccessException($"Cannot assign to read-only field '{_field.Name}' of .NET type '{_field.DeclaringType?.FullName}'"); + _field.SetValue(target, value); + } + + // The property or field `name` of `type`, or null. + public static DataMember? Find(Type type, string name, BindingFlags flags) + { + PropertyInfo? property = null; + try { property = type.GetProperty(name, flags); } + catch (AmbiguousMatchException) + { + // A property redeclared with `new` in a derived class: the most derived one. + property = type.GetProperties(flags).Where(p => p.Name == name && p.GetIndexParameters().Length == 0) + .OrderByDescending(p => Depth(p.DeclaringType)).FirstOrDefault(); + } + if (property is not null && property.GetIndexParameters().Length == 0) return new DataMember(property); + var field = type.GetField(name, flags); + return field is not null && !field.IsSpecialName ? new DataMember(field) : null; + } + + private static int Depth(Type? t) + { + var depth = 0; + for (; t is not null; t = t.BaseType) depth++; + return depth; + } +} + +public static partial class Bridge +{ + internal static DispatchResult BuildMembersResult(Type t) + { + const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance; + const BindingFlags stat = BindingFlags.Public | BindingFlags.Static; + const BindingFlags family = BindingFlags.NonPublic | BindingFlags.Instance; + + var instProps = Array.FindAll(t.GetProperties(inst), p => p.GetIndexParameters().Length == 0); + var statProps = Array.FindAll(t.GetProperties(stat), p => p.GetIndexParameters().Length == 0); + var instFields = Array.FindAll(t.GetFields(inst), f => !f.IsSpecialName); + var statFields = Array.FindAll(t.GetFields(stat), f => !f.IsSpecialName); + + static string[] Names(IEnumerable members) => members.Select(m => m.Name).Distinct().ToArray(); + static bool ReadOnlyField(FieldInfo f) => f.IsInitOnly || f.IsLiteral; + + var instEvents = t.GetEvents(inst); + var statEvents = t.GetEvents(stat); + var protectedMethods = t.GetMethods(family).Where(m => (m.IsFamily || m.IsFamilyOrAssembly) && !m.IsSpecialName); + var protectedProps = t.GetProperties(family).Where(p => p.GetIndexParameters().Length == 0 + && (p.GetMethod ?? p.SetMethod) is { } a && (a.IsFamily || a.IsFamilyOrAssembly)); + + return DispatchResult.Members( + methods: Names(t.GetMethods(inst).Where(m => !m.IsSpecialName)), + props: Names(instProps.Where(p => p.GetGetMethod() != null).Concat(instFields)), + staticMethods: Names(t.GetMethods(stat).Where(m => !m.IsSpecialName)), + staticProps: Names(statProps.Where(p => p.GetGetMethod() != null).Concat(statFields)), + readonlyProps: Names(instProps.Where(p => p.GetGetMethod() != null && p.GetSetMethod() == null).Concat(instFields.Where(ReadOnlyField))), + readonlyStaticProps: Names(statProps.Where(p => p.GetGetMethod() != null && p.GetSetMethod() == null).Concat(statFields.Where(ReadOnlyField))), + writeonlyProps: Names(instProps.Where(p => p.GetGetMethod() == null && p.GetSetMethod() != null)), + writeonlyStaticProps: Names(statProps.Where(p => p.GetGetMethod() == null && p.GetSetMethod() != null)), + extras: + [ + ("events", Names(instEvents)), + ("staticEvents", Names(statEvents)), + ("protectedMethods", Names(protectedMethods)), + ("protectedProperties", Names(protectedProps)), + ("nestedTypes", Names(t.GetNestedTypes(BindingFlags.Public))), + ]); + } + + // Delegates added for JS functions, by function token, so remove_X(fn) removes the right one. + private sealed class EventHandlers + { + public readonly Dictionary<(string Event, int Token), List> Added = new(); + // The handler of each event assigned with `obj.Event = fn`. + public readonly Dictionary Assigned = new(); + } + + private static readonly ConditionalWeakTable s_instanceEvents = new(); + private static readonly Dictionary s_staticEvents = new(); + + private static (EventInfo Event, EventHandlers Handlers) EventOf(object? target, string typeName, string eventName) + { + var type = target?.GetType() ?? ResolveType(null, typeName) + ?? throw new TypeLoadException($"Type not found: {typeName}"); + var flags = BindingFlags.Public | (target is null ? BindingFlags.Static : BindingFlags.Instance); + var ev = type.GetEvent(eventName, flags) + ?? throw new MissingMemberException($"Event '{eventName}' not found on {type.FullName}"); + EventHandlers handlers; + if (target is null) + { + lock (s_staticEvents) + { + if (!s_staticEvents.TryGetValue(type, out handlers!)) s_staticEvents[type] = handlers = new EventHandlers(); + } + } + else + { + handlers = s_instanceEvents.GetValue(target, static _ => new EventHandlers()); + } + return (ev, handlers); + } + + /// `obj.add_Event(fn)` from JS: subscribes a delegate of the event's type calling `fn`. + public static void AddEventHandler(object? target, string typeName, string eventName, int token, JsFunctionRef fn) + { + var (ev, handlers) = EventOf(target, typeName, eventName); + var handler = MakeJsDelegate(ev.EventHandlerType!, fn.Id); + ev.AddEventHandler(target, handler); + lock (handlers) + { + if (!handlers.Added.TryGetValue((eventName, token), out var list)) + handlers.Added[(eventName, token)] = list = new List(); + list.Add(handler); + } + } + + /// `obj.remove_Event(fn)` from JS: unsubscribes the delegate added for `fn`. + public static void RemoveEventHandler(object? target, string typeName, string eventName, int token) + { + var (ev, handlers) = EventOf(target, typeName, eventName); + Delegate? handler = null; + lock (handlers) + { + if (handlers.Added.TryGetValue((eventName, token), out var list) && list.Count > 0) + { + handler = list[^1]; + list.RemoveAt(list.Count - 1); + if (list.Count == 0) handlers.Added.Remove((eventName, token)); + } + } + if (handler is not null) ev.RemoveEventHandler(target, handler); + } + + /// `obj.Event = fn` from JS: replaces the handler assigned before (null removes it). + public static void SetEventHandler(object? target, string typeName, string eventName, JsFunctionRef? fn) + { + var (ev, handlers) = EventOf(target, typeName, eventName); + Delegate? previous; + lock (handlers) + { + handlers.Assigned.Remove(eventName, out previous); + } + if (previous is not null) ev.RemoveEventHandler(target, previous); + if (fn is not { } f) return; + var handler = MakeJsDelegate(ev.EventHandlerType!, f.Id); + ev.AddEventHandler(target, handler); + lock (handlers) + { + handlers.Assigned[eventName] = handler; + } + } +} diff --git a/dotnet-bridge/Bridge.Proxy.cs b/dotnet-bridge/Bridge.Proxy.cs index c16910d..d9b3699 100644 --- a/dotnet-bridge/Bridge.Proxy.cs +++ b/dotnet-bridge/Bridge.Proxy.cs @@ -17,7 +17,7 @@ public static partial class Bridge // The types behind JS subclasses, emitted at runtime. Keyed by base type PLUS the exact set of // interfaces/members a JS class asked for, so different JS subclasses of the same base type // (different override sets) get distinct emitted types. - private readonly record struct ProxyTypeKey(Type BaseType, string InterfaceKey, string MemberKey); + private readonly record struct ProxyTypeKey(Type BaseType, string InterfaceKey, string MemberKey, string ClassName); private static readonly ConcurrentDictionary s_dynamicProxyCache = new(); private static int s_dynamicProxyTypeCounter = 0; private static readonly AssemblyBuilder? s_dynamicAssembly = CreateDynamicAssembly(); @@ -74,6 +74,46 @@ public static void SetProperty(object instance, string propertyName, object? val { ProxySetProperty(instance, propertyName, value); } + + // Called by every emitted constructor. An instance .NET code created (not JS) gets its JS + // object from the factory registered for its type. + public static void Constructed(object instance) + { + if (s_pendingProxyCallbackId.Value.HasValue || IsJsBacked(instance)) return; + if (!s_nativeFactories.TryGetValue(instance.GetType(), out var factory)) + throw new InvalidOperationException($"{instance.GetType().FullName} is a JS class: register it with NSWinRT.dotnet.typeOf() before .NET code creates it"); + CallJsCallback(factory, [new HandleRef(HandleFor(instance))], expectsResult: false); + if (!IsJsBacked(instance)) + throw new InvalidOperationException($"The JS class behind {instance.GetType().FullName} did not create its instance"); + } + } + + // Factory callback per emitted type, for instances .NET code creates. + private static readonly ConcurrentDictionary s_nativeFactories = new(); + + /// `NSWinRT.dotnet.typeOf(JsClass)`: the class's emitted type, which .NET code can then create; + /// `factory` makes the JS object of each such instance. + public static Type RegisterJsClass(string baseTypeName, string[] interfaceNames, string[] memberNames, string className, JsFunctionRef factory) + { + var baseType = ResolveType(null, baseTypeName) ?? throw new TypeLoadException($"Type not found: {baseTypeName}"); + var interfaces = interfaceNames.ToList(); + if (baseType.IsInterface) + { + interfaces.Insert(0, baseType.FullName!); + baseType = typeof(object); + } + var type = GetOrCreateDynamicProxyType(baseType, interfaces.ToArray(), memberNames, className); + s_nativeFactories[type] = factory.Id; + return type; + } + + /// The factory's half: ties the instance behind `handle` to the JS object's dispatcher. + public static void AdoptJsInstance(int handle, JsFunctionRef dispatcher) + { + if (!s_handles.TryGetValue(handle, out var instance) || instance is null) + throw new KeyNotFoundException($"Invalid handle {handle}"); + RegisterJsBacked(instance, dispatcher.Id); + if (s_proxyCallbacks.TryGetValue(instance, out var holder)) holder.HandleId = handle; } // Ties a JS-backed instance to its JS dispatcher (and the handle its JS object holds). Lives as // long as the instance (ConditionalWeakTable value); when the instance is collected the runtime @@ -89,15 +129,32 @@ private sealed class ProxyCallbackHolder(int callbackId) private static readonly ConditionalWeakTable s_proxyCallbacks = new(); private static readonly ThreadLocal s_pendingProxyCallbackId = new(() => null); - private static Type GetOrCreateDynamicProxyType(Type baseType, string[] interfaceNames, string[] memberNames) + private static Type GetOrCreateDynamicProxyType(Type baseType, string[] interfaceNames, string[] memberNames, string className = "") { var interfaceKey = string.Join(",", interfaceNames.OrderBy(n => n, StringComparer.Ordinal)); var memberKey = string.Join(",", memberNames.OrderBy(n => n, StringComparer.Ordinal)); - var key = new ProxyTypeKey(baseType, interfaceKey, memberKey); - return s_dynamicProxyCache.GetOrAdd(key, _ => CreateDynamicProxyType(baseType, interfaceNames, memberNames)); + var key = new ProxyTypeKey(baseType, interfaceKey, memberKey, className); + return s_dynamicProxyCache.GetOrAdd(key, _ => CreateDynamicProxyType(baseType, interfaceNames, memberNames, className)); + } + + private static readonly HashSet s_usedProxyNames = new(StringComparer.Ordinal); + + // `NSWinRTDynamicProxies.` (numbered on clashes), so .NET code can find the type by + // name. Unnamed classes are named after their base type. + private static string ProxyTypeName(Type baseType, string className) + { + var safe = new string(className.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + if (safe.Length == 0 || char.IsDigit(safe[0])) + return $"NSWinRTDynamicProxies.{(baseType.FullName ?? "Type").Replace('.', '_').Replace('+', '_')}_JsProxy_{Interlocked.Increment(ref s_dynamicProxyTypeCounter)}"; + lock (s_usedProxyNames) + { + var name = "NSWinRTDynamicProxies." + safe; + for (int n = 2; !s_usedProxyNames.Add(name); n++) name = $"NSWinRTDynamicProxies.{safe}{n}"; + return name; + } } - private static Type CreateDynamicProxyType(Type baseType, string[] interfaceNames, string[] memberNames) + private static Type CreateDynamicProxyType(Type baseType, string[] interfaceNames, string[] memberNames, string className) { if (s_dynamicModule == null) @@ -115,9 +172,7 @@ private static Type CreateDynamicProxyType(Type baseType, string[] interfaceName // the vtable (no "call base" IL needed, the base method was simply never touched). var memberSet = new HashSet(memberNames, StringComparer.Ordinal); - var safeName = (baseType.FullName ?? Guid.NewGuid().ToString()).Replace('.', '_'); - var uniqueSuffix = Interlocked.Increment(ref s_dynamicProxyTypeCounter); - var typeName = $"NSWinRTDynamicProxies.{safeName}_JsProxy_{uniqueSuffix}"; + var typeName = ProxyTypeName(baseType, className); var tb = s_dynamicModule.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class, baseType); @@ -152,6 +207,8 @@ private static Type CreateDynamicProxyType(Type baseType, string[] interfaceName } } ilg.Emit(OpCodes.Call, bc); + ilg.Emit(OpCodes.Ldarg_0); + ilg.Emit(OpCodes.Call, typeof(ProxyRuntime).GetMethod(nameof(ProxyRuntime.Constructed))!); ilg.Emit(OpCodes.Ret); } @@ -164,12 +221,12 @@ static string SignatureKey(string name, Type[] paramTypes) => name + "(" + string.Join(",", paramTypes.Select(p => p.FullName)) + ")"; // Override only the base virtuals JS actually asked for (memberSet). Anything not in - // that set is simply never touched, so the base implementation stays live in the vtable — - // no "call base" IL needed. (Skip ref/out and generic methods — unsupported.) + // that set is simply never touched, so the base implementation stays live in the vtable: + // no "call base" IL needed. (ref/out methods are skipped.) // Abstract members are always implemented (dispatching to JS, which reports a missing // implementation as an error) — a type with an unimplemented abstract member can't be created. var virtualMethods = baseType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(m => m.IsVirtual && !m.IsFinal && !m.IsGenericMethod && !m.IsConstructor + .Where(m => m.IsVirtual && !m.IsFinal && !m.IsConstructor && (m.IsPublic || m.IsFamily || m.IsFamilyOrAssembly) && (memberSet.Contains(m.Name) || m.IsAbstract)) .ToArray(); @@ -178,6 +235,11 @@ static string SignatureKey(string name, Type[] paramTypes) => { var parameters = mi.GetParameters(); if (parameters.Any(p => p.ParameterType.IsByRef)) continue; + if (mi.IsGenericMethodDefinition) + { + EmitGenericOverride(tb, mi); + continue; + } var paramTypes = parameters.Select(p => p.ParameterType).ToArray(); var sigKey = SignatureKey(mi.Name, paramTypes); @@ -241,6 +303,88 @@ void CollectInterfaces(Type t) return tb.CreateTypeInfo()!.AsType(); } + // Overrides a generic virtual (`T Convert(object)`) with a generic method that calls JS and + // converts the result to T. + private static void EmitGenericOverride(TypeBuilder tb, MethodInfo mi) + { + var mb = tb.DefineMethod(mi.Name, MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, mi.CallingConvention); + var baseArgs = mi.GetGenericArguments(); + var gps = mb.DefineGenericParameters(baseArgs.Select(a => a.Name).ToArray()); + + Type Sub(Type t) + { + if (t.IsGenericParameter && t.DeclaringMethod is not null) return gps[t.GenericParameterPosition]; + if (t.IsArray) return t.GetArrayRank() == 1 ? Sub(t.GetElementType()!).MakeArrayType() : Sub(t.GetElementType()!).MakeArrayType(t.GetArrayRank()); + if (t.IsByRef) return Sub(t.GetElementType()!).MakeByRefType(); + if (t.IsGenericType && t.ContainsGenericParameters) + return t.GetGenericTypeDefinition().MakeGenericType(t.GetGenericArguments().Select(Sub).ToArray()); + return t; + } + + for (int i = 0; i < gps.Length; i++) + { + gps[i].SetGenericParameterAttributes(baseArgs[i].GenericParameterAttributes); + var constraints = baseArgs[i].GetGenericParameterConstraints().Select(Sub).ToArray(); + var baseConstraint = constraints.FirstOrDefault(c => !c.IsInterface); + if (baseConstraint is not null) gps[i].SetBaseTypeConstraint(baseConstraint); + var interfaces = constraints.Where(c => c.IsInterface).ToArray(); + if (interfaces.Length > 0) gps[i].SetInterfaceConstraints(interfaces); + } + + var paramTypes = mi.GetParameters().Select(p => Sub(p.ParameterType)).ToArray(); + var returnType = Sub(mi.ReturnType); + mb.SetReturnType(returnType); + mb.SetParameters(paramTypes); + + var il = mb.GetILGenerator(); + var argsLocal = il.DeclareLocal(typeof(object[])); + il.Emit(OpCodes.Ldc_I4, paramTypes.Length); + il.Emit(OpCodes.Newarr, typeof(object)); + il.Emit(OpCodes.Stloc, argsLocal); + for (int i = 0; i < paramTypes.Length; i++) + { + il.Emit(OpCodes.Ldloc, argsLocal); + il.Emit(OpCodes.Ldc_I4, i); + il.Emit(OpCodes.Ldarg, i + 1); + // `box` of a type parameter is a no-op for reference types. + if (paramTypes[i].IsGenericParameter || paramTypes[i].ContainsGenericParameters || paramTypes[i].IsValueType) + il.Emit(OpCodes.Box, paramTypes[i]); + il.Emit(OpCodes.Stelem_Ref); + } + var runtime = typeof(ProxyRuntime); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, mi.Name); + il.Emit(OpCodes.Ldloc, argsLocal); + if (returnType == typeof(void)) + il.Emit(OpCodes.Call, runtime.GetMethod(nameof(ProxyRuntime.InvokeVoid))!); + else + il.Emit(OpCodes.Call, runtime.GetMethod(nameof(ProxyRuntime.InvokeMethodTyped))!.MakeGenericMethod(returnType)); + il.Emit(OpCodes.Ret); + tb.DefineMethodOverride(mb, mi); + + // `super.Convert(...)`: a generic entry point to the base implementation. + if (mi.IsAbstract) return; + var baseCall = tb.DefineMethod(BaseCallPrefix + mi.Name, MethodAttributes.Public | MethodAttributes.HideBySig, CallingConventions.HasThis); + var bgps = baseCall.DefineGenericParameters(baseArgs.Select(a => a.Name).ToArray()); + for (int i = 0; i < bgps.Length; i++) bgps[i].SetGenericParameterAttributes(baseArgs[i].GenericParameterAttributes); + Type SubBase(Type t) + { + if (t.IsGenericParameter && t.DeclaringMethod is not null) return bgps[t.GenericParameterPosition]; + if (t.IsArray) return SubBase(t.GetElementType()!).MakeArrayType(); + if (t.IsGenericType && t.ContainsGenericParameters) + return t.GetGenericTypeDefinition().MakeGenericType(t.GetGenericArguments().Select(SubBase).ToArray()); + return t; + } + var baseParams = mi.GetParameters().Select(p => SubBase(p.ParameterType)).ToArray(); + baseCall.SetReturnType(SubBase(mi.ReturnType)); + baseCall.SetParameters(baseParams); + var bil = baseCall.GetILGenerator(); + bil.Emit(OpCodes.Ldarg_0); + for (int i = 0; i < baseParams.Length; i++) bil.Emit(OpCodes.Ldarg, i + 1); + bil.Emit(OpCodes.Call, mi.MakeGenericMethod(bgps)); + bil.Emit(OpCodes.Ret); + } + internal const string BaseCallPrefix = "__ns_base_"; internal const string JsSelfMember = "__ns_js_self"; @@ -303,14 +447,14 @@ private static MethodBuilder EmitProxyMethodBody( private static DispatchResult CreateJsSubclass( string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames) - => CreateJsSubclass(assemblyName, typeName, callbackId, interfaceNames, memberNames, []); + => CreateJsSubclass(assemblyName, typeName, callbackId, interfaceNames, memberNames, [], ""); // Creates an instance of a JS subclass: a dynamic subclass of the base type (or of System.Object // when the "base" is an interface being implemented) whose overridden virtuals and interface // members call the JS object registered under `callbackId`. `ctorArgs` select and feed the // base constructor, as `super(...)` arguments do on Android/iOS. private static DispatchResult CreateJsSubclass( - string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames, object?[] ctorArgs) + string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames, object?[] ctorArgs, string className) { // `typeName` is the base type; older runtimes passed an auto-generated proxy name there and // the base type in `assemblyName`. @@ -327,7 +471,7 @@ private static DispatchResult CreateJsSubclass( if (baseType.IsSealed) throw new InvalidOperationException($"{baseType.FullName} is sealed and can't be extended."); - var proxyType = GetOrCreateDynamicProxyType(baseType, interfaces.ToArray(), memberNames); + var proxyType = GetOrCreateDynamicProxyType(baseType, interfaces.ToArray(), memberNames, className); // Pick the constructor by argument count, preferring one whose parameters the arguments // convert to. @@ -463,39 +607,30 @@ private static ProxyCallbackHolder JsHolderFor(object instance) throw new InvalidOperationException("No JS callback registered for proxy instance."); } - // Payload for the JS dispatcher: (self handle, member name, ...args). The arguments are sent + // The JS dispatcher gets (self handle, member name, ...args). The arguments are sent // individually so each arrives as its JS value (numbers, strings, structs as plain objects, // objects as handles). - private static object?[] DispatchPayload(object instance, ProxyCallbackHolder holder, string memberName, object?[] args) - { - var payload = new object?[args.Length + 2]; - payload[0] = new HandleRef(EnsureJsHandle(instance, holder)); - payload[1] = memberName; - Array.Copy(args, 0, payload, 2, args.Length); - return payload; - } - private static object? ProxyInvokeMethod(object instance, string methodName, object[] args) { var holder = JsHolderFor(instance); - return CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, methodName, args), expectsResult: true); + return CallJsCallback(holder.CallbackId, EnsureJsHandle(instance, holder), methodName, args, expectsResult: true); } private static void ProxyInvokeVoid(object instance, string methodName, object[] args) { var holder = JsHolderFor(instance); - CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, methodName, args), expectsResult: false); + CallJsCallback(holder.CallbackId, EnsureJsHandle(instance, holder), methodName, args, expectsResult: false); } private static object? ProxyGetProperty(object instance, string propertyName) { var holder = JsHolderFor(instance); - return CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, "get_" + propertyName, []), expectsResult: true); + return CallJsCallback(holder.CallbackId, EnsureJsHandle(instance, holder), "get_" + propertyName, [], expectsResult: true); } private static void ProxySetProperty(object instance, string propertyName, object? value) { var holder = JsHolderFor(instance); - CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, "set_" + propertyName, [value]), expectsResult: false); + CallJsCallback(holder.CallbackId, EnsureJsHandle(instance, holder), "set_" + propertyName, [value], expectsResult: false); } } diff --git a/dotnet-bridge/Bridge.TaskHelper.cs b/dotnet-bridge/Bridge.TaskHelper.cs index 5671e5b..790d48a 100644 --- a/dotnet-bridge/Bridge.TaskHelper.cs +++ b/dotnet-bridge/Bridge.TaskHelper.cs @@ -28,14 +28,15 @@ internal static void ScheduleTaskContinuation(int handleId, int resolveId, int r { try { + // Rejections pass the exception; JS makes an Error of it. if (completed.IsFaulted) { - var ex = completed.Exception?.InnerException ?? completed.Exception; - CallJsCallback(rejectId, [ex?.Message ?? "Task faulted"]); + var ex = (Exception?)completed.Exception?.InnerException ?? completed.Exception; + CallJsCallback(rejectId, [ex ?? new InvalidOperationException("Task faulted")]); } else if (completed.IsCanceled) { - CallJsCallback(rejectId, ["Task cancelled"]); + CallJsCallback(rejectId, [new TaskCanceledException(completed)]); } else { @@ -44,7 +45,7 @@ internal static void ScheduleTaskContinuation(int handleId, int resolveId, int r } catch (Exception ex) { - CallJsCallback(rejectId, [ex.Message]); + CallJsCallback(rejectId, [ex]); } }, TaskScheduler.Default); } diff --git a/dotnet-bridge/Bridge.cs b/dotnet-bridge/Bridge.cs index 914caa4..6133224 100644 --- a/dotnet-bridge/Bridge.cs +++ b/dotnet-bridge/Bridge.cs @@ -34,7 +34,7 @@ public static partial class Bridge // 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_propCache = new(); private static readonly ConcurrentDictionary s_ctorCache = new(); // Cache of attempted assembly loads (simple name -> Assembly or null if not found). private static readonly ConcurrentDictionary s_assemblyLoadCache @@ -50,6 +50,7 @@ public static partial class Bridge internal static void ClearCaches() { s_typeCache.Clear(); + s_missingTypes.Clear(); s_methodCache.Clear(); s_propCache.Clear(); s_ctorCache.Clear(); @@ -58,7 +59,7 @@ internal static void ClearCaches() s_boxInfo.Clear(); s_paramsCache.Clear(); s_collectionInfo.Clear(); - s_handles.Clear(); + ClearHandles(); s_nativePtrs.Clear(); s_nativePtrsReverse.Clear(); s_nextHandle = 0; @@ -106,57 +107,76 @@ internal static void RemoveNativePtr(int handleId, IntPtr ptr) // on a coarse top-level root such as Microsoft. public static string GetNamespaceAssemblyMapJson() { + // Metadata only: loading each DLL to list its types is slow. var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var entryPath = Assembly.GetEntryAssembly()?.Location; try { foreach (var dir in s_assemblySearchDirs.Where(Directory.Exists)) { - try + IEnumerable files; + try { files = Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly); } + catch { continue; } + foreach (var file in files) { - foreach (var file in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly)) + try { - try - { - var simple = Path.GetFileNameWithoutExtension(file); - if (string.IsNullOrEmpty(simple)) continue; - - Assembly? asm = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => string.Equals(a.GetName().Name, simple, StringComparison.OrdinalIgnoreCase)); - if (asm is null) - { - try { asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(file); } - catch { continue; } - } - - // The app's own assembly (C# sources compiled into the app) contributes - // its internal types too: they are reachable from JS (type resolution - // finds non-public types), so their namespaces must be as well. - Type[] types; - try { types = asm == Assembly.GetEntryAssembly() ? asm.GetTypes() : asm.GetExportedTypes(); } - catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray()!; } - catch { types = Array.Empty(); } - - var assemblyName = asm.GetName().Name; - if (string.IsNullOrEmpty(assemblyName)) continue; - - foreach (var t in types) - { - if (string.IsNullOrEmpty(t.Namespace) || t.Name.StartsWith('<')) continue; - map[t.Namespace] = assemblyName; - var root = t.Namespace.Split('.')[0]; - if (!map.ContainsKey(root)) map[root] = assemblyName; - } - } - catch { } + // The app's own assembly counts its internal types too: JS reaches them. + var includeInternal = entryPath is not null && string.Equals(Path.GetFullPath(file), entryPath, StringComparison.OrdinalIgnoreCase); + AddNamespaces(file, includeInternal, map); } + catch { } } - catch { } } } catch { } try { return JsonSerializer.Serialize(map); } catch { return "{}"; } } + // Maps the namespaces of an assembly's top-level types (and their roots) to its name. + private static void AddNamespaces(string file, bool includeInternal, Dictionary map) + { + using var stream = File.OpenRead(file); + using var pe = new System.Reflection.PortableExecutable.PEReader(stream); + if (!pe.HasMetadata) return; + var md = System.Reflection.Metadata.PEReaderExtensions.GetMetadataReader(pe); + if (!md.IsAssembly) return; + var assemblyName = md.GetString(md.GetAssemblyDefinition().Name); + if (string.IsNullOrEmpty(assemblyName)) return; + var seen = new HashSet(); + foreach (var h in md.TypeDefinitions) + { + var td = md.GetTypeDefinition(h); + var visibility = td.Attributes & TypeAttributes.VisibilityMask; + if (visibility != TypeAttributes.Public && !(includeInternal && visibility == TypeAttributes.NotPublic)) continue; + if (td.Namespace.IsNil || !seen.Add(td.Namespace)) continue; + if (md.GetString(td.Name).StartsWith('<')) continue; + var ns = md.GetString(td.Namespace); + map[ns] = assemblyName; + map.TryAdd(ns.Split('.')[0], assemblyName); + } + } + + /// Loads the assembly at `path` (relative to the app directory unless absolute) and returns its + /// namespace map, as GetNamespaceAssemblyMapJson does. + public static string LoadAssembly(string path) + { + var full = Path.IsPathRooted(path) ? path : Path.Combine(AppContext.BaseDirectory, path); + var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(full)); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + Type[] types; + try { types = asm.GetExportedTypes(); } + catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray()!; } + var name = asm.GetName().Name ?? ""; + foreach (var t in types) + { + if (string.IsNullOrEmpty(t.Namespace)) continue; + map[t.Namespace] = name; + map.TryAdd(t.Namespace.Split('.')[0], name); + } + return JsonSerializer.Serialize(map); + } + static Bridge() { try @@ -212,6 +232,7 @@ static Bridge() s_assemblySearchDirs = dirs.Where(d => !string.IsNullOrEmpty(d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; } catch { @@ -470,6 +491,7 @@ internal static bool IsMarshaledForDifferentThread(Exception? ex) { var requested = new AssemblyName(args.Name).Name; if (string.IsNullOrEmpty(requested)) return null; + if (requested == "NSWinRTDynamicProxies") return s_dynamicAssembly; if (s_assemblyLoadCache.TryGetValue(requested, out var cached)) return cached; @@ -517,52 +539,6 @@ public static unsafe int RegisterJsCallback( return 0; } - // Callback id registered by the runtime/JS that should receive unhandled - // managed exceptions and unobserved task exceptions. - private static int s_unhandledExceptionCallbackId = -1; - - // Called from JS/Rust to request that managed unhandled exceptions be - // forwarded to the registered JS callback id. The callback id must have - // been created on the Rust side and point to a JS function stored in the - // runtime's `DOTNET_JS_CALLBACKS` map. - public static int RegisterUnhandledExceptionCallback(int callbackId) - { - s_unhandledExceptionCallbackId = callbackId; - - // Subscribe once (idempotent). - try - { - AppDomain.CurrentDomain.UnhandledException -= OnAppDomainUnhandledException; - AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException; - - TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; - TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; - - // Try to subscribe to Windows.CoreApplication.UnhandledErrorDetected if available - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - var coreAppType = asm.GetType("Windows.ApplicationModel.Core.CoreApplication"); - if (coreAppType == null) continue; - var ev = coreAppType.GetEvent("UnhandledErrorDetected"); - if (ev == null) continue; - try - { - var handlerType = ev.EventHandlerType; - var method = typeof(Bridge).GetMethod(nameof(CoreApplicationUnhandledErrorHandler), BindingFlags.NonPublic | BindingFlags.Static); - if (handlerType != null && method != null) - { - var del = Delegate.CreateDelegate(handlerType, method); - ev.AddEventHandler(null, del); - } - } - catch { } - } - } - catch { } - - return 0; - } - // IInspectable IID — the common interface for all WinRT objects. // WinRT APIs typically QI for IInspectable (not just IUnknown) when they // accept a polymorphic `object` parameter or need runtime class identity. @@ -693,56 +669,6 @@ private static IntPtr ObtainNativePtr(object obj) } } - private static void OnAppDomainUnhandledException(object? sender, UnhandledExceptionEventArgs e) - { - try - { - var ex = e.ExceptionObject as Exception; - var msg = ex?.ToString() ?? e.ExceptionObject?.ToString() ?? "(unknown)"; - SendUnhandledToJs("unhandled", msg); - } - catch { } - } - - private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) - { - try - { - var msg = e.Exception?.ToString() ?? "(unobserved task exception)"; - SendUnhandledToJs("unobservedTask", msg); - } - catch { } - } - - // Fallback handler for platform-specific CoreApplication unhandled events. - private static void CoreApplicationUnhandledErrorHandler(object? sender, object? args) - { - try - { - var msg = args?.ToString() ?? "(core unhandled)"; - SendUnhandledToJs("coreUnhandled", msg); - } - catch { } - } - - private static unsafe void SendUnhandledToJs(string kind, string message) - { - try - { - if (s_jsInvoker == null || s_unhandledExceptionCallbackId <= 0) return; - var payload = JsonSerializer.Serialize(new { kind = kind, message = message }); - var bytes = System.Text.Encoding.UTF8.GetBytes(payload); - fixed (byte* p = bytes) - { - byte* respPtr = null; - int respLen = 0; - s_jsInvoker(s_unhandledExceptionCallbackId, p, bytes.Length, &respPtr, &respLen); - if (respPtr != null && respLen > 0) Marshal.FreeHGlobal((IntPtr)respPtr); - } - } - catch { } - } - [UnmanagedCallersOnly(EntryPoint = "Invoke", CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] public static unsafe int Invoke( diff --git a/dotnet-bridge/DispatchTypes.cs b/dotnet-bridge/DispatchTypes.cs index 82e1057..73e36f7 100644 --- a/dotnet-bridge/DispatchTypes.cs +++ b/dotnet-bridge/DispatchTypes.cs @@ -46,7 +46,7 @@ internal readonly struct CtorEntry(ConstructorInfo? ctor, ParameterInfo[] parame public readonly ParameterInfo[] Parameters = parameters; } -internal enum DispatchKind : byte { Void, Primitive, Handle, Collection, Members, TaskHandle } +internal enum DispatchKind : byte { Void, Primitive, Handle, Collection, Members, TaskHandle, Bytes } internal readonly struct DispatchResult { @@ -66,18 +66,22 @@ internal readonly struct DispatchResult private readonly string[]? _readonlyStaticProperties; private readonly string[]? _writeonlyProperties; private readonly string[]? _writeonlyStaticProperties; + // Member lists added after the first eight, by name (events, nested types, ...). + private readonly (string Key, string[] Names)[]? _extraLists; private DispatchResult(DispatchKind kind, object? value, Type? type, int handle, string? typeName, string[]? methods, string[]? props, string[]? staticMethods, string[]? staticProps, string[]? readonlyProps = null, string[]? readonlyStaticProps = null, - string[]? writeonlyProps = null, string[]? writeonlyStaticProps = null) + string[]? writeonlyProps = null, string[]? writeonlyStaticProps = null, + (string Key, string[] Names)[]? extraLists = null) { _kind = kind; _value = value; _type = type; _handle = handle; _typeName = typeName; _methods = methods; _properties = props; _staticMethods = staticMethods; _staticProperties = staticProps; _readonlyProperties = readonlyProps; _readonlyStaticProperties = readonlyStaticProps; _writeonlyProperties = writeonlyProps; _writeonlyStaticProperties = writeonlyStaticProps; + _extraLists = extraLists; } public static DispatchResult Primitive(object value, Type type) @@ -92,13 +96,18 @@ public static DispatchResult TaskHandle(int id, string typeName) public static DispatchResult Collection(IEnumerable items) => new(DispatchKind.Collection, items, null, 0, null, null, null, null, null); + // byte[], Memory, ArraySegment: a Uint8Array in JS. + public static DispatchResult Bytes(object value) + => new(DispatchKind.Bytes, value, null, 0, null, null, null, null, null); + public static DispatchResult Members( string[] methods, string[] props, string[] staticMethods, string[] staticProps, string[] readonlyProps, string[] readonlyStaticProps, - string[] writeonlyProps, string[] writeonlyStaticProps) + string[] writeonlyProps, string[] writeonlyStaticProps, + (string Key, string[] Names)[]? extras = null) => new(DispatchKind.Members, null, null, 0, null, methods, props, staticMethods, staticProps, - readonlyProps, readonlyStaticProps, writeonlyProps, writeonlyStaticProps); + readonlyProps, readonlyStaticProps, writeonlyProps, writeonlyStaticProps, extras); internal int HandleId() => _handle; @@ -142,6 +151,11 @@ public void WriteTo(Utf8JsonWriter w, JsonSerializerOptions opts) w.WriteEndObject(); break; + case DispatchKind.Bytes: + Bridge.TryGetBytes(_value!, out var jsonBytes, out _); + w.WriteBase64StringValue(jsonBytes); + break; + case DispatchKind.Collection: w.WriteStartArray(); foreach (var item in (IEnumerable)_value!) @@ -159,6 +173,8 @@ public void WriteTo(Utf8JsonWriter w, JsonSerializerOptions opts) WriteStringArray(w, "readonlyStaticProperties"u8, _readonlyStaticProperties!); WriteStringArray(w, "writeonlyProperties"u8, _writeonlyProperties!); WriteStringArray(w, "writeonlyStaticProperties"u8,_writeonlyStaticProperties!); + foreach (var (key, names) in _extraLists ?? []) + WriteStringArray(w, System.Text.Encoding.UTF8.GetBytes(key), names); w.WriteEndObject(); break; } @@ -177,7 +193,7 @@ private static void WriteStringArray(Utf8JsonWriter w, ReadOnlySpan name, // 0x03 = i32[4] 0x04 = f64[8] 0x05 = string[u32+utf8] // 0x06 = handle[i32 + u16+utf8 type] // 0x07 = array[u32 count + N tagged items] - // 0x08 = members{ methods, props, staticMethods, staticProps } + // 0x08 = members{ 8 name lists, then u16 count + (u16+utf8 key, name list) per extra list } // 0xFF = error[u32+utf8] public void WriteAsBin(ArrayBufferWriter outBuf) @@ -220,6 +236,11 @@ public void WriteAsBin(ArrayBufferWriter outBuf) } break; + case DispatchKind.Bytes: + Bridge.TryGetBytes(_value!, out var bytes, out var kind); + Bridge.WriteTypedArray(ref w, bytes, kind); + break; + case DispatchKind.Collection: var items = new List(); foreach (var item in (IEnumerable)_value!) items.Add(item); @@ -238,6 +259,13 @@ public void WriteAsBin(ArrayBufferWriter outBuf) WriteStringArrayBin(ref w, _readonlyStaticProperties!); WriteStringArrayBin(ref w, _writeonlyProperties!); WriteStringArrayBin(ref w, _writeonlyStaticProperties!); + var extras = _extraLists ?? []; + w.WriteU16((ushort)extras.Length); + foreach (var (key, names) in extras) + { + w.WriteString16(key); + WriteStringArrayBin(ref w, names); + } break; } } @@ -253,8 +281,10 @@ private static void WritePrimitiveBin(ref BinWriter w, object value) case ushort v: w.WriteByte(0x03); w.WriteI32(v); break; case int v: w.WriteByte(0x03); w.WriteI32(v); break; case uint v: w.WriteByte(0x04); w.WriteF64(v); break; - case long v: w.WriteByte(0x04); w.WriteF64(v); break; - case ulong v: w.WriteByte(0x04); w.WriteF64(v); break; + case long v: Bridge.WriteInt64(ref w, v); break; + case ulong v: Bridge.WriteUInt64(ref w, v); break; + case nint v: Bridge.WriteInt64(ref w, v); break; + case nuint v: Bridge.WriteUInt64(ref w, v); break; case float v: w.WriteByte(0x04); w.WriteF64(v); break; case double v: w.WriteByte(0x04); w.WriteF64(v); break; case decimal v: w.WriteByte(0x04); w.WriteF64((double)v); break; @@ -272,6 +302,11 @@ private static void WriteValueBin(ref BinWriter w, object? value) WritePrimitiveBin(ref w, value); return; } + if (Bridge.TryGetBytes(value, out var bytes, out var kind)) + { + Bridge.WriteTypedArray(ref w, bytes, kind); + return; + } if (value is Array enumerable) { var items = new List(); @@ -281,8 +316,7 @@ private static void WriteValueBin(ref BinWriter w, object? value) foreach (var item in items) WriteValueBin(ref w, item); return; } - var id = Interlocked.Increment(ref Bridge.s_nextHandle); - Bridge.s_handles[id] = value; + var id = Bridge.HandleFor(value); w.WriteByte(0x06); w.WriteI32(id); w.WriteString16(t.FullName ?? t.Name); diff --git a/packages/common/src/prelude.rs b/packages/common/src/prelude.rs index 6f25388..b97192b 100644 --- a/packages/common/src/prelude.rs +++ b/packages/common/src/prelude.rs @@ -248,7 +248,10 @@ pub const PRELUDE: &str = r#" var childRequire = makeRequire(resolved); try { - var factory = new Function('module', 'exports', 'require', '__filename', '__dirname', content); + // A named script, so engines cache its compiled code. The module starts on line 1. + var factory = typeof g.__nsCompileScript === 'function' + ? g.__nsCompileScript('(function (module, exports, require, __filename, __dirname) {' + content + '\n})', resolved) + : new Function('module', 'exports', 'require', '__filename', '__dirname', content); factory(mod, mod.exports, childRequire, resolved, dirName); } catch (e) { cjsCache.delete(key); diff --git a/packages/windows-hermes/Cargo.lock b/packages/windows-hermes/Cargo.lock index 0857e1f..f10987f 100644 --- a/packages/windows-hermes/Cargo.lock +++ b/packages/windows-hermes/Cargo.lock @@ -726,11 +726,16 @@ version = "0.1.0" dependencies = [ "ahash", "dyn-clone", + "metadata-filter", "parking_lot", "sha1", "windows", ] +[[package]] +name = "metadata-filter" +version = "0.1.0" + [[package]] name = "mimalloc" version = "0.1.52" diff --git a/packages/windows-hermes/build.rs b/packages/windows-hermes/build.rs index b166d61..0748f23 100644 --- a/packages/windows-hermes/build.rs +++ b/packages/windows-hermes/build.rs @@ -42,6 +42,8 @@ fn main() { for dll in dlls { let _ = std::fs::copy(libdir.join(dll), profile_dir.join(dll)); } + // The bytecode compiler the app build runs (template/framework/tools/hermes-precompile.ps1). + let _ = std::fs::copy("vendor/x64/tools/hermesc.exe", profile_dir.join("hermesc.exe")); } println!("cargo:rerun-if-changed=vendor/x64/icu/napi_symbols.txt"); diff --git a/packages/windows-hermes/src/abi.rs b/packages/windows-hermes/src/abi.rs index dba1017..53bf144 100644 --- a/packages/windows-hermes/src/abi.rs +++ b/packages/windows-hermes/src/abi.rs @@ -104,7 +104,9 @@ pub extern "C" fn runtime_runscript( if run_module_entry(raw, filename, &code) { return; } - if let Err(e) = host::run_script_checked(raw, &code) { + let name = if filename.is_null() { String::new() } else { CStr::from_ptr(filename).to_string_lossy().into_owned() }; + let path = APP_ROOT.with(|r| entry_script_path(&r.borrow(), &name)); + if let Err(e) = crate::run_entry_script(&Env::from_raw(raw as napi::sys::napi_env), &code, &path) { eprintln!("[NativeScript] script error: {e}"); runtime::store_last_js_error(e); } @@ -112,6 +114,21 @@ pub extern "C" fn runtime_runscript( }); } +/// The path of an entry script the host names by file name (`vendor.js`): in `app/` under the app +/// root, or in the root. +fn entry_script_path(app_root: &str, name: &str) -> String { + let path = std::path::Path::new(name); + if path.is_absolute() || app_root.is_empty() { + return name.to_string(); + } + let root = std::path::Path::new(app_root); + [root.join("app").join(path), root.join(path)] + .into_iter() + .find(|p| p.is_file()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| name.to_string()) +} + /// Hermes's script evaluator for the module runner (`runtime::napi_engine::module_runner`). fn eval_for_modules(env: &Env, code: &str, filename: &str) -> Result { crate::eval_named(env, code, filename) diff --git a/packages/windows-hermes/src/lib.rs b/packages/windows-hermes/src/lib.rs index df4b9e8..1ce0995 100644 --- a/packages/windows-hermes/src/lib.rs +++ b/packages/windows-hermes/src/lib.rs @@ -34,6 +34,140 @@ pub mod ffi { result: *mut napi_value, ) -> i32; pub fn jsr_drain_microtasks(env: napi_env, max_count_hint: i32, result: *mut bool) -> i32; + pub fn jsr_run_script_buffer( + env: napi_env, + script_data: *const u8, + script_length: usize, + script_delete_cb: Option, + deleter_data: *mut c_void, + source_url: *const c_char, + result: *mut napi_value, + ) -> i32; + pub fn jsr_config_set_script_cache( + config: *mut c_void, + script_cache_data: *mut c_void, + load: Option, + store: Option, + data_delete: Option, + deleter_data: *mut c_void, + ) -> i32; + } + + pub type DataDelete = unsafe extern "C" fn(data: *mut c_void, deleter_data: *mut c_void); + + pub type ScriptCacheLoad = unsafe extern "C" fn( + script_cache_data: *mut c_void, + source_url: *const c_char, + source_hash: u64, + runtime_name: *const c_char, + runtime_version: u64, + cache_tag: *const c_char, + buffer: *mut *const u8, + buffer_size: *mut usize, + buffer_delete_cb: *mut Option, + deleter_data: *mut *mut c_void, + ); + + pub type ScriptCacheStore = unsafe extern "C" fn( + script_cache_data: *mut c_void, + source_url: *const c_char, + source_hash: u64, + runtime_name: *const c_char, + runtime_version: u64, + cache_tag: *const c_char, + buffer: *const u8, + buffer_size: usize, + buffer_delete_cb: Option, + deleter_data: *mut c_void, + ); +} + +/// Hermes's script cache, in the app's local folder. Files are keyed by script name, source hash, +/// Hermes version and cache tag, so an edited script or a new Hermes compiles again. +mod script_cache { + use std::ffi::{c_char, c_void, CStr}; + use std::path::PathBuf; + + use super::ffi::DataDelete; + + unsafe fn text(s: *const c_char) -> String { + if s.is_null() { String::new() } else { CStr::from_ptr(s).to_string_lossy().into_owned() } + } + + unsafe fn cache_path(url: *const c_char, hash: u64, version: u64, tag: *const c_char) -> Option { + let dir = runtime::local_folder()?; + let url = text(url); + let name: String = url + .rsplit(['/', '\\']) + .next() + .unwrap_or("script") + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }) + .take(64) + .collect(); + let tag: String = text(tag).chars().filter(|c| c.is_ascii_alphanumeric()).take(16).collect(); + Some(PathBuf::from(dir).join("hermes-cache").join(format!("{name}.{hash:016x}.{version:x}.{tag}.hbc"))) + } + + pub(crate) unsafe extern "C" fn free_bytes(data: *mut c_void, deleter_data: *mut c_void) { + let _ = data; + drop(Box::from_raw(deleter_data as *mut Vec)); + } + + pub unsafe extern "C" fn load( + _data: *mut c_void, + source_url: *const c_char, + source_hash: u64, + _runtime_name: *const c_char, + runtime_version: u64, + cache_tag: *const c_char, + buffer: *mut *const u8, + buffer_size: *mut usize, + buffer_delete_cb: *mut Option, + deleter_data: *mut *mut c_void, + ) { + *buffer = std::ptr::null(); + *buffer_size = 0; + let Some(path) = cache_path(source_url, source_hash, runtime_version, cache_tag) else { return }; + let Ok(bytes) = std::fs::read(&path) else { return }; + if bytes.is_empty() { + return; + } + let boxed = Box::new(bytes); + *buffer = boxed.as_ptr(); + *buffer_size = boxed.len(); + *buffer_delete_cb = Some(free_bytes); + *deleter_data = Box::into_raw(boxed) as *mut c_void; + } + + pub unsafe extern "C" fn store( + _data: *mut c_void, + source_url: *const c_char, + source_hash: u64, + _runtime_name: *const c_char, + runtime_version: u64, + cache_tag: *const c_char, + buffer: *const u8, + buffer_size: usize, + buffer_delete_cb: Option, + deleter_data: *mut c_void, + ) { + if let Some(path) = cache_path(source_url, source_hash, runtime_version, cache_tag) { + if !buffer.is_null() && buffer_size > 0 { + let bytes = std::slice::from_raw_parts(buffer, buffer_size); + // Written beside the final name first, so a crash never leaves a partial cache file. + let tmp = path.with_extension("tmp"); + let written = path.parent().map_or(Ok(()), std::fs::create_dir_all) + .and_then(|_| std::fs::write(&tmp, bytes)) + .and_then(|_| std::fs::rename(&tmp, &path)); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + } + } + } + if let Some(delete) = buffer_delete_cb { + delete(buffer as *mut c_void, deleter_data); + } } } @@ -54,6 +188,14 @@ pub unsafe fn create_runtime_env() -> Option<(*mut c_void, napi_env, *mut c_void let mut env_raw: napi_env = std::ptr::null_mut(); if ffi::jsr_create_config(&mut config) != 0 || ffi::jsr_config_set_explicit_microtasks(config, true) != 0 + || ffi::jsr_config_set_script_cache( + config, + std::ptr::null_mut(), + Some(script_cache::load), + Some(script_cache::store), + None, + std::ptr::null_mut(), + ) != 0 || ffi::jsr_create_runtime(config, &mut runtime) != 0 || ffi::jsr_runtime_get_node_api_env(runtime, &mut env_raw) != 0 || env_raw.is_null() @@ -67,10 +209,84 @@ pub unsafe fn create_runtime_env() -> Option<(*mut c_void, napi_env, *mut c_void Some((runtime, env_raw, scope)) } +/// What the prelude's `require` wraps a CommonJS module in (see `__nsCompileScript`). +const CJS_WRAPPER_HEAD: &str = "(function (module, exports, require, __filename, __dirname) {"; + +/// Bytecode the app build made with hermesc for `source_path`, unless older than the script: +/// `.hbc` for a script run as is, `.cjs.hbc` for one compiled in the CommonJS wrapper. +/// Hermes's own cache skips large scripts, which it compiles lazily. +fn precompiled(source_path: &str, suffix: &str) -> Option> { + if source_path.is_empty() { + return None; + } + let hbc = format!("{source_path}{suffix}"); + let hbc_time = std::fs::metadata(&hbc).and_then(|m| m.modified()).ok()?; + let source_time = std::fs::metadata(source_path).and_then(|m| m.modified()).ok()?; + if hbc_time < source_time { + return None; + } + std::fs::read(hbc).ok().filter(|b| !b.is_empty()) +} + +/// Runs Hermes bytecode as the script `url`. +unsafe fn run_bytecode(env: napi_env, bytecode: Vec, url: &std::ffi::CStr, result: *mut napi_value) -> i32 { + let boxed = Box::new(bytecode); + let (data, len) = (boxed.as_ptr(), boxed.len()); + ffi::jsr_run_script_buffer( + env, + data, + len, + Some(script_cache::free_bytes), + Box::into_raw(boxed) as *mut c_void, + url.as_ptr(), + result, + ) +} + +/// Run the app's entry script: its precompiled bytecode when the build made it, else `code`. +pub fn run_entry_script(env: &napi::Env, code: &str, filename: &str) -> Result { + use napi::NapiRaw; + if let (Some(bytecode), Ok(url)) = (precompiled(filename, ".hbc"), std::ffi::CString::new(filename)) { + let mut result: napi_value = std::ptr::null_mut(); + let status = unsafe { run_bytecode(env.raw(), bytecode, &url, &mut result) }; + if status == napi::sys::Status::napi_ok { + return Ok(String::new()); + } + return Err(pending_exception_message(env).unwrap_or_else(|| format!("jsr_run_script_buffer status {status}"))); + } + run_script(env, code) +} + +fn pending_exception_message(env: &napi::Env) -> Option { + use napi::{JsUnknown, NapiRaw, NapiValue}; + let mut is_pending = false; + unsafe { napi::sys::napi_is_exception_pending(env.raw(), &mut is_pending) }; + if !is_pending { + return None; + } + let mut err: napi_value = std::ptr::null_mut(); + unsafe { napi::sys::napi_get_and_clear_last_exception(env.raw(), &mut err) }; + unsafe { JsUnknown::from_raw_unchecked(env.raw(), err) } + .coerce_to_string() + .and_then(|s| s.into_utf8()) + .and_then(|s| Ok(format!("JS exception: {}", s.as_str()?))) + .ok() +} + /// Evaluate `code` as a script named `filename` for the module runner, returning its completion /// value. On failure Hermes's exception is left pending. pub fn eval_named(env: &napi::Env, code: &str, filename: &str) -> Result { use napi::NapiRaw; + if code.starts_with(CJS_WRAPPER_HEAD) { + if let (Some(bytecode), Ok(url)) = (precompiled(filename, ".cjs.hbc"), std::ffi::CString::new(filename)) { + let mut result: napi_value = std::ptr::null_mut(); + let status = unsafe { run_bytecode(env.raw(), bytecode, &url, &mut result) }; + if status != napi::sys::Status::napi_ok || result.is_null() { + return Err(()); + } + return Ok(result); + } + } let (Ok(source), Ok(url)) = (env.create_string(code), std::ffi::CString::new(filename)) else { return Err(()); }; diff --git a/packages/windows-hermes/vendor/README.md b/packages/windows-hermes/vendor/README.md index e4e1350..16cd832 100644 --- a/packages/windows-hermes/vendor/README.md +++ b/packages/windows-hermes/vendor/README.md @@ -26,6 +26,11 @@ and what that trades off. | `hermes.lib` | ~72 KB | Import library for `hermes.dll` | | `napi_symbols.txt` | — | Same export list as the `icu` variant (diffed identical, byte-for-byte after filtering the one regex false-positive below) | +### `x64/tools/` — from the same package's `tools/native/release/x64` +| File | Size | Purpose | +|---|---|---| +| `hermesc.exe` | ~3.6 MB | Hermes bytecode compiler. `build.rs` copies it beside the engine DLLs and `template/build.ps1 -Engine hermes` stages it in the framework's `tools/`, where Release app builds run it (`tools/hermes-precompile.ps1`) so a launch runs `.hbc` instead of compiling the bundle. It must come from the same package version as `hermes.dll`: bytecode is version-specific. | + `include/` (shared by both variants) holds the matching headers (`hermes/js_runtime_api.h`, `node-api/js_native_api.h`) for reference; the host declares the `jsr_*` FFI directly. @@ -51,6 +56,10 @@ llvm-objdump -p x64/icu/hermes.dll | grep -oE 'napi_[a-z0-9_]+' | sort -u > x64/ unzip -o hermes.nupkg 'build/native/uwp/x64/*' cp build/native/uwp/x64/{hermes.dll,hermes.lib} x64/no-icu/ llvm-objdump -p x64/no-icu/hermes.dll | grep -oE 'napi_[a-z0-9_]+' | sort -u > x64/no-icu/napi_symbols.txt + +# bytecode compiler: +unzip -o hermes.nupkg 'tools/native/release/x64/hermesc.exe' +cp tools/native/release/x64/hermesc.exe x64/tools/ ``` `napi_symbols.txt` is the list of `napi_*` exports that `build.rs` forward-exports from the host exe diff --git a/packages/windows-hermes/vendor/x64/tools/hermesc.exe b/packages/windows-hermes/vendor/x64/tools/hermesc.exe new file mode 100644 index 0000000..bb85306 Binary files /dev/null and b/packages/windows-hermes/vendor/x64/tools/hermesc.exe differ diff --git a/packages/windows-jsc/Cargo.lock b/packages/windows-jsc/Cargo.lock index d2fa88d..d28b46a 100644 --- a/packages/windows-jsc/Cargo.lock +++ b/packages/windows-jsc/Cargo.lock @@ -726,11 +726,16 @@ version = "0.1.0" dependencies = [ "ahash", "dyn-clone", + "metadata-filter", "parking_lot", "sha1", "windows", ] +[[package]] +name = "metadata-filter" +version = "0.1.0" + [[package]] name = "mimalloc" version = "0.1.52" diff --git a/packages/windows-jsc/vendor/shim/jsc-api.cpp b/packages/windows-jsc/vendor/shim/jsc-api.cpp index 45891b0..b8f3e84 100644 --- a/packages/windows-jsc/vendor/shim/jsc-api.cpp +++ b/packages/windows-jsc/vendor/shim/jsc-api.cpp @@ -585,37 +585,6 @@ class ExternalInfo: public BaseInfoT { } }; -class ReferenceInfo : public BaseInfoT { - public: - static napi_status Initialize(napi_env env, napi_value object, FinalizerT finalizer) { - - napi_valuetype type; - napi_typeof(env, object, &type); - - if (type == napi_object || type == napi_function) { - ReferenceInfo* info = new ReferenceInfo(env); - if (info == nullptr) { - return napi_set_last_error(env, napi_generic_failure); - } - - // JSObjectRef ref{JSObjectMake(env->context, info->_class, info)}; - // JSObjectSetPrototype(env->context, prototype, JSObjectGetPrototype(env->context, ToJSObject(env, object))); - // JSObjectSetPrototype(env->context, ToJSObject(env, object), prototype); - - NativeInfo::SetNativeInfo(env->context, ToJSObject(env, object), info->_class, "[[jsc_reference_info]]", info); - - info->AddFinalizer(finalizer); - } - - return napi_ok; - } - - private: - ReferenceInfo(napi_env env) - : BaseInfoT{env, "Native (Reference)"} { - } -}; - class WrapperInfo : public BaseInfoT { public: static napi_status Wrap(napi_env env, napi_value object, WrapperInfo** result) { @@ -703,6 +672,16 @@ class ExternalArrayBufferInfo { }; } +// JavaScriptCore's weak handles (JSWeakPrivate.h, exported by the bundled JavaScriptCore.dll). +extern "C" { +typedef const struct OpaqueJSWeak* JSWeakRef; +JS_EXPORT JSWeakRef JSWeakCreate(JSContextGroupRef group, JSObjectRef object); +JS_EXPORT void JSWeakRelease(JSContextGroupRef group, JSWeakRef weak); +JS_EXPORT JSObjectRef JSWeakGetObject(JSWeakRef weak); +} + +// While its count is 0 a reference holds its object through a JSWeak, which the collector clears +// before the object's memory can be reused. struct napi_ref__ { napi_ref__(napi_value value, uint32_t count) : _value{value} @@ -710,12 +689,9 @@ struct napi_ref__ { } napi_status init(napi_env env) { - // track the ref values to support weak refs - auto pair{env->active_ref_values.insert(_value)}; - if (pair.second) { - CHECK_NAPI(ReferenceInfo::Initialize(env, _value, [value = _value](ReferenceInfo* info) { - info->Env()->active_ref_values.erase(value); - })); + if (JSValueIsObject(env->context, ToJSValue(_value))) { + _group = JSContextGetGroup(env->context); + _weak = JSWeakCreate(_group, ToJSObject(env, _value)); } if (_count != 0) { @@ -729,6 +705,10 @@ struct napi_ref__ { if (_count != 0) { unprotect(env); } + if (_weak != nullptr) { + JSWeakRelease(_group, _weak); + _weak = nullptr; + } _value = nullptr; _count = 0; @@ -751,11 +731,11 @@ struct napi_ref__ { } napi_value value(napi_env env) const { - if (env->active_ref_values.find(_value) == env->active_ref_values.end()) { - return nullptr; + if (_count != 0 || _weak == nullptr) { + return _value; } - - return _value; + JSObjectRef object = JSWeakGetObject(_weak); + return object != nullptr ? ToNapi(object) : nullptr; } private: @@ -773,6 +753,8 @@ struct napi_ref__ { napi_value _value{}; uint32_t _count{}; std::list::iterator _iter{}; + JSContextGroupRef _group{}; + JSWeakRef _weak{}; }; void napi_env__::deinit_refs() { @@ -1480,13 +1462,33 @@ napi_status napi_create_range_error(napi_env env, return napi_ok; } +// JavaScriptCore's BigInt C API (exported by the bundled JavaScriptCore.dll; the vendored headers +// predate it). +extern "C" { +JS_EXPORT JSValueRef JSBigIntCreateWithInt64(JSContextRef ctx, int64_t integer, JSValueRef* exception); +JS_EXPORT JSValueRef JSBigIntCreateWithUInt64(JSContextRef ctx, uint64_t integer, JSValueRef* exception); +JS_EXPORT JSValueRef JSBigIntCreateWithString(JSContextRef ctx, JSStringRef string, JSValueRef* exception); +JS_EXPORT bool JSValueIsBigInt(JSContextRef ctx, JSValueRef value); +JS_EXPORT int64_t JSValueToInt64(JSContextRef ctx, JSValueRef value, JSValueRef* exception); +JS_EXPORT uint64_t JSValueToUInt64(JSContextRef ctx, JSValueRef value, JSValueRef* exception); +typedef enum { kJSRelationConditionUndefined, kJSRelationConditionEqual, kJSRelationConditionGreaterThan, kJSRelationConditionLessThan } JSRelationCondition; +JS_EXPORT JSRelationCondition JSValueCompareInt64(JSContextRef ctx, JSValueRef left, int64_t right, JSValueRef* exception); +JS_EXPORT JSRelationCondition JSValueCompareUInt64(JSContextRef ctx, JSValueRef left, uint64_t right, JSValueRef* exception); +} + +// JSType's BigInt member, after kJSTypeSymbol in newer headers. +static constexpr int kJSTypeBigIntValue = 7; + napi_status napi_typeof(napi_env env, napi_value value, napi_valuetype* result) { CHECK_ENV(env); CHECK_ARG(env, value); CHECK_ARG(env, result); - // JSC does not support BigInt JSType valueType = JSValueGetType(env->context, ToJSValue(value)); + if (static_cast(valueType) == kJSTypeBigIntValue) { + *result = napi_bigint; + return napi_ok; + } switch (valueType) { case kJSTypeUndefined: *result = napi_undefined; break; case kJSTypeNull: *result = napi_null; break; @@ -1736,6 +1738,110 @@ napi_status napi_get_value_int64(napi_env env, napi_value value, int64_t* result return napi_ok; } +napi_status napi_create_bigint_int64(napi_env env, int64_t value, napi_value* result) { + CHECK_ENV(env); + CHECK_ARG(env, result); + JSValueRef exception{}; + JSValueRef big = JSBigIntCreateWithInt64(env->context, value, &exception); + CHECK_JSC(env, exception); + *result = ToNapi(big); + return napi_ok; +} + +napi_status napi_create_bigint_uint64(napi_env env, uint64_t value, napi_value* result) { + CHECK_ENV(env); + CHECK_ARG(env, result); + JSValueRef exception{}; + JSValueRef big = JSBigIntCreateWithUInt64(env->context, value, &exception); + CHECK_JSC(env, exception); + *result = ToNapi(big); + return napi_ok; +} + +// Built from a hex string: the C API has no words constructor. +napi_status napi_create_bigint_words(napi_env env, int sign_bit, size_t word_count, const uint64_t* words, napi_value* result) { + CHECK_ENV(env); + CHECK_ARG(env, result); + if (word_count > 0) CHECK_ARG(env, words); + std::string text = sign_bit ? "-0x" : "0x"; + bool leading = true; + static const char digits[] = "0123456789abcdef"; + for (size_t i = word_count; i-- > 0;) { + for (int shift = 60; shift >= 0; shift -= 4) { + int nibble = static_cast((words[i] >> shift) & 0xF); + if (leading && nibble == 0) continue; + leading = false; + text.push_back(digits[nibble]); + } + } + if (leading) text = "0"; + JSStringRef string = JSStringCreateWithUTF8CString(text.c_str()); + JSValueRef exception{}; + JSValueRef big = JSBigIntCreateWithString(env->context, string, &exception); + JSStringRelease(string); + CHECK_JSC(env, exception); + *result = ToNapi(big); + return napi_ok; +} + +napi_status napi_get_value_bigint_int64(napi_env env, napi_value value, int64_t* result, bool* lossless) { + CHECK_ENV(env); + CHECK_ARG(env, value); + CHECK_ARG(env, result); + JSValueRef v = ToJSValue(value); + if (!JSValueIsBigInt(env->context, v)) return napi_set_last_error(env, napi_bigint_expected); + JSValueRef exception{}; + *result = JSValueToInt64(env->context, v, &exception); + CHECK_JSC(env, exception); + if (lossless) *lossless = JSValueCompareInt64(env->context, v, *result, &exception) == kJSRelationConditionEqual; + return napi_ok; +} + +napi_status napi_get_value_bigint_uint64(napi_env env, napi_value value, uint64_t* result, bool* lossless) { + CHECK_ENV(env); + CHECK_ARG(env, value); + CHECK_ARG(env, result); + JSValueRef v = ToJSValue(value); + if (!JSValueIsBigInt(env->context, v)) return napi_set_last_error(env, napi_bigint_expected); + JSValueRef exception{}; + *result = JSValueToUInt64(env->context, v, &exception); + CHECK_JSC(env, exception); + if (lossless) *lossless = JSValueCompareUInt64(env->context, v, *result, &exception) == kJSRelationConditionEqual; + return napi_ok; +} + +// Sign and the low 64 bits; a wider value reports word_count 2 with a zero high word. +napi_status napi_get_value_bigint_words(napi_env env, napi_value value, int* sign_bit, size_t* word_count, uint64_t* words) { + CHECK_ENV(env); + CHECK_ARG(env, value); + CHECK_ARG(env, word_count); + JSValueRef v = ToJSValue(value); + if (!JSValueIsBigInt(env->context, v)) return napi_set_last_error(env, napi_bigint_expected); + JSValueRef exception{}; + bool negative = JSValueCompareInt64(env->context, v, 0, &exception) == kJSRelationConditionLessThan; + uint64_t magnitude; + bool fits; + if (negative) { + int64_t s = JSValueToInt64(env->context, v, &exception); + fits = JSValueCompareInt64(env->context, v, s, &exception) == kJSRelationConditionEqual; + magnitude = 0 - static_cast(s); + } else { + magnitude = JSValueToUInt64(env->context, v, &exception); + fits = JSValueCompareUInt64(env->context, v, magnitude, &exception) == kJSRelationConditionEqual; + } + CHECK_JSC(env, exception); + size_t needed = magnitude == 0 && fits ? 0 : (fits ? 1 : 2); + if (words == nullptr) { + *word_count = needed; + return napi_ok; + } + if (sign_bit) *sign_bit = negative ? 1 : 0; + if (*word_count > 0) words[0] = magnitude; + if (*word_count > 1) words[1] = 0; + *word_count = needed; + return napi_ok; +} + napi_status napi_get_value_bool(napi_env env, napi_value value, bool* result) { CHECK_ENV(env); CHECK_ARG(env, value); diff --git a/packages/windows-jsc/vendor/shim/jsr.cpp b/packages/windows-jsc/vendor/shim/jsr.cpp index d5b66d5..3b1c7b8 100644 --- a/packages/windows-jsc/vendor/shim/jsr.cpp +++ b/packages/windows-jsc/vendor/shim/jsr.cpp @@ -1,5 +1,8 @@ #include "jsr.h" +// Collects now; JSGarbageCollect only hints that a collection would help. +extern "C" JS_EXPORT void JSSynchronousGarbageCollectForDebugging(JSContextRef ctx); + napi_status js_create_runtime(napi_runtime *runtime) { if (!runtime) return napi_invalid_arg; *runtime = (napi_runtime) JSGlobalContextCreateInGroup(nullptr, nullptr); @@ -14,7 +17,7 @@ napi_status js_create_napi_env(napi_env* env, napi_runtime runtime) { napi_value gc; napi_create_function(*env, "gc", strlen("gc"), [](napi_env env, napi_callback_info info) -> napi_value { - JSGarbageCollect(env->context); + JSSynchronousGarbageCollectForDebugging(env->context); napi_value undefined; napi_get_undefined(env, &undefined); return undefined; diff --git a/packages/windows-quickjs/Cargo.lock b/packages/windows-quickjs/Cargo.lock index be92bb2..d775880 100644 --- a/packages/windows-quickjs/Cargo.lock +++ b/packages/windows-quickjs/Cargo.lock @@ -726,11 +726,16 @@ version = "0.1.0" dependencies = [ "ahash", "dyn-clone", + "metadata-filter", "parking_lot", "sha1", "windows", ] +[[package]] +name = "metadata-filter" +version = "0.1.0" + [[package]] name = "mimalloc" version = "0.1.52" diff --git a/packages/windows-quickjs/src/lib.rs b/packages/windows-quickjs/src/lib.rs index 53d88df..74d4dbb 100644 --- a/packages/windows-quickjs/src/lib.rs +++ b/packages/windows-quickjs/src/lib.rs @@ -65,6 +65,13 @@ pub mod shim { file: *const c_char, result: *mut NapiValue, ) -> NapiStatus; + fn qjs_execute_script_cached( + env: NapiEnv, + script: NapiValue, + file: *const c_char, + cache_dir: *const c_char, + result: *mut NapiValue, + ) -> NapiStatus; fn qjs_free_napi_env(env: NapiEnv) -> NapiStatus; fn qjs_free_runtime(rt: NapiRuntime) -> NapiStatus; fn qjs_execute_pending_jobs(env: NapiEnv) -> NapiStatus; @@ -117,7 +124,11 @@ pub mod shim { napi_create_string_utf8(env, c.as_ptr(), NAPI_AUTO_LENGTH, &mut script); let file = CString::new("