Phase 69n — `fromContextAsync` build-once dispatcher table (migration)
Phase 69n — fromContextAsync build-once dispatcher table (migration)
What changes
Remoting.fromContextAsync now matches the build-once / read-per-call semantics its docstring promises. Pre-69n the Giraffe adapter's fromContextAsync arm called buildFromImplementation on every request, rebuilding the entire dispatcher substrate (TypeShape proxy + six attribute-driven classifier maps + rmsManager + compose-time guards) per dispatch. The async resolver itself ran per request as intended, but the substrate cost was paid every time — a load-bearing latent landmine for any consumer adopting the documented pattern at scale.
After 69n:
GiraffeUtil.buildDispatcherTable<'impl>is a new function that runs all the one-time substrate construction and compose-time guards, then returns a curried(HttpContext -> 'impl) -> HttpHandlerfunction. The proxy + classifier tables live in the returned function's closure.GiraffeUtil.buildFromImplementationis now a thin shim —buildDispatcherTable options implBuilder. Preserves the pre-69n signature so theStaticValue/FromContextarms ofbuildHttpHandlercontinue to compile unchanged.buildHttpHandler'sFromContextAsyncarm bindslet dispatch = GiraffeUtil.buildDispatcherTable optionsONCE at compose time, then the per-request closure just awaits the async resolver and callsdispatch (fun _ -> impl) next ctx. The substrate is built once; only the async resolver and the resulting impl-builder closure run per request.- The
Remoting.fromContextAsyncdocstring's "Performance caveat (until Phase 69n ships)" block is retired (added in the TIDY-UP "ToolUp.Remoting per-request cleanups" bundle's F4-companion item — it was the holding action until this phase shipped).
Diff to apply
No consumer source change required. Internal refactor; the public surface (the Remoting.* setters + Api.make wrapper + buildHttpHandler) is unchanged. Consumers pick up the build-once behaviour automatically at the next package upgrade.
Consumers that had been holding off on fromContextAsync because of the per-request rebuild cost can now adopt it at any scale; this migration is the perf alignment that pattern always needed.
Verification
dotnet build ToolUp.Forge.sln— clean.dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsproj— green (newPhase 69n — fromContextAsync build-oncepack adds source-audit tests pinning the carve-out + the compose-time bind + the retired docstring caveat + the AspNetCore-side parity-refusal preservation).- Pack lives at
src/ToolUp.Platform.Tests/InProcess/FromContextAsyncBuildOnceTests.fs. - An integration-shape build-once test (compose
fromContextAsyncwith a tracking resolver + a trackingmakeApiProxysubstitute; dispatch 100 requests; assert onemakeApiProxycall) is deferred to the shared TestServer-scaffold follow-up TIDY-UP item along with the Phase 69l + 69m deferrals. StaticValueandFromContextpaths produce byte-identical behaviour — they continue through thebuildFromImplementationshim which delegates tobuildDispatcherTable. The existing 704+ Remoting byte-pin test gallery covers the wire format.
Rollback
Revert in this order:
buildHttpHandler'sFromContextAsyncarm: restore the per-requestGiraffeUtil.buildFromImplementation (fun _ -> impl) options next ctxcall inside the closure (drop thelet dispatch = ...compose-time bind).GiraffeAdapter.fs: revert thebuildDispatcherTable<'impl> options = ...carve-out and the innerfun implBuilder -> fun next ctx -> task { ... }wrap. RestorebuildFromImplementation<'impl> implBuilder options = ...as the top-level shape.Remoting.fs:fromContextAsyncdocstring: restore the "Performance caveat (until Phase 69n ships)" block (it lived in the TIDY-UP F4-companion entry; restoring it from the git history of that bundle would be the canonical undo).
See also
69b-remoting-platform-seams.md— theToolUp.Remoting.Serverplatform seams this phase hoists, and theRemoting.fromContextAsyncper-request async resolver this phase makes safe at scale.69l-telemetry-zero-cost-gate.md— sister perf phase in the same cluster.69m-dispatcher-body-and-arg-fastpath.md— sister perf phase; landed before this one.- AspNetCore middleware adapter
FromContextAsyncrefusal atMiddleware.fsstays in place — that refusal is for adapter-parity reasons (not the rebuild cost). Lifting it is a separate question.