Skip to content

Hosted Script Execution

Oleg Shilo edited this page Dec 7, 2023 · 23 revisions

Further reading:

Hosted script execution

CS-Script can be hosted by any CLR application. The best way to bring scripting in your application is to add the corresponding NuGet package to your Visual Studio project :

Install-Package CS-Script

The package contains CSScriptLib.dll assembly, which is compiled for .NET Standard 2.0 target framework. Meaning it can be hosted on both .NET Framework and .NET Core CLR.

It is highly recommended that you analyze the samples first as they demonstrate the indented use:


Setting up Evaluator

Any application hosting CS-Script engine can execute C# code containing either fully defined types or code fragments (e.g. methods). It is important to note that CS-Script is neither a compiler nor an interpreter/evaluator. It is rather a runtime environment that relies on the code compilation by the standard compiling toolset available with any .NET installation. The first compiling services CS-Script integrated was CodeDom. It is the very original compiler-as-service that was available starting from the first release of .NET Framework.

While many may not consider CodeDom as 'true compiler-as-service', it actually is. The problem with CodeDom API is that it's inconvenient in require a great deal of manual runtime configuration. And it is exactly what CS-Script is addressing.

Later, some alternative proprietary compiler-as-service solutions became available. First Mono Evaluator and then Roslyn. Both demonstrated a completely different approach and understanding of what a good scripting API should look like.

Thus, all three scripting platforms were inconsistent with respect to each other and only partially overlapping functionality wise. And this is where CS-Script comes to the rescue. It provides one unified generic interface transparent to the underlying compiler technology.

With the latest development in the .NET ecosystem, CodeDom and Mono has been discontinued. Thus, CS-Script implements its own equivalent of CodeDom (read more).

You can have your script code executed by either of three supported compilers without affecting your hosting code.

The code below demonstrates creating a scripted method.

dynamic script = CSScript.Evaluator
                         .LoadMethod(@"int Multiply(int a, int b)
                                       {
                                           return a * b;
                                       }");

int result = script.Multiply(3, 2);

By default, CSScript.Evaluator references Roslyn compiler. However, you can change this behaviour globally by re-configuring the default compiler:

CSScript.EvaluatorConfig.Engine = EvaluatorEngine.Roslyn;
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.CodeDom;

Alternatively, you can access the corresponding compiler via a dedicated member property:

CSScript.RoslynEvaluator.LoadMethod(...
CSScript.CodeDomEvaluator.LoadMethod(...

You may want to choose one or another engine depending on the scripting scenario. In most of the cases, Roslyn engine (default) is a good choice as it does not require any other dependencies on the hosting environment except .NET 5 runtime. Whereas CodeDom is more flexible but requires .NET SDK (read more).

Every time a CSScript.*Evaluator property is accessed a new instance of the corresponding evaluator is created and returned to the caller. Though this can be changed by re-configuring the evaluator access type to return the reference to the global evaluator object:

CSScript.EvaluatorConfig.Access = EvaluatorAccess.Singleton;

Debugging

If you want to debug your script you will need to indicate that scripts need to be compiled with debug info available. You can do it via EvaluatorConfig.DebugBuild:

CSScript.EvaluatorConfig.DebugBuild = true;

dynamic script = CSScript.RoslynEvaluator
                         .LoadMethod(@"public (int, int) func()
                                       {
                                           return (0,5);
                                       }");

(int, int) result = script.func(); // put the breakpoint at the start of this line and when it breaks, you just "step in" (e.g. F11).

Executing scripts

Creating objects

The evaluator allows executing code containing the definition of a type (or multiple types):

Assembly printer = CSScript.Evaluator
                           .LoadCode(@"using System;
                                       public class Printer
                                       {
                                           public void Print() =>
                                               Console.WriteLine(""Printing..."");
                                       }");

Alternatively, you can compile code containing only method(s). In this case, Evaluator will wrap the method code into a class definition (with class name DynamicClass):

Assembly calc = CSScript.Evaluator
                        .CompileMethod(@"int Multiply(int a, int b)
                                         {
                                             return a * b;
                                         }");

Using raw script assembly is possible (e.g. via Reflection) but not very convenient. Thus, access to the script members can be simplified by using Evaluator.Load*, which compiles the code and returns the instance of the first class in the compiled assembly (for almost every Compile*() there is an equivalent Load*()):

dynamic calc = CSScript.Evaluator
                       .LoadMethod(@"int Multiply(int a, int b)
                                     {
                                         return a * b;
                                     }");

int result = calc.Multiply(3, 2);

Note that in the code above the method Multiply is invoked with 'dynamic' keyword, meaning that the host application cannot do any compile-time checking in the host code. In many cases it is OK, though sometimes it is desirable that the script object is strongly typed. The easiest way to achieve this is to use interfaces:

public interface ICalc
{
    int Add(int a, int b);
}
...
ICalc script = (ICalc)CSScript.Evaluator
                              .LoadCode(@"using System;
                                          public class Script : ICalc
                                          {
                                              public int Add(int a, int b)
                                              {
                                                  return a + b;
                                              }
                                          }");
int result = script.Add(1, 2);

Note that you can also use an interface alignment (duck-typing) technique, which allows 'aligning' the script object to the interface even if the script defines only the interface methods but not the whole class. It is achieved by the evaluator wrapping the script object into a dynamically generated proxy of the interface (e.g. ICalc) type:

ICalc calc = new_evaluator.LoadMethod<ICalc>("int Add(int a, int b) => a + b;");
var result = calc.Add(7, 3);

When it comes to the execution of simple C# expressions then Eval is arguably the best approach:

int sum = CSScript.Evaluator.Eval("6 + 3");

Though you can use this method even for more comprehensive scenarios:

var calc = CSScript.Evaluator
                   .Eval(@"using System;
                           public class Script
                           {
                               public int Sum(int a, int b)
                               {
                                   return a+b;
                               }
                           }
                           return new Script();");

int sum = calc.Sum(1, 2);

Note The v4.8.3 release supports scripting from applications published with PublishSingleFile (Roslyn defect #50719). Thanks to workaround described here CS-Script provides a transparent way for supporting this deployment scenario. Thus the following code will work just fine regardless of how you built/published your application:

The complete sample can be found here.

Creating delegates

Evaluator also allows compiling method scripts into class-less delegates:

 var add = new_evaluator.CreateDelegate(@"int Add(int a, int b)
                                              => a + b;");

 int result = (int)add(7, 3);

In the code above CreateDelegate returns MethodDelegate<T>, which is semi-dynamic by nature. It is strongly typed by a return type and dynamically typed (thanks to 'params') by method parameters:

public delegate T MethodDelegate<T>(params object[] parameters);

Note, when using CreateDelegate, CLR cannot distinguish between arguments of type params object[] and any other array (e.g. string[]). You may need to do one extra step to pass array args. You will need to wrap them as an object array:

var getFirst = CSScript.Evaluator
                       .CreateDelegate<string>(@"string GetFirst(string[] values)
                                                 {
                                                     return values[0];
                                                 }");

string[] values = "aa,bb,cc".Split(',');

// cannot pass values directly
string first = getFirst(new object[] { values });

Though if a strongly typed delegate is preferred then you can use LoadDelegate instead:

Func<string[], string> getFirst = CSScript.Evaluator
                                       .LoadDelegate<Func<string[], string>>(
                                               @"string GetFirst(string[] values)
                                                 {
                                                     return values[0];
                                                 }");
                                                 
string[] values = "aa,bb,cc".Split(',');

string result = getFirst(values);
Referencing assemblies

The script can automatically access all types of the host application without any restrictions but according to the type's visibility (public vs. private). Thus, the evaluator references (by default) all loaded assemblies of the current AppDomain. Meaning that from the script you can access all the assemblies of the current AppDomain of your host application.

Additionally, you can also reference any custom assembly:

IEvaluator evaluator = CSScript.Evaluator;
string code = "<some C# code>"; // IE ICalc implementation

evaluator.Reset(false); // clear all ref assemblies

dynamic script = evaluator
    .ReferenceAssembliesFromCode(code)
    .ReferenceAssembly(Assembly.GetExecutingAssembly())
    .ReferenceAssembly(Assembly.GetExecutingAssembly().Location)
    .ReferenceAssemblyByName("System")
    .ReferenceAssemblyByNamespace("System.Xml")
    .TryReferenceAssemblyByNamespace("Fake.Namespace", out var resolved)
    .ReferenceAssemblyOf(this)
    .ReferenceAssemblyOf<XDocument>()
    .ReferenceDomainAssemblies()
    .LoadCode<ICalc>(code);

int result = script.Add(13, 2);
Referencing other dependencies

While you can reference the assemblies from the hosting code, an alternative mechanism for referencing dependencies from the script itself is also available. This method is the only dependency definition mechanism available for CS-Script CLI execution since there is no hosting code.

This referencing approach is based on the CS-Script special in-script directives //css_*. These directives are placed directly in the code and allow:

  • Referencing assemblies with //css_reference
  • Importing other scripts with //css_include
    This directive is a lighter version of //css_import. //css_include is a prefered option on most of the cases as you only need to use //css_import if you are importing a script that already has static main colliding with the static main of your primary script in CLI scenarios.
    While CodeDom engine supports //css_include fully, Roslyn engine does it with limitations. Thus, Roslyn API does not allow multi-module scripting. Meaning that when importing the scripts, CS-Script simply merges imported scripts with the primary script into a single combined script. And this in turn can potentially lead to C# syntax errors (e.g. namespaces collisions).

Script unloading - avoiding memory leaks

Prior .NET Core 3.0, CLR had a fundamental by-design constraint/flaw that was preventing loaded assemblies from being unloaded. This in turn had a dramatic usability impact on scripting, as the script being executed must be loaded in a caller AppDomain as an assembly.

Thus, once the script (as in fact any assembly) is loaded it cannot be unloaded. This leads to the situation when the AppDomain is stuffed with abandoned assemblies and a new one is added every single time a new script is executed. This behaviour is a well-known design flaw in the CLR architecture. It has been reported, acknowledged by Microsoft as a problem and ... eventually dismissed as "hard to fix". Instead of fixing it, MS offered a workaround - "a dynamically loaded assembly should be loaded into remote temporary AppDomain, which can be unloaded after the assembly routine execution".
A convenient script unloading based on "Remote AppDomain" workaround was available in CS-Script from the first release - class AsmHelper. Though it's no longer distributed with CS-Script as "Remote AppDomain" workaround is no longer recommended due to the native CLR support for assembly unloading in the latest .NET Core releases. Though if you are interested, you can find it here.

Eventually, after 17 years of delay the assembly unloading became available with the introduction of the AssemblyLoadingContext.IsCollectible. CS-Script offers convenient extension method Assembly.Unload() for that:

dynamic script = CSScript.Evaluator
                         .With(eval => eval.IsAssemblyUnloadingEnabled = true)
                         .LoadMethod(@"public object Func()
                                       {
                                           return new[] {0,5};
                                       }");

var result = script.Func();

var scriptAssemblyType = (Type)script.GetType();

scriptAssemblyType.Assembly.Unload();

Note, that assembly unloading needs to be enabled first by setting IEvaluator.IsAssemblyUnloadingEnabled = true. It is disabled by default because this .NET feature comes with some limitations. One of them is that collectible (unloadable) assemblies cannot be referenced from any dynamically loaded assembly (e.g. scripts). Due to this limitation, even though minor, you should enable unloading only if it is truly required.

Tips and Hints
  • When running on Windows you may need to initiate your host application to the specific Apartment Threading Model (STA vs. MTA). This is needed for example when you display a file selection dialogue from your script. Since Apartment Thread cannot be changed after the process started, you have to make the decision about Apartment threading when you compile your hos application (not the script) and place STAThread/MTAThreadattribute in your static main of the host application.

  • When it comes to the interaction between the script and the host application it makes sense to consider it as the interaction of an application and assemblies it loads at runtime. Thus all public types of the host application are visible from the referenced assemblies. You can still pass even the types that are unknown to this assembly but in this case, the type needs to be "downgraded" to a more generic one (e.g. object).

  • Another important point is that all the limitations of the CLR are still to be dealt with when you are implementing scripted scenarios. Thus assembly unloading or memory leaks triggered by the .NET default event notification implementation will need to be addressed one or another way (see an interesting discussion on the topic here).

  • Error handling can be tricky with scripting. The errors that are the result of the script business logic are trivial to handle with a canonical exception handling technique. But the errors that are the result of the script definition mistake (e.g. syntax errors) require extra attention. Thus any script syntax error will throw a special type of exception - CompilerException:


Conclusion

This article briefly described the hosting API but you will find more samples here.

The hosting API itself is made the compiler transparent so in terms of development effort, it doesn't matter which compiler you use. However, it doesn't mean that both compilers are offering the same functionality. The next article Choosing Compiler Engine will help you to choose the compiler most suitable for your task.