dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.09k stars 4.7k forks source link

[MONO][Interp][Wasm] RangeError: Maximum call stack size exceeded #99398

Closed Nemo-G closed 7 months ago

Nemo-G commented 7 months ago

Description

When targetting WASM without AOT, there is a potential cycle in loading assemblies. Please check the following stacktrace image

I changed MONO_LOG_LEVEL to debug and found that it infinitely tried to load a non-existing Module DOTween.Module image

Digging into the code, I found that in https://github.com/dotnet/runtime/blob/4e86b1c63d9c41c6bfb6f42710be907199ce2671/src/mono/mono/metadata/appdomain.c#L806 mono_assembly_request_byname will recursively try to load module DOTween.Modules until Maximum call stack size exceeded

Reproduction Steps

This is part of a Unity project, and the dll currently loading is DOTween.dll. I deassembled it and found the problematic code is

namespace DG.Tweening.Core
{
  /// <summary>Various utils</summary>
  public static class DOTweenUtils
  {
    private static Assembly[] _loadedAssemblies;
    private static readonly string[] _defAssembliesToQuery = new string[3]
    {
      "DOTween.Modules",
      "Assembly-CSharp",
      "Assembly-CSharp-firstpass"
    };

/* Ignoring the parts we don't care */

    /// <summary>
    /// Looks for the type within all possible project assembly names
    /// </summary>
    internal static Type GetLooseScriptType(string typeName)
    {
      for (int index = 0; index < DOTweenUtils._defAssembliesToQuery.Length; ++index)
      {
        Type type = Type.GetType(string.Format("{0}, {1}", (object) typeName, (object) DOTweenUtils._defAssembliesToQuery[index]));
        if (type != null)
          return type;
      }
      if (DOTweenUtils._loadedAssemblies == null)
        DOTweenUtils._loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
      for (int index = 0; index < DOTweenUtils._loadedAssemblies.Length; ++index)
      {
        Type type = Type.GetType(string.Format("{0}, {1}", (object) typeName, (object) DOTweenUtils._loadedAssemblies[index].GetName()));
        if (type != null)
          return type;
      }
      return (Type) null;
    }
  }
}

Expected behavior

Report error msg but continue execution

Actual behavior

RangeError: Maximum call stack size exceeded and terminate

Regression?

No

Known Workarounds

In single thread situation, a requesting assembly table like static char s_assemblies_requesting[MAX_ASSEMBLY_LOAD_DEPTH][MAX_ASSEMBLY_NAME_LENGTH] can be set to check if there is a circular request. And goto fail block when detected or reaching MAX_ASSEMBLY_LOAD_DEPTH.

Configuration

.NET8 and .NET9 alpha .\build.cmd -os browser -subset mono+libs It should be browser agnostic.

Other information

DOTween is a Unity plugin and in one of the version it may generate DOTween.Modules.dll and in other versions there is no such module generated.

vargaz commented 7 months ago

Does the app have some kind of c# assembly loader callback installed ?

Nemo-G commented 7 months ago

@vargaz I think not, the app is quite simple. What exact function or keyword shall I search for a loader cb? BTW here is a more detailed callstack image

And I tried to generate DOTween.Modules.dll and once it is found everything works fine.

The caller looks like

        Type looseScriptType = DOTweenUtils.GetLooseScriptType("DG.Tweening.DOTweenModuleUtils");
        if (looseScriptType == null)
          Debugger.LogError((object) "Couldn't load Modules system");
        else
          looseScriptType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public).Invoke((object) null, (object[]) null);
vargaz commented 7 months ago

The runtime calls AssemblyLoadContext.OnAssemblyResolve when it cannot find an assembly, which looks like this:

        // This method is called by the VM.
        private static RuntimeAssembly? OnAssemblyResolve(RuntimeAssembly? assembly, string assemblyFullName)
        {
            return InvokeResolveEvent(AssemblyResolve, assembly, assemblyFullName);
        }

So its calling the internal AssemblyResolve event, something is setting that.

Nemo-G commented 7 months ago

I found a usage of this in engine code, could it explain the infinite loading requests?

    /// <summary>Occurs when the resolution of an assembly fails.</summary>
    public event ResolveEventHandler AssemblyResolve;

  static void InitAssemblyRedirections()
        {
            AppDomain.CurrentDomain.AssemblyResolve += (object _, ResolveEventArgs args) =>
            {
                var sourceAsmName = new AssemblyName(args.Name);
                try
                {
                    var loadedAssembly = AppDomain.CurrentDomain.Load(sourceAsmName.Name);
                    return loadedAssembly;
                }
                catch
                {
                    // Default if we fail to load for any reason
                    return null;
                }
            };
        }
vargaz commented 7 months ago

Yes, its added to AssemblyLoadContext.AssemblyResolve:

        public event ResolveEventHandler? AssemblyResolve
        {
            add { AssemblyLoadContext.AssemblyResolve += value; }
            remove { AssemblyLoadContext.AssemblyResolve -= value; }
        }
Nemo-G commented 7 months ago

Kk. Except for ves_icall_System_Reflection_Assembly_InternalLoad, is there any system icall which can trigger such infinite loading? What can be the best way to bypass such case? Does the workaround make sense?

vargaz commented 7 months ago

The c# code needs to make sure it cannot be called recursively.