oleg-shilo / cs-script

C# scripting platform
http://www.cs-script.net
MIT License
1.57k stars 234 forks source link

Error in Eval using a referenced assembly with unloading enabled. #355

Open MUN1Z opened 7 months ago

MUN1Z commented 7 months ago

Hello @oleg-shilo , i am trying reload my script files. I have a assembly loaded called Libs, all my scripts uses this Libs with reference in Roslyn Eval method.

image

Could not load file or assembly 'ℛ*1240c384-fa14-4563-9208-e649e77e3a95#1-0, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'. The system cannot find the specified file.

The Eval method do not find the referenced assembly before addition of "IsAssemblyUnloadingEnabled" flag in CompileCode method.

If i remove the flag, the Eval find the assembly and works fine, but i cannot unload the asm assembly:

image

image

MUN1Z commented 7 months ago

This is a example project to reproduce the issue: https://github.com/MUN1Z/CsSrcipt.unload.issue

oleg-shilo commented 7 months ago

This is a very interesting problem and it's not an easy task to solve...but possible. I have provided the code sample for that.

Anyway, let's try.

Though, in your first code snippet you are preparing for unloading the referenced assembly asm, what kinda doesn't make sense as you indicated you want to reference it from multiple eval-scrips.

Next, if you remove IsAssemblyUnloadingEnabled from your shared script then you are fine and your code will run and you will be able to unload.

Though with the very current API you cannot unload assembly if your eval-script does not return anything. Thus the API needs to change to allow that. I have done the change (https://github.com/oleg-shilo/cs-script/commit/af55aabd60c1502f9466ee062f062faa9dd8c88c) and it will be available in the very next release. For your convenience I have provided the extension method for that so you do not have to wait.

Next, you need to be careful with eval type of scenarios. Every time an instance of the tipe from a give assembly typecasted to dynamic it becomes un-unloadable. It is either a bug or a feature of CLR. But we cannot change it. Ironically avoiding dynamic and relying on interfaces arguably is a better approach anyway. EvalAndUnload also takes care of that.

Note in the code sample below I had to call GC.Collect explicitly and deliberatly place it outside of the host-call method. Just to demonstrate the fact that the eval-script is unloaded.

And below is the complete working sample that shows the use of the shared script form the unloadable eval-script:

I do recommend avoid using eval and using LoadMethod instead. It's more manageable (useDelegate option).

using System.Reflection;
using Microsoft.CodeAnalysis;
using CSScripting;
using CSScriptLib;

namespace ConsoleApp1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var useDelegate = false;

            for (int i = 0; i < 20; i++)
            {
                Console.WriteLine("Loaded assemblies count: " + AppDomain.CurrentDomain.GetAssemblies().Count());

                call_UnloadAssembly(useDelegate);

                GC.Collect();
            }
        }

        static Assembly printer_asm;

        static void call_UnloadAssembly(bool useDelegate)
        {
            var info = new CompileInfo { RootClass = "Printing", AssemblyFile = "Printer.dll" };

            if (printer_asm == null)
                printer_asm = CSScript.Evaluator
                                      .CompileCode(@"using System;
                                                     using System.Diagnostics;
                                                     public class Printer
                                                     {
                                                         public static void Print() =>
                                                             Console.WriteLine(""Printing..."");
                                                     }", info);
            if (useDelegate)
            {
                var script = CSScript.Evaluator
                                     .With(eval => eval.IsAssemblyUnloadingEnabled = true)
                                     .ReferenceAssembly(printer_asm)
                                     .LoadMethod<ICalc>(@"public int Sum(int a, int b)
                                                          {
                                                              Printing.Printer.Print();
                                                              return a+b;
                                                          }");
                script.Sum(1, 2);
                script.GetType().Assembly.Unload();
            }
            else
            {
                var result = CSScript.Evaluator.With(eval => eval.IsAssemblyUnloadingEnabled = true)
                                     .ReferenceAssembly(printer_asm)
                                     .EvalAndUnload("Printing.Printer.Print();");
            }
        }
    }

    public interface ICalc
    {
        int Sum(int a, int b);
    }
}

static class Extension
{
    public static object EvalAndUnload(this IEvaluator evaluator, string scriptText, bool unloadableDependency = true)
    {
        var info = new CompileInfo { CodeKind = SourceCodeKind.Script };
        var asm = evaluator.CompileCode(scriptText, info);

        // note that the leading dot is missing "css_Root" vs ".css_Root"; this is required for asms compiled with inloadable context
        var entryPointType = asm.GetType($"{info.RootClass}", true, false);

        var entryPointMethod = entryPointType?.GetTypeInfo().GetDeclaredMethod("<Factory>") ?? throw new InvalidOperationException("Script entry point method could be found.");

        var submissionFactory = (Func<object[], Task<object>>)entryPointMethod.CreateDelegate(typeof(Func<object[], Task<object>>));

        var result = submissionFactory.Invoke(new object[] { null, null }).Result;

        asm.Unload();

        return result;
    }
}

image

MUN1Z commented 7 months ago

I understand, I'm not at home right now but in a little while I'll run the example you sent, thank you very much @oleg-shilo .

Regarding the use of Eval, it was the closest I could get to simulating the behavior of lua scripts that I use on Tibia servers.

I need the developer who will create the scripts to be free to create and call objects, variables, methods, create his own business rule in that script and override one or two delegates to in the end return the script instance to be stored and called when some player performs a certain action.

but I will analyze the use of the load method calmly, it may be possible to change it if I change some behaviors in the scripts.

I'm going to leave an example of the pattern that is used in lua and as I'm recreating it in eval, I want to make it very similar so I can convert these lua scripts to csx automatically in the future.

image

Regarding reloading scripts, they will be executed when a player with permission needs to speak a specific command in the game, for example /reload scripts, or via terminal on the server, execute the same command.

MUN1Z commented 7 months ago

@oleg-shilo I tested it now, so the same problem still occurs, the problem occurs when you put "IsAssemblyUnloadingEnabled = true" in the printer assembly and reference the assembly in Eval.

In my case I need to reload both the assemblies created from scripts and the scripts that only use those assembly.

image

image

oleg-shilo commented 7 months ago

Correct. That's why I did not marked the shared assembly for unloading.

Though, in your first code snippet you are preparing for unloading the referenced assembly asm, what kinda doesn't make sense as you indicated you want to reference it from multiple eval-scrips.

That was an architectural reason but it also served an additional purpose: Roslyn is not able to reference assemblies that are loaded with the "collectable" context.

Thus you may want to consider not unloading the assemblies at all or not unloading the shared scripts/assemblies. After all it might not be such a pressure on your memory as it might seem. But it only you can answer this question.

BTW, it's only CS-Script who is trying to do something about unloading. Roslyn completely expects you to do eval and forget about it. Completely ignoring the fact that a tiny assembly has been created. And before .NETCore MS did not even provide a mechanism for unloading at all.

You can also consider an early days technique that CS-Script implemented when true unloading was not available. You cna group your eval jobs in to batches hosted in a dedicated AppDomain. And when the job is doen then just unload the domain with all these eval-assemblies in it.

I have already captures some of those considerations here