Enhance winapp run --debug-output crash stacks with WinUI stowed-exception triage#597
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enriches winapp run --debug-output crash diagnostics for WinUI 3 apps (closes #549). When a captured minidump contains Microsoft.UI.Xaml.dll, winapp now runs an automatic stowed-exception triage pass that hosts DbgEng directly and executes the WinUI team's winui-dbgext.js extension (!xamlstowed / !xamltriage), surfacing the originating HRESULT + ErrorContext chain and the native XAML dispatch stack alongside the existing ClrMD managed frames. It fits into CrashDumpService.AnalyzeDumpAsync, immediately after the ClrMD pass, and is auto-enabled (no new flag). The debugger engine bits are restored from NuGet (or downloaded/cached on first use), JsProvider.dll is range-extracted from the official WinDbg bundle and Authenticode-verified, and the pinned extension is hash-gated before load.
Changes:
- Adds a WinUI triage subsystem (
XamlTriageService/IXamlTriageService,XamlTriageRunner,XamlTriageBinaries,WinDbgJsProviderAcquirer,ZipRangeExtractor,AuthenticodeVerifier) run in an isolated__xaml-triagechild process to avoid adbghelp.dllloader collision. - Extends
WriteMiniDumpto prefer the terminating stowed exception's record/parameters (so!xamlstowedcan locate the stowed array) while keeping the first-chance thread CONTEXT for ClrMD;AnalyzeWithClrMDnow reports whether the dump is WinUI. - Adds restore-only
Microsoft.Debugging.Platform.DbgEng/SymSrvpackage references (version pinned + drift-tested), unit tests for the new logic, and docs/skill updates.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
Services/XamlTriageService.cs |
Orchestrates binary resolution/acquisition, spawns the isolated child, formats output & symbol-gap notes |
Services/XamlTriageRunner.cs |
Child-process DbgEng host that loads the JS provider + extension and runs the triage commands |
Services/XamlTriageBinaries.cs |
Resolves/acquires engine DLLs from override dir, installed Debuggers, or NuGet cache/download |
Services/WinDbgJsProviderAcquirer.cs |
Range-extracts JsProvider.dll from the WinDbg bundle and Authenticode-verifies it |
Services/IXamlTriageService.cs |
Triage service interface/contract |
Services/CrashDumpService.cs |
Selects stowed vs first-chance exception record; wires in triage output; detects WinUI |
Services/ICrashDumpService.cs |
WriteMiniDump signature extended with crash-exception code/address/parameters |
Services/DebugOutputService.cs |
Forwards terminating exception parameters into the dump |
Helpers/ZipRangeExtractor.cs |
Ranged ZIP/ZIP64 central-directory reader + IRangeReader |
Helpers/AuthenticodeVerifier.cs |
WinVerifyTrust-based Microsoft-signer check |
Helpers/HostBuilderExtensions.cs |
Registers IXamlTriageService |
Program.cs |
Intercepts the hidden __xaml-triage verb before host setup |
WinApp.Cli.csproj / Directory.Packages.props |
Restore-only debugger package references + pinned versions |
WinApp.Cli.Tests/* |
Tests for ZIP extraction, arg building, symbol-gap, version pinning, exception-record selection; fakes updated |
docs/usage.md, docs/debugging.md, docs/fragments/skills/.../setup.md, .github/plugin/.../SKILL.md, .claude/skills/.../SKILL.md |
Document the triage pass + WINAPP_DBGTOOLS_DIR |
src/winapp-npm/src/winapp-commands.ts |
Auto-generated schema-version comment bump |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Build Metrics ReportBinary Sizes
Test Results✅ 1611 passed, 1 skipped out of 1612 tests in 360.0s (+66 tests, -306.2s vs. baseline) Test Coverage❌ 18.3% line coverage, 37.4% branch coverage · ✅ +0.3% vs. baseline CLI Startup Time42ms median (x64, Updated 2026-07-11 02:43:21 UTC · commit |
nmetulev
left a comment
There was a problem hiding this comment.
Review: WinUI stowed-exception triage — findings + live end-to-end verification
I reviewed this across security, correctness, CLI UX, alternative-solution, test-coverage, docs/samples, and packaging, cross-checked the top findings on a second model family (GPT-5.4), and validated the feature end-to-end against a purpose-built crashing WinUI 3 app.
End-to-end verification ✅
Built a packaged WinUI 3 app that auto-crashes ~2 s after activation with 0xC000027B, then ran the branch-built winapp run --debug-output against it:
- Happy path works: ClrMD managed stack + full
!xamlstowed/!xamltriagebreakdown (ErrorCode 0xc000027b,ErrorMessage "IDispatcherQueueHandler::Invoke failed.", nativeCoreMessagingXP!DispatcherQueueTimer::TimerCallback → RoOriginateErrordispatch stack). - Acquisition pipeline validated: engine bits copied from the NuGet cache (pinned
20260319.1511.0),JsProvider.dll(~23 MB) downloaded from the WinDbg CDN + Authenticode-verified,winui-dbgext.js(137 KB) from GitHub — all cached under~\.winapp\dbgtools\. - Warm-cache re-run: 8.7 s, no re-download.
- M5 reproduced live (see inline): with
WINAPP_DBGTOOLS_DIRpointed at an empty dir and a fully warm cache, triage still reported "unavailable — setWINAPP_DBGTOOLS_DIR…" (the variable that was already set).
Findings
| ID | Sev | Location | Issue |
|---|---|---|---|
| H1 | High | CrashDumpService.cs:202 + XamlTriageService.cs:101 |
Triage OperationCanceledException (incl. HttpClient timeout) escapes and discards the already-computed managed crash stack → "Analysis failed." Regression on slow / first-run networks. |
| M1 | Med | .github/plugin/agents/winapp.agent.md:139 |
--debug-output agent doc not updated for triage / WINAPP_DBGTOOLS_DIR / first-run download. |
| M2 | Med | tests | No integration test asserts triage failure is non-fatal (fail-open) — would have caught H1. |
| M3 | Med | tests | AuthenticodeVerifier (the trust gate for a DLL loaded into the debugger) has no unit tests. |
| M4 | Med | XamlTriageBinaries.cs:287 |
Engine .nupkg downloaded + extracted with no package-hash/signature verification (HTTPS-only trust). |
| M5 | Med | XamlTriageBinaries.cs:107 |
WINAPP_DBGTOOLS_DIR override short-circuit → wasted egress into an ignored cache dir, then false "unavailable." |
| M6 | Med | WinDbgJsProviderAcquirer.cs:66 |
Non-atomic writes to final filenames (23 MB JsProvider + File.Copy sites) → concurrent / partial-file races. |
| L1 | Low | RunCommand.cs help |
--debug-output/--symbols help text doesn't mention triage or first-run downloads. |
| L2 | Low | AuthenticodeVerifier.cs:82 |
Revocation checking disabled (WTD_REVOKE_NONE) — no CRL/OCSP. |
| L3 | Low | CrashDumpService.cs:234 |
"triage written to the debug log" prints even when triage was skipped/unavailable; native value-add never surfaced in console. |
| L4 | Low | XamlTriageBinaries.cs:157 |
Cached JsProvider.dll not re-verified on cache hit (only at download time). |
| L5 | Low | XamlTriageBinaries.cs:275 |
Bespoke NuGet download duplicates INugetService (maintainability — note: NugetService also skips integrity checks, so reuse would not fix M4). |
Positives
JsProvider.dllis HTTPS + Authenticode-gated; the extension script is git-blob-SHA1-pinned and fail-closed;ZipArchive.ExtractToDirectoryavoids zip-slip; the ranged ZIP64 reader uses bounded reads. Packaging is clean (exact-pinned Microsoft packages, restore-only refs, AOT end-user path falls back to flat-container download).
Inline comments below anchor the actionable code-level items (H1, M4, M5, M6, L2, L3). H1 is the one blocking-worthy item; the rest are polish/hardening.
nmetulev
left a comment
There was a problem hiding this comment.
Follow-up review: all prior findings resolved — but stress-testing the real "download on first use" path surfaced a blocking issue
Thanks for the thorough pass on 11cafc7 — every finding from my first review is addressed, several with extra hardening (SHA-512 per-.nupkg, AtomicFile, JsProvider re-verify self-heal, revocation whole-chain). Re-verification table is at the bottom.
I then did what I'd flagged I hadn't done live the first time: exercised the genuine cold-cache "download on first use" path (my earlier happy-path run reused a warm cache from an earlier session). That's where the problem is.
🔴 Blocking — JsProvider.dll / engine version drift breaks triage for every fresh user
The engine and the JS provider are pinned to different, incompatible sources:
| Binary | Source | Version I got today |
|---|---|---|
dbgeng.dll (engine) |
NuGet Microsoft.Debugging.Platform.DbgEng 20260319.1511.0, SHA-512-gated |
10.0.29547.1002 |
JsProvider.dll |
WinDbg "current" appinstaller (WinDbgJsProviderAcquirer.cs:29) |
10.0.29617.1000 (30.7 MB) |
The "current" WinDbg bundle rolls forward independently of the NuGet-pinned engine. Loading the newer JS provider into the older engine makes the triage child process exit 0x80000003 (STATUS_BREAKPOINT, -2147483645), and triage is silently skipped — the exact headline feature this PR adds never runs.
Reproduced from a genuinely empty ~\.winapp\dbgtools\, both acquisition paths:
- Scenario A — dev/CI cold (
NUGET_PACKAGEShas the debugger packages): engine copied from the NuGet cache, JsProvider downloaded (30.7 MB "current") → triage skipped, ~16.6 s. - Scenario B — true end-user cold (
NUGET_PACKAGESempty → engine must download): engine downloaded from api.nuget.org flat-container (the live.nupkgpassed the compiled SHA-512 gate ✅ — that path is solid), JsProvider downloaded (30.7 MB "current") → triage skipped, ~20.7 s. - Control: dropping in a version-matched
JsProvider.dll(10.0.29547.1002, 23 MB) → triage succeeds with the full verdict (0xc000027b — "IDispatcherQueueHandler::Invoke failed.").
So the only variable separating success from skip is the JsProvider build. Why it hides in dev: iterative dev keeps a warm cache whose JsProvider was fetched when "current" happened to equal the pinned engine build — which is exactly why my first-pass happy path looked green. A user (or CI, or me with a cleared cache) downloading today gets the mismatch.
Root cause: AppInstallerUrl always resolves to the "current" MainBundle. FallbackBundleUrl (WinDbgJsProviderAcquirer.cs:32) is only used when the appinstaller fails to parse, so a version-matched bundle is never the primary source, and nothing checks that the two binaries agree after acquisition.
Any one of these closes it:
- Pin JsProvider to a WinDbg bundle whose build matches the pinned
DbgEng, and make that bundle the primary source (not just the parse-failure fallback). Add a unit test assertingJsProviderbuild == engine build — mirroring the excellent.nupkgSHA-512 drift test you already added for the engine. - Or add a post-acquire compatibility check: compare
dbgeng.dllandJsProvider.dllProductVersion; on mismatch, reject the JsProvider (re-acquire the matched bundle, or fail-closed) so self-heal + honest messaging kick in instead of a silent child crash. - Either way, surface a clearer skip reason than
exited with code -2147483645— detect the mismatch and say so.
Residuals (non-blocking)
dbgeng.dllcorruption has no self-heal (Low).ResolveExistingre-verifies onlyJsProvider.dll's Authenticode on a cache hit; a truncated/corrupt engine DLL passesFile.Exists, the child crashes, triage skips, and the cache stays poisoned across runs (JsProvider self-heals, engine doesn't). Managed stack is still preserved. Consider extending the re-verify (size or hash) to the engine bits too.- H1 console noise on the download timeout (Low). The H1 fix correctly preserves the managed stack now (verified with a live 5-min induced timeout 👍), but the raw
TaskCanceledExceptionstack still prints to the console and the user waits the full 5-minuteHttpClient.Timeoutbefore the crash stack appears. Consider a shorter default JsProvider-acquisition timeout and suppressing the raw stack.
Re-verified fixed this pass ✅
H1 (managed stack preserved on timeout — double-guarded) · L3 (XamlTriageResult surfaces the one-line verdict in console) · M5 (override no longer wastes egress; DescribeOverrideGap is honest) · M6 (AtomicFile staged-write + publish) · L4 (cached JsProvider re-verified every resolve → truncation self-heals) · M4 (compiled-in SHA-512 per .nupkg, latest fallback dropped → fail-closed; drift test passes) · L2 (revocation whole-chain, cache-only so no offline hang; CERT_E_REVOKED hard-fails). Triage unit tests: 63/63 pass (the 5 failing E2E_NodeSubcommand_* are pre-existing and unrelated — they need npm run build).
|
✅ Revalidated Rebuilt at Cold first-run (the scenario that was broken): cleared Compat-gate self-heal: planted a valid Microsoft-Authenticode-signed but wrong-version DLL ( Engine self-heal (my earlier residual): truncated the cached Nice work — you took both recommendations (pin the matched bundle and add a runtime compat gate), closed the two residuals I'd left (engine PE self-heal + the H1 timeout console noise via the new One tiny non-blocking thought: |
The drift guard compared two hand-maintained constants, so a DbgPackageVersion bump shipping a new dbgeng build would not be caught. Read the actual dbgeng.dll product version from the restored (restore-only) NuGet package and assert PinnedJsProviderProductVersion matches it, using the same VersionsMatch normalization the runtime gate uses and the same Inconclusive-when-absent tolerance as the .nupkg SHA-512 drift test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Enhance
winapp run --debug-outputcrash stacks with WinUI stowed-exception triageCloses #549
What
When
winapp run --debug-outputcaptures a crash from a WinUI 3 app, the resulting stack was thin — the user's faulting frame plus a couple of cswinrt ABI shims, with no visibility into the XAML dispatcher / CoreMessaging / runtime chain that led to the failure, and no breakdown of stowed exceptions (0xC000027B), the most common WinUI crash shape.This PR adds an automatic WinUI stowed-exception triage pass on top of the existing ClrMD analysis. When
Microsoft.UI.Xaml.dllis present in the crashed process's module list, winapp now surfaces:Microsoft.UI.Xaml→CXcpDispatcher→CoreMessagingXP→ CLR host)The feature is auto-enabled for WinUI apps — no new flag. The standard managed/native analysis is unchanged for all other apps.
How
Microsoft.Debugging.Platform.DbgEngNuGet package and runs the WinUI team'swinui-dbgext.jsextension (!xamlstowed/!xamltriage) against the same minidump — no WinDbg install required.symsrv.dll(fromMicrosoft.Debugging.Platform.SymSrv) next todbgeng.dllsosrv*symbol paths resolve.JsProvider.dll(the JS scripting host, not on NuGet) is fetched on first use directly from the official WinDbg download using ranged reads (only the few hundred KB needed), Authenticode-verified as Microsoft-signed, and cached under the winapp global directory.Directory.Packages.propsvia a drift test) so dev/CI acquisition is deterministic.WINAPP_DBGTOOLS_DIRto a directory containingdbgeng.dllandJsProvider.dll. When the binaries can't be obtained, triage is skipped with a clear log note and the standard analysis still runs.--symbolsadditionally improves the native XAML dispatch stack for WinUI apps.Testing
Directory.Packages.props.Docs
docs/usage.md(--debug-output/--symbols) and thesetup.mdskill fragment;docs/debugging.mddocuments the triage pass andWINAPP_DBGTOOLS_DIRoverride. Regenerated Copilot/Claude skills.Notes
!xamlstowedproduces the highest-value output and works fully with the NuGet + JsProvider setup.!xamltriage's managed-frame walker depends on SOS/CLR data access; winapp instead combines its own ClrMD managed stack with the extension's native output, so managed frames are always resolved.Services/XamlTriageService.cs,XamlTriageBinaries.cs,XamlTriageRunner.cs,WinDbgJsProviderAcquirer.cs,Helpers/ZipRangeExtractor.cs,Helpers/AuthenticodeVerifier.cs; triage slots in afterCrashDumpService.AnalyzeWithClrMD.