File: PortableCallHelpers\PInvokeTableGenerator.cs
Web Access
Project: ILCompiler.ReadyToRun.csproj (ILCompiler.ReadyToRun)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;

using Internal.TypeSystem;
using Internal.TypeSystem.Ecma;

namespace ILCompiler.PortableCallHelpers
{
    /// <summary>
    /// Emits the static P/Invoke resolution table and the native-to-interpreter reverse thunks.
    /// </summary>
    internal sealed class PInvokeTableGenerator(InteropLogger log)
    {
        public void EmitPInvokeTable(TextWriter w, IEnumerable<string> pinvokeModules, List<PInvokeInfo> pinvokes)
        {
            // Modules an unresolved P/Invoke has already been reported for, so each is logged once.
            // Only the logging is suppressed: a module a later P/Invoke does resolve - through
            // [WasmImportLinkage], say - still has to make it into the table.
            var skippedModules = new HashSet<string>(StringComparer.Ordinal);
            var modules = new SortedSet<string>(StringComparer.Ordinal);
            foreach (string module in pinvokeModules)
                modules.Add(module);

            // What actually gets linked in, captured before the scan below starts adding to modules.
            // The lib-prefix fallback has to resolve against this rather than against modules, or an
            // alias could be derived from another alias, or from a module that is only imported for
            // [WasmImportLinkage] and has no archive behind it at all.
            var linkedModules = new HashSet<string>(modules, StringComparer.Ordinal);

            foreach (PInvokeInfo pinvoke in pinvokes)
            {
                if (modules.Contains(pinvoke.Module))
                    continue;

                // A static archive is named libFoo.a, so the module list - built from the file names
                // of what gets linked in - carries "libFoo", while the managed side spells the
                // [DllImport] "Foo", the name it would use on Windows. That is also the name the
                // runtime resolver looks up, so accept it as naming the same module.
                if (linkedModules.Contains($"lib{pinvoke.Module}"))
                {
                    modules.Add(pinvoke.Module);
                    log.Verbose($"Adding module {pinvoke.Module} for statically linked lib{pinvoke.Module}");
                    continue;
                }

                // Handle special modules, and add them to the list of modules otherwise, skip them
                // and throw an exception at runtime if they are called.
                if (pinvoke.WasmLinkage)
                {
                    // WasmLinkage means we need to import the module
                    modules.Add(pinvoke.Module);
                    log.Verbose($"Adding module {pinvoke.Module} for WasmImportLinkage");
                }
                else if (pinvoke.Module == "*")
                {
                    // Special case for * module to indicate static linking without specifying the module
                    modules.Add(pinvoke.Module);
                    log.Verbose($"Adding module {pinvoke.Module} for static linking");
                }
                else if (pinvoke.Module != "QCall")
                {
                    // Unresolved module: not statically linked, [WasmImportLinkage], "*" or QCall.
                    // Skip it and throw at runtime if it is ever called, which is what Mono does too.
                    // Deliberately not a warning: assemblies routinely carry P/Invokes for platforms they
                    // are not running on - a NuGet package with Windows and Linux entry points, say - and
                    // those are never reached on wasm. P/Invoke resolution failure is a runtime condition,
                    // so reporting it at build time produces false positives that have to be suppressed.
                    if (skippedModules.Add(pinvoke.Module))
                        log.Verbose($"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' (not statically linked on wasm; will throw if called).");
                }
            }

            w.WriteLine(
                """
                //
                // GENERATED FILE, DON'T EDIT
                // Generated by coreclr callhelpers generator
                //

                #include <callhelpers.hpp>
                #include <minipal/entrypoints.h>

                extern "C" {
                """);

            var pinvokesGroupedByEntryPoint = pinvokes
                                                .Where(l => modules.Contains(l.Module))
                                                .OrderBy(l => l.EntryPoint, StringComparer.Ordinal)
                                                .GroupBy(CEntryPoint, StringComparer.Ordinal);

            foreach (IGrouping<string, PInvokeInfo> group in pinvokesGroupedByEntryPoint)
            {
                PInvokeInfo[] candidates = group.Distinct().ToArray();
                PInvokeInfo first = candidates[0];
                if (ShouldTreatAsVariadic(candidates))
                {
                    string imports = string.Join(Environment.NewLine,
                                                candidates.Select(
                                                    p => $"    {p.Method} (in [{((EcmaAssembly)p.Method.Module).GetName().Name}] {p.Method.OwningType})"));
                    log.Warning("WASM0001", $"Found a native function ({first.EntryPoint}) with varargs in {first.Module}." +
                                     " Calling such functions is not supported, and will fail at runtime." +
                                    $" Managed DllImports: {Environment.NewLine}{imports}");

                    foreach (PInvokeInfo candidate in candidates)
                        candidate.Skip = true;

                    continue;
                }

                var decls = new HashSet<string>();
                foreach (PInvokeInfo candidate in candidates)
                {
                    string decl = GenPInvokeDecl(candidate);
                    if (decls.Add(decl))
                        w.WriteLine(decl);
                }
            }

            w.Write(
                """
                } // extern "C"

                """);

            var moduleImports = new Dictionary<string, List<string>>();
            foreach (string module in modules)
            {
                // the order here is not important, because we use hash tables, we want it to be stable though
                List<string> imports = pinvokes
                    .Where(l => l.Module == module && !l.Skip)
                    .OrderBy(l => l.EntryPoint, StringComparer.Ordinal)
                    .GroupBy(d => d.EntryPoint, StringComparer.Ordinal)
                    .Select(l =>
                    {
                        PInvokeInfo p = l.First();
                        // Runtime resolver looks up by managed EntryPoint.
                        // [WasmImportLinkage] mangles the C symbol per module,
                        // so emit the entry-point string explicitly rather than
                        // stringifying the mangled name via DllImportEntry.
                        if (p.WasmLinkage)
                            return $"    {{ \"{EscapeLiteral(p.EntryPoint)}\", (void*)&{CEntryPoint(p)} }}, // {ListRefs(l)}{w.NewLine}";
                        return $"    DllImportEntry({CEntryPoint(p)}) // {ListRefs(l)}{w.NewLine}";
                    })
                    .ToList();

                moduleImports[module] = imports;
                w.Write(
                    $$"""

                    static const Entry s_{{FixupSymbolName(module)}} [] = {
                    {{string.Concat(imports)}}};

                    """);
            }

            w.Write(
                $$"""

                typedef struct PInvokeTable {
                    const char* LibraryName;
                    const Entry* Entries;
                    size_t EntryCount;
                } PInvokeTable;

                static PInvokeTable s_PInvokeTables[] = {
                    {{string.Join($",{w.NewLine}    ", modules.Select(m => $"{{\"{EscapeLiteral(m)}\", s_{FixupSymbolName(m)}, {moduleImports[m].Count}}}"))}}
                };
                const size_t s_PInvokeTablesCount = sizeof(s_PInvokeTables) / sizeof(s_PInvokeTables[0]);

                const void* callhelpers_pinvoke_override(const char* library_name, const char* entry_point_name)
                {
                    for (size_t i = 0; i < s_PInvokeTablesCount; i++)
                    {
                        if (strcmp(library_name, s_PInvokeTables[i].LibraryName) == 0)
                        {
                            LOG((LF_INTEROP, LL_INFO1000, "Wasm callhelpers PInvoke override for: lib: %s, entry: %s \n", library_name, entry_point_name));
                            return minipal_resolve_dllimport(s_PInvokeTables[i].Entries, s_PInvokeTables[i].EntryCount, entry_point_name);
                        }
                    }

                    return nullptr;
                }

                """);

            static bool ShouldTreatAsVariadic(PInvokeInfo[] candidates)
            {
                if (candidates.Length < 2)
                    return false;

                // Detect possible vararg entrypoint usage, where the same entrypoint is used with
                // different numbers of arguments.
                int firstNumArgs = candidates[0].Method.Signature.Length;
                for (int i = 1; i < candidates.Length; i++)
                {
                    if (candidates[i].Method.Signature.Length != firstNumArgs)
                        return true;
                }

                return false;
            }

            static string ListRefs(IGrouping<string, PInvokeInfo> l)
                => string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n, StringComparer.Ordinal));
        }

        public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
        {
            // Generate the native->interpreter entry functions. Native code calls these directly, so
            // each one carries the native signature its caller expects, taken from the
            // [UnmanagedCallersOnly] method it wraps. Only blittable parameter and return types are
            // supported.
            //
            // Each wrapper caches the MethodDesc it dispatches to in a static and hands the arguments to
            // ExecuteInterpretedMethodFromUnmanaged. The g_ReverseThunks table emitted at the end maps
            // the runtime's key for a method to its wrapper, and the runtime fills that static in as it
            // hands the wrapper out. An export is not handed out that way, so it resolves the static
            // itself, by name, on the first call.
            w.Write(
                """
                //
                // GENERATED FILE, DON'T EDIT
                // Generated by coreclr callhelpers generator
                //

                #include <callhelpers.hpp>

                // WASM-TODO: The method lookup would ideally be fully qualified assembly and then methodDef token.
                // The current approach has limitations with overloaded methods.
                extern "C" void LookupUnmanagedCallersOnlyMethodByName(const char* fullQualifiedTypeName, const char* methodName, MethodDesc** ppMD);
                extern "C" void ExecuteInterpretedMethodFromUnmanaged(MethodDesc* pMD, int8_t* args, size_t argSize, int8_t* ret, PCODE callerIp);

                """);

            var callbackNames = new HashSet<string>();
            var keys = new HashSet<string>();
            callbacks.Sort(new PInvokeCallbackComparer());
            foreach (PInvokeCallback cb in callbacks)
            {
                cb.EntrySymbol = FixedSymbolName(cb);

                if (!callbackNames.Add(cb.EntrySymbol))
                    throw new LogAsErrorException($"Two callbacks with the same symbol '{cb.EntrySymbol}' are not supported.");

                if (!keys.Add(cb.Key))
                    throw new LogAsErrorException($"Two callbacks with the same Name and number of arguments '{cb.Key}' are not supported.");

                // That check only catches overloads of the same arity, which collide outright. Different
                // arities produce distinct keys and distinct symbols, yet the export wrapper resolves its
                // MethodDesc through LookupUnmanagedCallersOnlyMethodByName, which matches on the
                // declaring type and the method name alone. Overloads are indistinguishable to it, so an
                // export sharing its name with another callback would be handed whichever MethodDesc the
                // walk reached first and would then call it with its own arguments.
                //
                // This is a stopgap for a lookup that cannot express what it means to ask. If the runtime
                // ever resolves these unambiguously, this rejection should go away with it.
                if (cb.IsExport)
                    RejectAmbiguousExport(cb);

                int parameterCount = cb.Parameters.Length;
                string argsArgs = parameterCount > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0";
                string[] parameterCTypes = ParameterTypes(cb.Parameters).Select(MapType).ToArray();
                // A cast converts a float numerically, while the slot has to carry its bits, so copy
                // those instead. Every other type this emits reaches the slot unchanged through a cast.
                bool CarriesBits(int i) => parameterCTypes[i] is "float" or "double";
                string argsDeclaration = parameterCount > 0
                    ? $"\n    int64_t args[{parameterCount}] = {{ {string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => CarriesBits(i) ? "0" : $"(int64_t)arg{i}"))} }};\n"
                      + string.Concat(Enumerable.Range(0, parameterCount).Where(CarriesBits).Select(i => $"    memcpy(&args[{i}], &arg{i}, sizeof(arg{i}));\n"))
                    : string.Empty;
                string parametersDeclaration = string.Join(", ", parameterCTypes.Select((p, i) => $"{p} arg{i}"));
                string arguments = string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"arg{i}"));
                string exportFunction = cb.IsExport ?
                    $$"""


                    extern "C" {{MapType(cb.ReturnType)}} {{cb.EntryPoint}}({{parametersDeclaration}})
                    {
                        {{(cb.IsVoid ? "" : "return ")}}Call_{{cb.EntrySymbol}}({{arguments}});
                    }
                    """ : string.Empty;
                w.Write(
                    $$"""

                    static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr;
                    static {{
                    MapType(cb.ReturnType)}} Call_{{cb.EntrySymbol}}({{parametersDeclaration}})
                    {{{argsDeclaration}}
                        // Lazy lookup of MethodDesc for the function export scenario.
                        if (!MD_{{cb.EntrySymbol}})
                        {
                            LookupUnmanagedCallersOnlyMethodByName("{{cb.TypeFullName}}, {{cb.AssemblyName}}", "{{cb.MethodName}}", &MD_{{cb.EntrySymbol}});
                        }{{
                        (!cb.IsVoid ? $"{w.NewLine}{w.NewLine}    {MapType(cb.ReturnType)} result;" : "")}}
                        ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{
                        (!cb.IsVoid ? $"{w.NewLine}    return result;" : "")}}
                    }{{exportFunction}}

                    """);
            }

            w.Write(
                $$"""

                const ReverseThunkMapEntry g_ReverseThunks[] =
                {
                {{string.Join($",{w.NewLine}", callbacks.Select(ThunkMapEntryLine))}}
                };

                const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]);

                """);

            // The runtime walks every [UnmanagedCallersOnly] method the type declares, so match that.
            static void RejectAmbiguousExport(PInvokeCallback cb)
            {
                List<string> ambiguous = [];
                foreach (MethodDesc candidate in cb.Method.OwningType.GetMethods())
                {
                    if (candidate != cb.Method
                        && candidate.Name.StringEquals(cb.MethodName)
                        && candidate.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute"))
                    {
                        ambiguous.Add(candidate.ToString());
                    }
                }

                if (ambiguous.Count == 0)
                    return;

                ambiguous.Add(cb.Method.ToString());
                ambiguous.Sort(StringComparer.Ordinal);

                throw new LogAsErrorException(
                    $"Exported callback '{cb.EntryPoint}' cannot be resolved at run time: '{cb.TypeFullName}' declares more than one [UnmanagedCallersOnly] method named '{cb.MethodName}', and the runtime looks them up by name alone. Give them distinct names: {string.Join(", ", ambiguous)}");
            }
        }

        private string CEntryPoint(PInvokeInfo pinvoke)
        {
            if (pinvoke.WasmLinkage)
            {
                // We mangle the name to avoid collisions with symbols in other modules
                string namespaceName = TypeNames.GetNamespace(pinvoke.Method.OwningType);
                return FixupSymbolName($"{namespaceName}#{pinvoke.Module}#{pinvoke.EntryPoint}");
            }

            return FixupSymbolName(pinvoke.EntryPoint);
        }

        private string GenPInvokeDecl(PInvokeInfo pinvoke)
        {
            MethodSignature signature = pinvoke.Method.Signature;
            TypeDesc returnType = signature.ReturnType;
            List<string> parameterTypes = [];
            foreach (TypeDesc parameter in ParameterTypes(signature))
                parameterTypes.Add(MapType(parameter));

            if (IsPassedByReference(returnType))
            {
                returnType = pinvoke.Method.Context.GetWellKnownType(WellKnownType.Void);
                parameterTypes.Insert(0, "void *");
            }

            string importAttributes = pinvoke.WasmLinkage
                ? $"__attribute__((import_module(\"{EscapeLiteral(pinvoke.Module)}\"),import_name(\"{EscapeLiteral(pinvoke.EntryPoint)}\"))) "
                : "";
            string externKeyword = pinvoke.WasmLinkage ? "extern " : "";

            return $"    {importAttributes}{externKeyword}{MapType(returnType)} {CEntryPoint(pinvoke)} ({string.Join(", ", parameterTypes)});";
        }

        private string FixedSymbolName(PInvokeCallback cb)
        {
            string paramTypes = cb.Parameters.Length > 0
                ? string.Join("_", ParameterTypes(cb.Parameters).Select(TypeToNameType))
                : "Void";

            return FixupSymbolName($"{cb.EntryName}_{paramTypes}_Ret{TypeToNameType(cb.ReturnType)}");
        }

        private string ThunkMapEntryLine(PInvokeCallback cb)
            => $"    {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{FixedSymbolName(cb)}, (void*)&Call_{cb.EntrySymbol} }} }}";

        /// <summary>
        /// Whether the wasm ABI moves a struct by reference instead of as a bare value. Padding,
        /// several fields, or a type too wide for one slot all force the hidden-pointer form, so the
        /// C declaration has to say <c>void *</c> rather than unwrap to the field's type.
        /// </summary>
        private static bool IsPassedByReference(TypeDesc type)
        {
            if (!type.IsValueType || type.IsPrimitive || type.IsEnum || type is FunctionPointerType)
                return false;

            return InteropSignature.GetAbiToken(type)[0] is 'S' or 'A';
        }

        private static string TypeToNameType(TypeDesc type)
        {
            if (!type.IsValueType || type.IsPointer || type.IsByRef || type is FunctionPointerType)
                return "I32";

            if (type.IsEnum)
                return TypeToNameType(type.UnderlyingType);

            return InteropSignature.TokenToNameType(InteropSignature.GetAbiToken(type));
        }

        private static string MapType(TypeDesc type) => type.Category switch
        {
            TypeFlags.Void => "void",
            TypeFlags.Double => "double",
            TypeFlags.Single => "float",
            TypeFlags.Int64 => "int64_t",
            TypeFlags.UInt64 => "uint64_t",
            TypeFlags.Int32 or TypeFlags.Int16 or TypeFlags.Char or TypeFlags.Boolean or TypeFlags.SByte => "int32_t",
            TypeFlags.UInt32 or TypeFlags.UInt16 or TypeFlags.Byte => "uint32_t",
            TypeFlags.IntPtr or TypeFlags.UIntPtr => "void *",
            _ => PickCTypeNameForUnknownType(type)
        };

        private static string PickCTypeNameForUnknownType(TypeDesc type)
        {
            // Pass objects by-reference (their address by-value), and pointers and function pointers
            // by-value.
            if (!type.IsValueType || type.IsPointer || type is FunctionPointerType)
                return "void *";

            if (type.IsEnum)
                return MapType(type.UnderlyingType);

            // The wasm C ABI hands a struct over as a bare scalar only when it recursively contains a
            // single scalar that fills it. Padding or extra fields make it travel by reference, so ask
            // the same lowering the runtime encodes into the signature instead of unwrapping blindly:
            // otherwise a `[StructLayout(Size = 16)] struct { long V; }` parameter is declared int64_t
            // while the caller passes a pointer.
            if (IsPassedByReference(type))
                return "void *";

            // https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md#function-signatures
            // Any struct or union that recursively (including through nested structs, unions, and arrays)
            //  contains just a single scalar value and is not specified to have greater than natural alignment.
            // FIXME: Handle the scenario where there are fields of struct types that contain no members
            FieldDesc singleField = null;
            foreach (FieldDesc field in ((MetadataType)type).GetFields())
            {
                if (field.IsStatic)
                    continue;

                if (singleField is not null)
                    return "void *";

                singleField = field;
            }

            return singleField is not null ? MapType(singleField.FieldType) : "void *";
        }

        private static readonly char[] s_charsToReplace = ['.', '-', '+', '<', '>'];

        /// <summary><see cref="MethodSignature"/> is indexable but not enumerable; this makes it LINQ-friendly.</summary>
        private static IEnumerable<TypeDesc> ParameterTypes(MethodSignature signature)
        {
            for (int i = 0; i < signature.Length; i++)
                yield return signature[i];
        }

        /// <summary>
        /// Rewrites a name into something that can be used as a C identifier, reversibly enough that
        /// two different names cannot collide.
        /// </summary>
        private static string FixupSymbolName(string name)
        {
            var sb = new StringBuilder();
            foreach (byte b in Encoding.UTF8.GetBytes(name))
            {
                if (b is (>= (byte)'0' and <= (byte)'9') or (>= (byte)'a' and <= (byte)'z') or (>= (byte)'A' and <= (byte)'Z') or (byte)'_')
                    sb.Append((char)b);
                else if (Array.IndexOf(s_charsToReplace, (char)b) >= 0)
                    sb.Append('_');
                else
                    sb.Append(CultureInfo.InvariantCulture, $"_{b:X}_");
            }

            return sb.ToString();
        }

        private static string EscapeLiteral(string input)
        {
            if (input is null)
                return string.Empty;

            var sb = new StringBuilder();
            for (int i = 0; i < input.Length; i++)
            {
                char c = input[i];

                sb.Append(c switch
                {
                    '\\' => "\\\\",
                    '\"' => "\\\"",
                    '\n' => "\\n",
                    '\r' => "\\r",
                    '\t' => "\\t",
                    // take special care with surrogate pairs to avoid
                    // potential decoding issues in generated C literals
                    _ when char.IsHighSurrogate(c) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1])
                        => $"\\U{char.ConvertToUtf32(c, input[++i]):X8}",
                    _ when char.IsControl(c) || c > 127
                        => $"\\u{(int)c:X4}",
                    _ => c.ToString()
                });
            }

            return sb.ToString();
        }

        /// <summary>
        /// Equivalent to <c>ULONG HashString(LPCWSTR szStr)</c> in the CoreCLR runtime,
        /// src/coreclr/inc/utilcode.h.
        /// </summary>
        private static uint HashString(string str)
        {
            uint hash = 5381;
            foreach (char c in str)
                hash = ((hash << 5) + hash) ^ c;

            return hash;
        }
    }
}