haf / expecto

A smooth testing lib for F#. APIs made for humans! Strong testing methodologies for everyone!
Apache License 2.0
670 stars 96 forks source link

Building expecto test apps as NativeAOT #498

Open Numpsy opened 2 months ago

Numpsy commented 2 months ago

I'm not sure how sensible / feasible doing this is, but:

If I try to build the minimal test app from the readme:

open Expecto

let tests =
  test "A simple test" {
    let subject = "Hello World"
    Expect.equal subject "Hello World" "The strings should equal"
  }

[<EntryPoint>]
let main args =
  runTestsWithCLIArgs [] args tests

as .NET 8.0 using NativeAOT, then the test app fails to run with this exception:

Unhandled Exception: System.TypeInitializationException: A type initializer threw an exception. To determine which type, inspect the InnerException's StackTrace property.
 ---> System.TypeLoadException: Attempted to load a type that was not created during ahead of time compilation.
   at Internal.Runtime.CompilerHelpers.ThrowHelpers.ThrowUnavailableType() + 0x2b
   at Microsoft.FSharp.Core.FSharpFunc`2.InvokeFast[V](FSharpFunc`2, T, TResult) + 0x1a
   at <StartupCode$Expecto>.$Logging..cctor() + 0x4b3
   at System.Runtime.CompilerServices.ClassConstructorRunner.EnsureClassConstructorRun(StaticClassConstructionContext*) + 0xb9
   --- End of inner exception stack trace ---
   at System.Runtime.CompilerServices.ClassConstructorRunner.EnsureClassConstructorRun(StaticClassConstructionContext*) + 0x14a
   at System.Runtime.CompilerServices.ClassConstructorRunner.CheckStaticClassConstructionReturnGCStaticBase(StaticClassConstructionContext*, Object) + 0xd
   at Expecto.Logging.ANSIOutputWriter.setColourLevel(ColourLevel) + 0x63
   at Expecto.Tests.runTestsWithCancel@600(CancellationToken, Impl.ExpectoConfig, Test) + 0x20
   at Expecto.Tests.runTestsWithCLIArgs(IEnumerable`1, String[], Test) + 0x1d
   at ex_test_2!<BaseAddress>+0x2268a0

(I've seen a few variations of the call stack with other test apps, depending on if they use runTestsInAssembly or variations like that).

If I build the app with full AOT analysis enabled then I get a few instances of warnings like this:

ILC : AOT analysis warning IL3054: Microsoft.FSharp.Core.OptimizedClosures.FSharpFunc`3<FSharpFunc`2<FSharpFunc`2<Unit,Int64>,FSharpFunc`2<FSharpFunc`2<String[],Logger>
,FSharpFunc`2<Object,LoggingConfig>>>,DVar`1<FSharpFunc`2<Unit,Int64>>,DVar`1<FSharpFunc`2<FSharpFunc`2<String[],Logger>,FSharpFunc`2<Object,LoggingConfig>>>>: Generic
expansion to 'Microsoft.FSharp.Core.OptimizedClosures.Invoke@3756<FSharpFunc`2<FSharpFunc`2<Unit,Int64>,FSharpFunc`2<FSharpFunc`2<String[],Logger>,FSharpFunc`2<Object,L
oggingConfig>>>,DVar`1<FSharpFunc`2<Unit,Int64>>,DVar`1<FSharpFunc`2<FSharpFunc`2<String[],Logger>,FSharpFunc`2<Object,LoggingConfig>>>>' was aborted due to generic rec
ursion. An exception will be thrown at runtime if this codepath is ever reached. Generic recursion also negatively affects compilation speed and the size of the compila
tion output. It is advisable to remove the source of the generic recursion by restructuring the program around the source of recursion. The source of generic recursion
might include: 'Microsoft.FSharp.Core.FSharpFunc`2', '<StartupCode$FSharp-Core>.$Prim-types.op_Implicit@3875', '<StartupCode$FSharp-Core>.$Prim-types.op_Implicit@3880-1
', '<StartupCode$FSharp-Core>.$Prim-types.op_Implicit@3883-2', '<StartupCode$FSharp-Core>.$Prim-types.op_Implicit@3886-3', '<StartupCode$FSharp-Core>.$Prim-types.FromCo
nverter@3889', '<StartupCode$FSharp-Core>.$Prim-types.ToConverter@3892', 'Microsoft.FSharp.Core.OptimizedClosures.FSharpFunc`3', 'Microsoft.FSharp.Core.OptimizedClosure
s.FSharpFunc`4', 'Microsoft.FSharp.Core.OptimizedClosures.FSharpFunc`5', 'Microsoft.FSharp.Core.OptimizedClosures.FSharpFunc`6', 'Microsoft.FSharp.Core.OptimizedClosure
s.Invoke@3756', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3763', 'Microsoft.FSharp.Core.OptimizedClosures.Invoke@3770-1', 'Microsoft.FSharp.Core.OptimizedClosures.
Adapt@3779-1', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3783-2', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3797-3', 'Microsoft.FSharp.Core.OptimizedClosures.
Adapt@3802-4', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3806-5', 'Microsoft.FSharp.Core.OptimizedClosures.Invoke@3809-2', 'Microsoft.FSharp.Core.OptimizedClosures
.Invoke@3817-3', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3827-6', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3832-7', 'Microsoft.FSharp.Core.OptimizedClosure
s.Adapt@3837-8', 'Microsoft.FSharp.Core.OptimizedClosures.Adapt@3841-9' [S:\DevTest\ex_test_2\ex_test_2.fsproj]

Which looks like something in the logging setup is generating recursive generics too complicated for the AOT compiler to handle. I don't know anything about how the DVar stuff works and haven't investigated any further that this so far, but leaving this here in case anyone else has any ideas about if it's possible to get this to work.

farlee2121 commented 2 months ago

I appreciate the well-researched suggestion!

I've also been poking at the logging recently, since there are several outstanding issues

454, #480, #482

The DVar type is used for managing global logger state (like semaphore instances) behind the scenes.

From the poking I've done so far, I think there's a route to de-globalize the logger. That alone might be enough to solve the AOT problem, since it could eliminate DVar. If not, it'd also simplify the route to supporting Microsoft.Extensions.Logging which I'd guess is supported by AOT compile

Numpsy commented 2 months ago

Microsoft.Extensions.Logging.Abstractions can be used in AOT apps (I have cli tools at work using it for console logging that work in AOT compiles).

On another related note - there are a bunch of places in Expecto that use sprintf for string formatting, and that can have issues with AOT builds. Looking at the code, I think at least some of those could be changed to use string interpolation instead which tends to work better, so are there any thoughts or objections on changing that?

farlee2121 commented 2 months ago

I don't see any issue with the sprintf subs you made. It was just interpolating strings, so there shouldn't be any issues with type-specific formatting. It also shouldn't make formatting evaluations any more eager.

Numpsy commented 2 months ago

In a simple test app, that change seems to be enough to get a

testTask "I am (should fail)" {
  "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal true false
}

to run and print out the correct results in a build with simplified logging :-) I still get an error using Expect.equal on records rather than base types, but one thing at once.

farlee2121 commented 2 months ago

Interesting, I wouldn't have expected %s to behave any different than the .ToString() implied in string interpolation.

Numpsy commented 2 months ago

I believe that some of the issues with (s)printf are down to all the machinery for partial application and such, which interpolation doesn't have so there's less scope for issues (though I don't know why there appears to be some situations where sprintf "%A" int works in NativeAOT and sprintf "%i" int throws :-( )

There's also a specific optimization in recent compilers that can convert interpolations that only involve strings into calls to String.Concat which simplifies things - https://github.com/dotnet/fsharp/pull/16556 - though you might still need langVersion set to preview to get that ATM.