From f74c75b51a0513470b6e8a879b232d581a37c292 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 1 Jun 2026 07:01:47 -0500 Subject: [PATCH] F# pre-generated code via codegen write --language fsharp; JasperFx 2.4.1 (6.3.1) Completes the Wolverine-on-F# story: pre-generate Wolverine's runtime code as F# from the CLI. - Pin JasperFx.* to 2.4.1 (adds the codegen `--language fsharp` flag). - Add F# emit to the static HandlerRegistry frame (WriteTypeArrayFrame): F# array literals of typeof<...> via Type.FSharpName(). Previously this threw NotSupportedException, which made `codegen write --language fsharp` fail for ANY real Wolverine app (every app generates the internal HandlerRegistry, not just user handlers). - Make Wolverine.Behavioural.FSharpApp a runnable exe (Program.fs + RunJasperFxCommands, config matching BehaviouralCodegen.Configure) so the CLI can target it. - New CI proof (CodegenWriteFSharpCli): runs `dotnet run -- codegen write --language fsharp` against the F# app, asserts exit 0 (every chain emits F#) and that the generated adapter is byte-identical to the committed Generated.fs the behavioural run-step already compiles + executes under TypeLoadMode.Static. Behavioural F# test classes serialized into one non-parallel collection (nested dotnet build/run). - fsharp CI step updated to cover the CLI proof. - F# tutorial: document the `codegen write --language fsharp` workflow + the .fsproj ordered includes. - Wolverine 6.3.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/fsharp.yml | 7 +- Directory.Build.props | 12 +- Directory.Packages.props | 8 +- docs/tutorials/fsharp.md | 33 +++++- .../Program.fs | 22 ++++ .../Wolverine.Behavioural.FSharpApp.fsproj | 6 + .../BehaviouralRunStep.cs | 1 + .../CodegenWriteFSharpCli.cs | 108 ++++++++++++++++++ .../Runtime/Handlers/HandlerRegistry.cs | 18 +++ 9 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs create mode 100644 src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs diff --git a/.github/workflows/fsharp.yml b/.github/workflows/fsharp.yml index 7a6c8d6b4..2d855b941 100644 --- a/.github/workflows/fsharp.yml +++ b/.github/workflows/fsharp.yml @@ -85,6 +85,9 @@ jobs: run: dotnet test src/Testing/Wolverine.Cosmos.FSharpTests/Wolverine.Cosmos.FSharpTests.csproj -c "$config" --nologo # Run-verified: boots a host in TypeLoadMode.Static against the pre-generated F# adapter and - # asserts the handler actually executes (not just compiles). - - name: Behavioural run-step (TypeLoadMode.Static) + # asserts the handler actually executes (not just compiles). Also exercises the + # `dotnet run -- codegen write --language fsharp` CLI end to end against the F# app, asserting it + # exits 0 (every chain — including Wolverine's internal HandlerRegistry — emits F#) and that the + # generated adapter matches the committed, run-verified Generated.fs. + - name: Behavioural run-step + codegen write --language fsharp (TypeLoadMode.Static) run: dotnet test src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj -c "$config" --nologo diff --git a/Directory.Build.props b/Directory.Build.props index 4e49c6f08..846ea1380 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -165,8 +165,18 @@ #2965 (SharedMemory transport cross-node AssignAgent serialization — bug predates this release line, was failing on every main commit through V6.2.0 and V6.2.1). + 6.3.1: Completes the Wolverine-on-F# story by pinning JasperFx 2.4.1, + which adds a "language fsharp" flag to the codegen write + command so Wolverine's runtime code can be pre-generated as F# + (.fs) instead of C#. Adds F# emit to the static HandlerRegistry + frame (WriteTypeArrayFrame) so the F# codegen write succeeds for + a real Wolverine app (every handler chain emits F#, not just the + user handlers). The F# tutorial documents the CLI workflow, and + the fsharp CI action runs the command end to end against the F# + behavioural app and asserts the generated adapter matches the + run-verified Generated.fs. --> - 6.2.2 + 6.3.1 $(PackageProjectUrl) true true diff --git a/Directory.Packages.props b/Directory.Packages.props index c960d6a83..fccc662ba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,13 +32,13 @@ - - - + + + - + diff --git a/docs/tutorials/fsharp.md b/docs/tutorials/fsharp.md index 6a49d26a3..75b09a3aa 100644 --- a/docs/tutorials/fsharp.md +++ b/docs/tutorials/fsharp.md @@ -126,12 +126,41 @@ type BehaviouralPingHandler1244766258() = System.Threading.Tasks.Task.CompletedTask ``` +## Generating the F# code from the command line + +Wolverine apps answer the JasperFx command line (the final `RunJasperFxCommands(args)` in your +`Program`). As of JasperFx 2.4.1 the `codegen` command takes a `--language` flag, so you can write the +pre-generated code out as **F#** instead of C#: + +```bash +dotnet run -- codegen write --language fsharp +``` + +This emits one `.fs` file per generated type into your code-generation output directory +(`Internal/Generated/…` by default) — the handler adapters plus Wolverine's static +`GeneratedHandlerRegistry`. Because F# requires explicit, ordered compilation, add the generated files +to your `.fsproj` `` list (the registry and adapters depend on your handler/message types, so +list them after those): + +```xml + + + + + + +``` + +Re-run the command (and commit the regenerated files) whenever your handler graph changes; the generated +type names are deterministic for a given handler graph. + ## Running on pre-generated F# code To ship an F# app that runs on pre-generated code rather than compiling at startup: -1. Generate the F# adapters and commit them into your application (for example as a `Generated.fs` - compiled into the app assembly). The generated type names are deterministic for a given handler graph. +1. Generate the F# adapters with `codegen write --language fsharp` (above) and commit them into your + application, compiled into the app assembly. The generated type names are deterministic for a given + handler graph. 2. Boot the host in `TypeLoadMode.Static` and point Wolverine's `ApplicationAssembly` at the assembly that contains the pre-generated F# — Wolverine then loads each handler adapter **by name** out of that assembly's exported types, with no Roslyn at runtime: diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs new file mode 100644 index 000000000..44bb1a179 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs @@ -0,0 +1,22 @@ +module WolverineBehaviouralFSharpApp.Program + +open Microsoft.Extensions.Hosting +open JasperFx +open Wolverine +open WolverineBehaviouralFSharpApp + +// Entry point so the app answers the JasperFx CLI verbs — notably +// `dotnet run -- codegen write --language fsharp`, which regenerates the F# handler adapter. +// The Wolverine configuration here MUST match BehaviouralCodegen.Configure in the test project +// (DisableConventionalDiscovery + IncludeType) so the generated handler +// type-name hash is identical to the one the behavioural run-step computes under TypeLoadMode.Static. +[] +let main args = + Host + .CreateDefaultBuilder(args) + .UseWolverine(fun opts -> + opts.Discovery.DisableConventionalDiscovery() |> ignore + opts.Discovery.IncludeType() |> ignore) + .RunJasperFxCommands(args) + .GetAwaiter() + .GetResult() diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj index 62f5ccc0d..53a119acc 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj @@ -13,6 +13,11 @@ net9.0 false + + Exe + false disable @@ -25,6 +30,7 @@ + diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs index 6f00247c7..50ae91242 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs @@ -16,6 +16,7 @@ namespace Wolverine.Behavioural.FSharpTests; /// carries the committed, pre-generated F# handler adapter), sends a message, and asserts the F# /// handler actually executed — run-verification, not just compilation. /// +[Collection("BehaviouralFSharp")] public class BehaviouralRunStep { private readonly ITestOutputHelper _output; diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs new file mode 100644 index 000000000..1e0045613 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Wolverine.Behavioural.FSharpTests; + +// The behavioural F# tests each spawn nested `dotnet build`/`dotnet run` of the F# app and load its +// assembly; running them concurrently races on the app's build outputs. Serialize them. +[CollectionDefinition("BehaviouralFSharp", DisableParallelization = true)] +public class BehaviouralFSharpCollection; + +/// +/// Proves the JasperFx codegen write --language fsharp CLI flag works end to end against a +/// real Wolverine F# application: it runs the verb against Wolverine.Behavioural.FSharpApp, +/// asserts every handler chain (including Wolverine's internal static HandlerRegistry) emits F# +/// (exit 0), and confirms the generated handler adapter is identical to the committed +/// Generated.fs — which the behavioural run-step already compiles and executes under +/// . So: the CLI produces the same F# +/// that is proven to run. +/// +[Collection("BehaviouralFSharp")] +public class CodegenWriteFSharpCli +{ + private readonly ITestOutputHelper _output; + + public CodegenWriteFSharpCli(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task codegen_write_fsharp_generates_runnable_fsharp_for_a_wolverine_app() + { + var appProject = BehaviouralCodegen.AppProjectPath(); + var appDir = Path.GetDirectoryName(appProject)!; + var generatedDir = Path.Combine(appDir, "Internal"); + + // Start clean so we only see what THIS run produced. + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + + try + { + var (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp"); + + // A transient F# build crash / file lock can fail the embedded `dotnet run` build; retry once. + if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error") + || output.Contains("being used by another process") + || output.Contains("MSB3883"))) + { + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp"); + } + + _output.WriteLine(output); + + // Exit 0 means every generated handler chain — including Wolverine's internal static + // HandlerRegistry (WriteTypeArrayFrame) — successfully emitted F#. + exitCode.ShouldBe(0); + + var generatedFiles = Directory.GetFiles(generatedDir, "*.fs", SearchOption.AllDirectories); + generatedFiles.ShouldNotBeEmpty(); + + // The handler adapter the CLI produced must match the committed Generated.fs that the + // behavioural run-step compiles + executes under TypeLoadMode.Static. + var adapterFile = generatedFiles.Single(f => + Path.GetFileName(f).StartsWith("BehaviouralPingHandler", StringComparison.Ordinal)); + var generatedAdapter = Normalize(await File.ReadAllTextAsync(adapterFile)); + var committedAdapter = Normalize(await File.ReadAllTextAsync(BehaviouralCodegen.GeneratedFilePath())); + generatedAdapter.ShouldBe(committedAdapter); + + // The static HandlerRegistry was also emitted as valid F# (the Type[] accessors as F# + // array literals) — this is what previously threw NotSupportedException. + var registryFile = generatedFiles.Single(f => + Path.GetFileName(f) == "GeneratedHandlerRegistry.fs"); + var registry = await File.ReadAllTextAsync(registryFile); + registry.ShouldContain("inherit Wolverine.Runtime.Handlers.HandlerRegistry()"); + registry.ShouldContain("typeof"); + } + finally + { + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + } + } + + private static string Normalize(string code) + => code.Replace("\r\n", "\n").Trim(); + + private static async Task<(int ExitCode, string Output)> RunDotnetAsync(string workingDirectory, string arguments) + { + var info = new ProcessStartInfo("dotnet", arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + info.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = Process.Start(info)!; + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, stdout + stderr); + } +} diff --git a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs index 37098b0fe..bcc718671 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs @@ -131,4 +131,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + // F# counterpart so `codegen write --language fsharp` can emit the static HandlerRegistry. F# + // method bodies are expressions (no `return`/`;`): an empty array, or an F# array literal of + // typeof<...> values. + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + if (_types.Length == 0) + { + writer.Write("System.Array.Empty()"); + } + else + { + var literals = string.Join("; ", _types.Select(t => $"typeof<{t.FSharpName()}>")); + writer.Write($"[| {literals} |]"); + } + + Next?.GenerateFSharpCode(method, writer); + } }