Skip to content

XAML server: Native AOT is feasible with no functional cost; work is out-of-proc project load #233

Description

Status: revised three times. Filed as a closed negative ("infeasible, do not retry") — wrong. Revised to "feasible, but costs {x:Bind} accuracy against source-generated members" — also wrong. Revised to "feasible, cost is generated-member staleness" — also wrong. This body reflects the current, measured position: no accuracy cost, no freshness cost.

Summary

Native AOT compilation of the WinUI XAML language server is feasible, with no known blocker and no functional cost.

The only cost is engineering: rebuilding the project-load path as an out-of-process design-time build. Source-generated members ([ObservableProperty], [RelayCommand]) remain fully supported, refresh on the same trigger as today, and do so ~5x faster than the current path.

A 33.5 MB win-arm64 native binary referencing only Microsoft.CodeAnalysis.CSharp — no Roslyn Workspaces, no Microsoft.Build.*, no MSBuildLocator — passes the full Stage == Full feature set.

Why the original "infeasible" verdict was wrong

Two blockers were observed and reported as individually fatal:

  1. MefHostServices.DefaultHost throws CompositionFailedException under AOT (trimming breaks MEF type identity)
  2. MSBuildLocator needs a runtime assembly loader, which AOT doesn't have

Both observations are accurate. The error was assuming both are unavoidable.

MEF only loads through Microsoft.CodeAnalysis.Workspaces, and that dependency is confined to a single fileWinUiXaml.Workspace/RoslynProjectWorkspace.cs. Everything downstream consumes only Compilation:

  • XamlProjectResolver calls exactly one Workspaces member: GetCompilationAsync() (XamlProjectResolver.cs:137).
  • GetFrameworkCompilation() already hand-builds a raw CSharpCompilation.Create.
  • No SymbolFinder, Formatter, Simplifier or Renamer anywhere.
  • The TextDocument used across the LSP layer is the server's own type, not Roslyn's.

Drop Workspaces and both the MEF warnings and the CompositionFailedException disappear. The claim that this would require "reimplementing project/document/solution tracking" was wrong — nothing in the server consumes that surface.

AOT-safe MSBuild acquisition

Process.Start is AOT-safe, so MSBuild runs as a child process:

dotnet msbuild <proj> -t:Rebuild -p:ProvideCommandLineArgs=true \
  -p:SkipCompilerExecution=true -p:DesignTimeBuild=true -getItem:CscCommandLineArgs

On the smoke fixture this returns 276 args — 209 /reference: + 28 sources, including every XamlCompiler-generated .g.cs.

Source-generated members: fully supported, and faster than today

Two successive revisions of this issue got this wrong — first claiming {x:Bind} against [ObservableProperty] would break, then claiming it would be stale. Neither holds.

What freshness actually is today

Measured against the current framework-dependent server, not assumed:

  • documentSelector is XAML-only (xamlLanguageService.ts:678-681), and onDidChangeTextDocument early-returns unless languageId === "xaml" (line 352-356).
  • C# therefore reaches the server only through the file watcher **/*.{cs,csproj,xaml,props,targets} (line 673) — i.e. on save. Unsaved C# buffers are already invisible.
  • On a .cs event, DidChangeWatchedFilesAsync marks it structural (XamlLanguageServer.cs:536) and calls _resolver.Invalidate, which evicts the cache entry (XamlProjectResolver.cs:232). The next resolve is a full MSBuildWorkspace.OpenProjectAsync.

Today is not incremental. The bar to match is: on save, ~2.5 s.

PHASE 1 (restore only, no build ever run):  load 10672 ms
  Greeting: True   RefreshCommand: True
PHASE 2 (saved .cs edit adds [ObservableProperty] int ItemCount, NO build):
  reload 2486 ms   ItemCount: True
VERDICT: in-proc generators run -> today = "on save, no build needed"

Matching it out-of-process

A full incremental dotnet build -p:EmitCompilerGeneratedFiles=true after touching one .cs takes 4,347 ms — it works, but pays for the XAML compiler and every other target.

A helper that runs only CSharpGeneratorDriver, fed the cached CscCommandLineArgs:

refs=210 analyzers=11 sources=29    incremental=18 legacy=1 generators, 4 generated files
  Greeting: True  ItemCount: True  RefreshCommand: True
cold  2581 ms (generator phase 2057)
warm   512 / 517 / 514 ms (generator phase 257-316)
Path Latency on saved .cs edit
Today (MSBuildWorkspace full reload) 2,486 ms
Out-of-proc full design-time build 4,347 ms
Out-of-proc generator-only helper ~515 ms warm

~5x faster than the current path, on the identical trigger, with identical accuracy.

The invalidation plumbing already exists

DidChangeWatchedFilesAsync already distinguishes the two classes:

  • .cs → csc args unchanged; re-run generators only
  • .csproj / .props / .targets / project.assets.json → re-acquire CscCommandLineArgs, then re-run

Nothing new is needed to know when to refresh. Only what to call changes.

Measured result — Workspaces-free native binary

refs=209 srcs=28 / parsed trees=28 / metadata refs=209
  [PASS] metadata symbol Page -> Microsoft.UI.Xaml.Controls.Page
  [PASS] source symbol SmokePage -> SmokeFixture.SmokePage
  [PASS] base chain crosses to metadata -> Page UserControl Control FrameworkElement UIElement DependencyObject Object
  [PASS] x:Name generated fields -> Scroller,Title,BoundText,AttachedProbe,ScratchInput,GoButton,Repeater
  [PASS] GoButton field type binds -> Microsoft.UI.Xaml.Controls.Button
  [PASS] code-behind handler + location -> SmokePage.xaml.cs:27
  [PASS] no compile errors (semantic model sound) -> 0 errors
RESULT: ALL CHECKS PASSED

The incremental path an LSP actually stresses was also verified: ReplaceSyntaxTree picks up newly typed members, the original compilation stays immutable (so the existing caching model is sound), and diagnostics and symbol display work natively.

Roslyn's compiler layer is empirically AOT-clean

Dropping Workspaces takes unique analyzer warnings 144 → 20, across only three sites:

Site Nature
UICultureUtilities localized diagnostic text; TryGet* pattern, degrades to English
PooledDelegates IL2091, annotation-only
RoslynLazyInitializer IL2091, annotation-only

Zero MEF, MSBuild or Workspaces entries. The decisive detail is what is absent — no IL3050, no IL2055/IL2060 — meaning nothing reachable requires runtime code generation.

⚠️ Roslyn is not formally supported on Native AOT. This is an empirical result for the paths this server exercises and must be re-run on Roslyn version bumps.

The extension's SDK acquisition is already AOT-safe

Worth stating explicitly, because it means the out-of-proc precedent already ships:

  • The .NET host is resolved in TypeScriptdotnetInstallTool.ts via the ms-dotnettools dotnet.findPath command.
  • dotnetRuntime.ts sets DOTNET_ROOT / DOTNET_HOST_PATH on the child environment (lines 24-25).
  • The SDK is already invoked as a child process — runDotnetRestore, xamlLanguageService.ts:848.

None of that runs inside the .NET server. The only in-process SDK assembly loading is MSBuildLocator in MsBuildRegistrar.cs (lines 40-62) — exactly what an AOT migration deletes. Acquisition is already fine; only consumption isn't.

Revised verdict

No known blocker, and no functional cost. The remaining work is:

  • Rebuilding the project-load path as an out-of-proc design-time build (scope: one file, RoslynProjectWorkspace.cs, plus a generator-refresh helper)

⚠️ Gate before attempting: add fixture coverage for source-generated members and wire EmitCompilerGeneratedFiles / the generator-driver helper. There is currently no ObservableProperty, CommunityToolkit.Mvvm or [RelayCommand] usage anywhere in this repository (verified repo-wide), so no existing test would catch a regression in this path. This gate now guards a migration, not a tradeoff.

Note that the existing self-contained publish already delivers the main user-facing benefit (no .NET SDK required), so the case for AOT rests on startup time and memory footprint — plus, per the table above, a materially faster C#-edit refresh.

Reproduction traps worth recording

  • Do not pass -p:PublishAot=true on the command line — it propagates to WinUiXaml.Xaml (netstandard2.0) and fails with NETSDK1207. Set it in the .csproj; project-file properties don't propagate.
  • Console output hard-wraps and truncates warnings. Use "/flp:logfile=aot.log;verbosity=normal".
  • MSB3073 ... link.exe ... exited with code 123 is not a host/target mismatch. The ILC targets shell out to vswhere.exe; when it isn't on PATH, the error text is concatenated into $(_Linker). Prepend C:\Program Files (x86)\Microsoft Visual Studio\Installer to PATH inside a vcvarsall.bat shell.
  • PublishAot=true implicitly sets JsonSerializerIsReflectionEnabledByDefault=false. A reflection-based serializer dies immediately with InvalidOperationException: Reflection-based serialization has been disabled for this application.
  • -getItem:ReferencePath returns pre-target state (empty). Use -getItem:CscCommandLineArgs.
  • Generated files are absent from CscCommandLineArgs; add the directory explicitly, and keep CompilerGeneratedFilesOutputPath under obj/ to avoid the SDK's implicit **/*.cs glob re-ingesting it and duplicating every declaration.
  • -p:SkipCompilerExecution=true (how CscCommandLineArgs is harvested) means csc never runs, so that invocation produces no generator output. Harvesting args and refreshing generated members are two separate calls.
  • Assembly.GetTypes() over the analyzer list throws ReflectionTypeLoadException on *.CodeFixers.dll (it references Microsoft.CodeAnalysis.Workspaces). Catch it and use rtle.Types.Where(t => t != null), or the helper dies with 0xE0434352 before running a single generator.
  • Namespace collision makes the Workspaces surface look pervasive when it is one file. The server defines its own WinUiXaml.Workspace namespace, plus its own TextDocument and XamlFormatter types. Grepping for Workspace / TextDocument / Formatter returns 44 hits across 36 files and suggests a deep Roslyn Workspaces dependency. This misreading is what produced the original incorrect "infeasible" verdict. Narrow the search instead:
    using Microsoft.CodeAnalysis.MSBuild | MSBuildWorkspace | MefHostServices
      | SymbolFinder | Simplifier | Renamer | Microsoft.CodeAnalysis.Formatting
    
    That returns 6 lines in a single file, WinUiXaml.Workspace/RoslynProjectWorkspace.cs.

Prerequisite work, already done

Commit aa4baab on chiaramooney-native-aot-compatibility routes all LSP traffic through a source-generated JsonSerializerContext, taking our own trim/AOT warnings 12 → 0 with no behavior change. It is a hard prerequisite for any AOT build (see the reflection-serialization trap above) but stands on its own merits, and it caught a genuine latent bug: Command.Arguments (an object[] of code-action argument records) was unregistered and would have thrown NotSupportedException at runtime.

Tradeoff for a reviewer: the strictness cuts both ways — a future result type reaching the wire without a [JsonSerializable] entry now throws rather than quietly working.

Not attached to #50, which is in active review.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions