dotnet / fsharp

The F# compiler, F# core library, F# language service, and F# tooling integration for Visual Studio
https://dotnet.microsoft.com/languages/fsharp
MIT License
3.91k stars 785 forks source link

Compiler error Constructor on type 'FSharp.DependencyManager.Nuget.FSharpDependencyManager' not found. #13923

Open roboz0r opened 2 years ago

roboz0r commented 2 years ago

I am building a tool to enable F# scripting within a native windows application. I've run into an issue with the ability to use #r nuget directives.

I have two different ways I am calling the compiler against the script files:

  1. Producing a dynamic assembly

    let tryCompile scriptPath = 
        let compilerArgs (scriptPath:string) = 
            [|
                "-a"; scriptPath
                "--targetprofile:netcore"
                "--target:module"
            |]
        async {
            let compilerArgs = compilerArgs scriptPath
            let! errors, retCode, maybeAssembly = 
                checker.CompileToDynamicAssembly(compilerArgs, None)
    
            return
                match maybeAssembly with
                | Some a -> 
                    Ok (a, errors)
                | None -> 
                    Error (retCode, scriptPath, errors)
        }
  2. Producing a dll (to enable debugging)

    let private compilerArgs (scriptPath:string) = 
        let dll = Path.Combine(Path.GetDirectoryName(scriptPath), "bin", $"%O{Guid.NewGuid()}.dll")
        [|
            "-a"; scriptPath
            $"-o:%s{dll}"
            "--targetprofile:netcore"
            "--target:library"
            "--debug:full"
        |], dll
    
    let tryCompileCtx (loadCtx:AssemblyLoadContext) scriptPath = 
        async {
            let compilerArgs, dllPath = compilerArgs scriptPath
    
            let! errors, retCode = checker.Compile(compilerArgs)
            let maybeAssembly = 
                match retCode with
                | 0 -> loadCtx.LoadFromAssemblyPath(dllPath) |> Some
                | _ -> None
    
            return
                match maybeAssembly with
                | Some a -> 
                    Ok (a, errors)
                | None -> 
                    Error (retCode, scriptPath, errors)
        }

Both of these versions of the code pass basic tests to compile and run the following script in the test environment (running with Visual Studio):

#r "nuget: FSharp.Data, 4.2.8" 

open FSharp.Data

let getSomeData() : string =
    let resp = Http.Request "https://en.wikipedia.org/wiki/F_Sharp_(programming_language)"
    match resp.Body with
    | Text text -> 
        text.[0..100]
    | Binary _ -> "Shouldn't be binary"

However, when attempting to compile the same script calling tryCompileCtx in the native hosted environment I get the following errors (using tryCompile works as expected):

  1. Using FSharp.Core 6.0.3, FSharp.Compiler.Service 41.0.3

    18:45:08.2591: Compilation of ...\main.fsx failed with code: 1
    18:45:08.2654: ...\main.fsx (1,1)-(1,31) parse error Constructor on type 'FSharp.DependencyManager.Nuget.FSharpDependencyManager' not found.
    
  2. Using FSharp.Core 6.0.6, FSharp.Compiler.Service 41.0.6

    18:55:10.5977: Compilation of ...\main.fsx failed with code: 1
    18:55:10.6025: ...\main.fsx (1,1)-(1,31) parse error Constructor on type 'FSharp.DependencyManager.Nuget.FSharpDependencyManager' not found.
    18:55:10.6030: ...\main.fsx (1,1)-(1,31) parse error Invalid directive. Expected '#r "<file-or-assembly>"'.

Based on the phrasing of the errors and the fact that it is different between 41.0.3 and 41.0.6 I'm fairly confident the error originates from an exception thrown in DependencyProvider.fs. However I have no idea how to track it down further than that or what to do about it.

```fsharp
let instance = Activator.CreateInstance(theType, [| outputDir :> obj |]) // on 41.0.3
let instance = // on 41.0.6
    if not(isNull (theType.GetConstructor([|typeof<string option>; typeof<bool>|]))) then
        Activator.CreateInstance(theType, [| outputDir :> obj; useResultsCache :> obj |])
    else
        Activator.CreateInstance(theType, [| outputDir :> obj |])
```

Compiling a simple script like let hello name = $"Hello, %s{name}" works as expected in all cases.

Is this a bug or is there some additional configuration I need to provide so that #r nuget directives will work as expected?

If it's relevant, I'm running:

smoothdeveloper commented 2 years ago

@roboz0r, not 100% sure, you may need to provide compilertool option with path that contains the Nuget extension.

It would be something similar than what is done in this test: https://github.com/fsprojects/Paket/blob/eac93a466a2ed15c5b8bae3ad00e41d1dabc68f2/integrationtests/Paket.IntegrationTests/FsiExtension.fs#L36

roboz0r commented 2 years ago

@smoothdeveloper No luck sadly. I added --compilertool:C:\...\net6.0-windows which was the path to the directory containing FSharp.DependencyManager.Nuget.dll, FSharp.Compiler.Service.dll, FSharp.Core etc. I also tried various combinations /compilertool instead of --, --compilertool:C:\...\net6.0-windows\FSharp.DependencyManager.Nuget.dll.

This might be a complete red herring but I noticed when I trigger compilation the AssemblyLoadContext goes looking for FSharp.Core, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a. Is this correct and it will happily accept later FSharp.Core dlls?

smoothdeveloper commented 2 years ago

For the assembly load, maybe you need something akin to https://github.com/fsprojects/Paket/blob/eac93a466a2ed15c5b8bae3ad00e41d1dabc68f2/integrationtests/Paket.IntegrationTests/FsiExtension.fs#L21-L28

roboz0r commented 2 years ago

That worked! Or at least compilation was successful. Now it fails when I try to call the function with:

TargetInvocationException: Could not load file or assembly 'FSharp.Data, Version=4.2.8.0, Culture=neutral, PublicKeyToken=49286adf818aa259'. The system cannot find the file specified.
   at Main.getSomeData()

It exists at "C:\Users\Robert\.nuget\packages\fsharp.data\4.2.8\lib\netstandard2.0\FSharp.Data.dll". Is there a simple way to either have the compiler copy the needed dlls into the compile dir (I didn't see anything on the Compiler Options listed)? Or a pre-built utility function that knows how to search the local nuget dir? I'm sure I can get something working from here but it will be less error-prone if something is readily available.

Thanks for your help with this.

roboz0r commented 2 years ago

I've come up with a solution that seems to work. Pasting below so that others can make use of it:

open System
open System.IO
open System.Reflection
open System.Runtime.Loader
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Text

type MyContext(sharedTypes:Assembly, scriptPath:string, nugetDlls: string seq) =
    inherit AssemblyLoadContext("MyContext", true)
    let dllDir = Path.Combine(Path.GetDirectoryName(scriptPath), "bin")
    let dllName = Guid.NewGuid().ToString()
    let dllFullName = Path.Combine(dllDir, $"%s{dllName}.dll")

    let mutable scriptAssembly = Unchecked.defaultof<Assembly>
    let mutable resolver : AssemblyDependencyResolver option = None
    //https://learn.microsoft.com/en-us/dotnet/api/system.runtime.loader.assemblydependencyresolver.-ctor?view=net-6.0#system-runtime-loader-assemblydependencyresolver-ctor(system-string)
    // componentAssemblyPath can be a relative or an absolute path. The assembly must have an accompanying deps.json file in the same directory that describes the assembly's dependencies. This file is output during the build process.
    // Compiler produces ".\bin\Projects\28244--2f36d849-4688-4031-8ecf-713772ead307\bin\Debug\net6.0\win-x64\Project.deps.json"
    // Rename to <dll-name>.deps.json and copy to dll folder

    let copyDepsJson () = 
        let dir = DirectoryInfo(dllDir)
        let toFind = "Project.deps.json"
        let files = dir.GetFiles(toFind, SearchOption.AllDirectories)
        match files.Length with
        | 0 -> logger.Warn $"Could not locate %s{toFind}"
        | 1 -> 
            let dest = Path.ChangeExtension(dllFullName, ".deps.json")
            files[0].CopyTo(dest, true) |> ignore
            // Cannot create resolver until deps.json is in place
            resolver <- new AssemblyDependencyResolver(dllFullName) |> Some
            logger.Debug $"Created resolver for %s{dest}"
        | i -> 
            logger.Debug $"Located {i} {toFind}. Selecting newest."
            files
            |> Array.sortInPlaceBy (fun file -> file.CreationTimeUtc)

            let dest = Path.ChangeExtension(dllFullName, ".deps.json")
            (Array.last files).CopyTo(dest, true) |> ignore
            resolver <- new AssemblyDependencyResolver(dllFullName) |> Some
            logger.Debug $"Created resolver for %s{dest}"

    let copyNugetDlls () = 
        for dll in nugetDlls do
            logger.Debug($"%s{dllName}: Copying %s{dll}")
            let file = FileInfo(dll)
            let destFile = Path.Combine(dllDir, Path.GetFileName(dll))
            if File.Exists destFile then ()
            else file.CopyTo(destFile) |> ignore

    override this.Load(assemblyName) =
        // Must use same Assembly instances where possible (instead of loading Assembly again from disk) otherwise types won't match and aren't compatible.
        let fullName = assemblyName.FullName
        logger.Debug($"%s{dllName}: Resolving {fullName}")
        if fullName = sharedTypes.FullName then 
            logger.Debug($"%s{dllName}: Supplied {sharedTypes.FullName}")
            sharedTypes
        else
            AssemblyLoadContext.GetLoadContext(sharedTypes).Assemblies
            |> Seq.tryFind (fun x -> x.FullName = fullName)
            |> function
            | Some a -> 
                logger.Debug($"%s{dllName}: SharedTypes Assemblies Supplied {a.Location}")
                a
            | None ->
                match resolver with
                | Some resolver -> 
                    resolver.ResolveAssemblyToPath(assemblyName)
                    |> function
                    | null -> 
                        logger.Debug($"%s{dllName}: Failed to resolve {fullName}")
                        null
                    | path -> 
                        logger.Debug($"%s{dllName}: AssemblyDependencyResolver Supplied {path}")
                        this.LoadFromAssemblyPath path
                | None ->
                        logger.Debug($"%s{dllName}: Failed to resolve {fullName}")
                        null

    member __.Assembly = scriptAssembly
    member __.DllDir = dllDir
    member __.DllFullName = dllFullName
    member this.LoadCompiledDll() = 
        copyNugetDlls ()
        copyDepsJson ()
        scriptAssembly <- this.LoadFromAssemblyPath(dllFullName)

module Checker =

    let private checker = FSharpChecker.Create()

    // https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/compiler-options
    let private compilerArgs (scriptPath:string) (dllPath) = 
        [|
            "-a"; scriptPath
            $"-o:%s{dllPath}"
            "--targetprofile:netcore"
            "--target:library"
            "--debug:full"
            // Needed to ensure Projects dirs appear with dllPath
            $"--compilertool:%s{Assembly.GetExecutingAssembly().DirectoryName}"
        |]

    // Based on https://queil.net/2021/06/embedding-fsharp-compiler-nuget-references/
    let resolveNugets (scriptPath) =
        async {
            let source = File.ReadAllText scriptPath |> SourceText.ofString
            let! projOptions, errors = checker.GetProjectOptionsFromScript(scriptPath, source)

            match errors with
            | [] -> 
                let! projResults = checker.ParseAndCheckProject(projOptions)
                return
                    match projResults.HasCriticalErrors with
                    | false -> 
                        projResults.DependencyFiles 
                        |> Array.filter(fun path ->  path.EndsWith(".dll"))
                        |> Set.ofArray
                        |> Ok
                    | _ -> Error (List.ofArray projResults.Diagnostics)
            | _ -> return Error (errors)
        }

    let tryCompileCtx sharedTypes scriptPath logger = 
        async {
            let! nugetDlls = resolveNugets scriptPath
            match nugetDlls with
            | Ok nugetDlls -> 
                let loadCtx = MyContext(sharedTypes, scriptPath, logger, nugetDlls)
                let compilerArgs = compilerArgs scriptPath loadCtx.DllFullName
                let! errors, retCode = checker.Compile(compilerArgs)
                let maybeAssembly = 
                    match retCode with
                    | 0 -> 
                        loadCtx.LoadCompiledDll()
                        Some loadCtx
                    | _ -> None

                return
                    match maybeAssembly with
                    | Some a -> 
                        Ok (a, List.ofArray errors)
                    | None -> 
                        Error (retCode, scriptPath, List.ofArray errors, List.ofArray compilerArgs)

            | Error errors -> return Error (-1, scriptPath, errors, [])
        }

    // Ensure the hosting process' AssemblyLoadContext can also access FSharp.Core
    do
        AssemblyLoadContext.Default.add_Resolving (fun alc name -> 
            logger.Debug $"Default AssemblyLoadContext searching for: {name.FullName}"
            if name.FullName.Contains("FSharp.Core") then 
                logger.Debug $"Providing local FSharp.Core"
                typedefof<Map<_,_>>.Assembly
            else null
        )