Repository navigation
feat: add WinUI ETW performance recording and analysis - #837
Conversation
Add non-elevated performance diagnostics for investigating expensive XAML layout, rendering, and scrolling, with readable console output and bounded JSON queries for agents. - Add perf start, status, mark, stop, and analyze commands, with app selection by PID, process name, or window title. - Integrate recording with run --profile across packaged, unpackaged, and execution-alias launch paths. - Support detached, duration- and size-limited captures that finalize automatically when the target app exits. - Preserve standard ETL recordings and generate disposable NDJSON caches for querying operation trees, element costs, slow-frame hotspots, event evidence, and managed GC suspension overlap. - Report incomplete coverage and unavailable GC data explicitly, without presenting elapsed timings as CPU usage or overlap as proof of causation. - Add native capture and analysis tests, npm wrappers, and performance documentation and agent guidance.
There was a problem hiding this comment.
🟡 Changes recommended
Malformed IPC can kill captures, orphan recovery can remain stale, and cache cleanup can delete through a junction.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds end-to-end WinUI ETW performance capture, profiling, offline analysis, npm bindings, tests, and guidance.
Changes:
- Adds
perflifecycle/analysis commands andrun --profile. - Implements bounded ETW, WinUI, and GC processing.
- Adds comprehensive tests and user/agent documentation.
File summaries
| File | Description |
|---|---|
src/winapp-npm/test/winapp-commands-run.test.ts |
Tests npm perf/profile forwarding. |
src/winapp-npm/src/winapp-commands.ts |
Adds generated npm APIs. |
src/winapp-npm/scripts/generate-commands.mjs |
Generates required option types. |
src/winapp-CLI/WinApp.Cli/Services/Performance/WinUiEventDecoder.cs |
Decodes WinUI ETW events. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PrivateEtwSession.cs |
Manages private ETW sessions. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfGcAnalyzer.cs |
Builds GC intervals. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfEtwReader.cs |
Reads native ETL records. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfControlChannel.cs |
Implements worker IPC. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfCaptureWorker.cs |
Runs capture lifecycle. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfCaptureService.cs |
Prepares and controls captures. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfCaptureDocument.cs |
Defines capture metadata. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfAnalyzer.cs |
Builds elements and operation trees. |
src/winapp-CLI/WinApp.Cli/Services/Performance/PerfAnalysisStore.cs |
Creates verified analysis caches. |
src/winapp-CLI/WinApp.Cli/Services/Performance/ClrGcEventDecoder.cs |
Decodes CLR GC events. |
src/winapp-CLI/WinApp.Cli/Program.cs |
Dispatches workers and JSON errors. |
src/winapp-CLI/WinApp.Cli/NativeMethods.txt |
Adds required ETW APIs. |
src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs |
Registers performance services/commands. |
src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs |
Exposes the perf command. |
src/winapp-CLI/WinApp.Cli/Commands/RunCommand.ProjectMode.cs |
Profiles unpackaged launches. |
src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Performance.cs |
Implements run --profile. |
src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs |
Integrates profiling across launch paths. |
src/winapp-CLI/WinApp.Cli/Commands/PerfCommand.cs |
Defines perf CLI operations. |
src/winapp-CLI/WinApp.Cli.Tests/TestApps/PerfNativeProbe/Program.cs |
Adds native ETW probe. |
src/winapp-CLI/WinApp.Cli.Tests/TestApps/PerfNativeProbe/PerfNativeProbe.csproj |
Configures NativeAOT probe. |
src/winapp-CLI/WinApp.Cli.Tests/TestApps/PerfGcFixture/Program.cs |
Generates GC activity. |
src/winapp-CLI/WinApp.Cli.Tests/TestApps/PerfGcFixture/PerfGcFixture.csproj |
Configures GC fixture. |
src/winapp-CLI/WinApp.Cli.Tests/RunCommandTests.cs |
Hardens fake launch PIDs. |
src/winapp-CLI/WinApp.Cli.Tests/RunCommandProjectModeTests.cs |
Tests profile launch ordering. |
src/winapp-CLI/WinApp.Cli.Tests/PerfScopeTests.cs |
Tests scope accounting. |
src/winapp-CLI/WinApp.Cli.Tests/PerfGcTests.cs |
Tests GC decoding/intervals. |
src/winapp-CLI/WinApp.Cli.Tests/PerfEtwTests.cs |
Tests native ETW behavior. |
src/winapp-CLI/WinApp.Cli.Tests/PerfCommandTests.cs |
Tests perf CLI contracts. |
src/winapp-CLI/WinApp.Cli.Tests/PerfCaptureCompletionTests.cs |
Tests finalization behavior. |
src/winapp-CLI/WinApp.Cli.Tests/PerfCallQueryTests.cs |
Tests queries, paging, and bounds. |
src/winapp-CLI/WinApp.Cli.Tests/PerfAnalysisTests.cs |
Tests analysis safety/correctness. |
README.md |
Links the performance workflow. |
plugins/winapp/skills/winapp-ui-automation/SKILL.md |
Connects UI automation to profiling. |
plugins/winapp/skills/winapp-troubleshoot/SKILL.md |
Routes performance troubleshooting. |
plugins/winapp/skills/winapp-performance/SKILL.md |
Adds the performance agent workflow. |
docs/usage.md |
Documents perf and profiling entry points. |
docs/guides/winui-performance.md |
Adds the full performance guide. |
docs/debugging.md |
Links performance diagnostics. |
Review details
- Files reviewed: 45/45 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Nikola Metulev (nmetulev)
left a comment
There was a problem hiding this comment.
🤖 AI-generated review (winappcli pr-review skill) — verify before acting.
Requesting changes. The feature's scope makes sense, but I would fix the five issues below before merging. They affect whether recordings survive and whether the results can be trusted. None requires expanding this into a general-purpose profiler.
Fix before merging
Cancelling another command also kills the recording helper
Example: Run winapp perf start --app MyApp --output .\capture --duration-sec 120. It returns your terminal prompt while recording continues. Run another command in that terminal, then press Ctrl+C to cancel it. The recording helper also exits, and perf status <id> reports failure.
Why this matters: You intended to cancel the other command, not the recording. Redirecting the helper's input/output makes it quiet but leaves it attached to the same Windows console. Killing the helper is not a clean stop; tracing in the target can remain active without its supervisor.
Proposed fix: Launch the helper without inheriting the terminal's console lifetime. Keep the existing stop, duration, size, and target-exit handling, and verify that the helper still exits through those paths.
Recovery fails if the helper dies and the app then closes
Example: Start recording, terminate its helper, then close the app. perf stop <id> fails because the app's process no longer exists. In the reproduction, the recording file remained on disk, but capture.json still said recording with an empty traceFiles list. perf analyze refused to open it.
Why this matters: A useful recording becomes inaccessible through the CLI. Retrying stop hits the same failure.
Proposed fix: When the original target is gone, still find the saved trace files and finalize the metadata as incomplete, with unknown event loss. Only attempt native session cleanup when the original process identity still matches; do not touch a replacement process that reused its PID.
Missing UI evidence can look like a successful performance report
Example: A capture contains garbage-collection events but no usable UI timing events. perf analyze .\capture --view hotspots --json returns exit code 0, coverage.complete: true, and no rows.
Why this matters: "No UI evidence was recorded" is not the same as "we found no slow frames." The current check accepts GC evidence for a UI query, then the query excludes it. The same check also blocks --view events when retained events have unsupported formats, hiding the diagnostic details that explain the problem.
Proposed fix: Check for evidence appropriate to the requested view. GC alone must not establish usable UI coverage. Let the events view return retained unsupported-event details as partial evidence instead of telling the user to repeat an unhelpful recording.
Dropped events can make two short GC pauses look like one long pause
Example: Suppose the app pauses for garbage collection at 0-3 ms and again at 97-100 ms: 6 ms total. If the first pause's ending and the second pause's earlier boundaries are lost, the remaining events can be paired into one apparently complete 0-100 ms pause.
Why this matters: The report can substantially overestimate GC's overlap with a slow frame. It still describes partial overlap as a "lower bound," even though this reconstruction can be larger than the actual pause time. A general event-loss warning does not make that numerical claim correct.
Proposed fix: Carry known event-loss uncertainty into suspension analysis. Preserve uncertain boundary evidence, but do not use an uncertifiable pairing as a complete pause in guaranteed lower-bound overlap calculations.
PerfGcAnalyzer.cs:106-113 and PerfAnalysisStore.cs:239-251
The console report hides information needed to understand the result
Example: A layout operation spans the selected 5-35 ms range. --view elements --from-ms 5 --to-ms 35 prints count 0 and elapsed/self time 0. The JSON correctly contains one boundary-crossing operation and 30 ms of overlap, but the console does not show those fields.
Following an evidence ID with --view events --event v1 also prints the event name and unknown timings, but omits its timestamp, start/end phase, and recorded values.
Why this matters: A busy interval can look idle, and inspecting an event does not reveal its evidence unless the user switches to JSON.
Proposed fix: Print the existing boundary-overlap count and clipped duration alongside the wholly-contained totals. Render individual events with their timestamp, phase, and available payload fields rather than inapplicable duration columns.
Non-blocking improvements
Element-filtered events lose the template operation's end
Example: An ApplyTemplate call belongs to element e1 and references start/end events v1,v2, but --view events --element e1 returns only v1. The stop event has no element in its payload, although the analyzer already knows which operation it closes.
Why this matters: Element-focused investigation loses closing evidence that the tool has already associated successfully.
Proposed fix: Give a matched stop event the scope's element ID when the stop has no element identity of its own.
The agent skill creates scenario markers but does not use them
Example: Record eleven slow preparation frames, then one shorter frame between the scenario markers. The skill's unfiltered hotspot query returns ten preparation frames and excludes the marked scenario from its first page.
Why this matters: An agent can spend its initial queries and context investigating setup instead of the interaction the user asked about.
Proposed fix: Put --from-marker scenario-start --to-marker scenario-end in the initial query, using the marker names just created, and retain that range for subsequent rankings and GC comparison.
winapp-performance/SKILL.md:16-27
Reproduction scope
The behaviors above were reproduced with published CLI binaries and controlled ETW recordings. The GC-loss case used a deliberately incomplete event sequence and simulated loss metadata; it was not a measurement of a real app's GC pause. Ctrl+C was sent in an isolated console.
NativeAOT publishes succeeded for x64 and ARM64. The targeted C# suite had 302 passes and 2 skips; npm wrapper tests had 11 passes. A live WinUI application's provider coverage and packaged/alias launches were not exercised end-to-end.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eec84ca0-c432-4ed5-9aaf-ef6c5ce3175f
- isolate suppressed background workers from the invoking console - validate evidence by query view and preserve unsupported event diagnostics - avoid certifying GC overlap when ETW event loss is known or unknown - include boundary overlap and event details in console output - propagate element identity from matched scopes to stop events - bound performance analysis workflows with scenario markers - add regression coverage for each reported issue
…nt breakdowns, and improve textual output from analyze commands
… into dotmorten/perf
Nikola Metulev (nmetulev)
left a comment
There was a problem hiding this comment.
🤖 AI-generated review (winappcli pr-review skill). Each finding below was checked by running the CLI or reading the code, but it may still be wrong or miss context. Morten Nielsen (@dotMorten), please verify each one yourself before changing anything, and push back where you disagree.
All five issues from the previous review are fixed and covered by tests. Recording works well on real apps: run --profile, perf start/mark/stop, repeated stop, and finalization when the app exits all behaved correctly.
Below are the issues found in this pass, roughly in order of user impact.
1. On real apps, page loads lose their durations and almost every analysis is reported as partial
What is wrong: There are two independent causes. Both come from how WinUI writes these events, and the analyzer's begin/end pairing doesn't handle either one.
- Component loads are logged twice, and the copies interleave. WinUI writes each component load as a legacy event (
ApplicationLoadComponent) and as a newer event (Application_LoadComponent), in this order: legacy begin → new begin → legacy end → new end. The analyzer pairs begin and end events with one stack per thread. When the legacy end arrives, it pops the newer begin asmissing-endand marks the outer loadcorrupt-boundaries. The newer end then becomesmissing-begin. Corrupt calls have no duration, and the corruption spreads to ancestors (MeasureElement,MeasureOverride,ParseXaml). - Image decodes only have an end event. WinUI defines
ImageCacheDecodeBegin(event 145) but never emits it; onlyTraceImageCacheDecodeEndis called (dxaml/xcp/components/imaging/ImageCache.cpp). Every decoded image therefore becomes amissing-beginincomplete call.
Show me: Run winapp run AIDevGallery.csproj --arch arm64 --profile .\cap --detach, navigate Samples → Models → AI APIs → Settings → Home between perf mark calls, then perf analyze .\cap --view summary.
- Event order on the UI thread:
v117 ApplicationLoadComponent begin→v118 Application_LoadComponent begin(both at 400.57 ms) →v1429 ApplicationLoadComponent end→v1430 Application_LoadComponent end(both at 581.72 ms). - Actual: call
c2(400.57–581.72 ms) iscorrupt-boundarieswith no duration. Across the capture, 25 component loads covering 573 ms of time have no duration. There are also 21missing-beginand 21missing-endnew-format loads, 14MeasureElementand 13MeasureOverridemarked corrupt, and 52ImageCacheDecodewithmissing-begin(52 end events, 0 begin events recorded). The analysis exits 1 with "Some UI operations have missing or inconsistent start/end events". - The image cause alone is enough. A capture of the one-window
samples/winui-unpackaged-apphas no interleaved loads, but its singleImageCacheDecodemissing-beginstill makes every view exit 1. - Expected: page loads paired with durations counted toward Parsing, end-only events not treated as broken calls, and exit 0 when nothing is actually missing.
Why it matters: The Parsing figure leaves out page-load time, which is usually what someone investigating slow navigation wants to see. Because nearly every real capture exits nonzero, a partial result can't be told apart from a clean one. The npm perfAnalyze() wrapper also throws (see below).
Smallest fix: In PerfAnalyzer.Accept, when an end arrives, close only the matching begin. Leave unrelated open scopes on the stack instead of popping them as missing-end and marking ancestors corrupt; the two load events then pair independently. Treat ImageCacheDecode end as a point event, or drop it from the scope table. Add a test with the interleaved order above and one with an end-only decode.
PerfAnalyzer.cs:79-110, WinUiEventDecoder.cs:54-55
2. run --profile --on sandbox reports success but records nothing
What is wrong: Both sandbox launch paths return before the recording is prepared or bound, so no recording worker starts. --profile then sees no error and returns the launch's exit code.
Show me:
- Unpackaged:
winapp run . --on sandbox --profile .\cap --detach --json→ exit 0, JSON contains"Sandbox": trueand noProfileblock,.\capis never created, and the app is running in the sandbox. - Packaged (
samples/winui-app): the same command without--jsonprints "Started the application in Windows Sandbox (PID: 4184)" and exits 0, with no warning. - Expected: an error that
--profileonly records on this machine.
Why it matters: A user or agent believes the run was profiled when nothing was recorded. winapp perf start --on sandbox already refuses with "always runs on this machine", so only run --profile slips through.
Fix (author's choice):
- Support it: record inside the sandbox and bring the capture back to the host, or
- Fail clearly: in
RunCommand.Performance.InvokeAsync, reject--profilewhenExecutionTargetSelection.Resolve(parseResult)isn't local, the same wayperf startalready does, and note in the guide that profiling runs on this machine only.
Either way, add a test.
RunCommand.Performance.cs:54, RunCommand.cs:742, RunCommand.ProjectMode.cs:382
3. "App callbacks" always reports 0 ms
What is wrong: This category counts only the EventCallback event (IDs 15/16). The WinUI manifest marks EventCallbackBegin as notLogged="true", and nothing in the WinUI source emits it.
Show me: In both captures above, the first table shows App callbacks 0.000 0.0%, and events.ndjson has 0 EventCallback events. The AI Dev Gallery capture ran navigation handlers on every page change. Expected: either real callback time or no row.
Why it matters: The report's first table tells users their event handlers cost nothing, while that time actually lands in "Unclassified".
Smallest fix: Remove the "App callbacks" category and the EventCallback decoder entries.
PerfQuery.cs:588-608, WinUiEventDecoder.cs:38-39
4. Two documented commands fail when copied
What is wrong: The guide gives the wrong duration flag for run --profile, and the usage example reads a JSON property that doesn't exist.
Show me:
docs/guides/winui-performance.md:10:winapp run . --profile .\cap --duration-sec 60→Unrecognized arguments: '--duration-sec', '60'. The option is--profile-duration-sec. Line 62, aboutperf start, is correct.docs/usage.md:2161:winapp perf stop $capture.captureId→Required argument missing for command: 'stop', becauseperf start --jsonreturnsid, notcaptureId. The recording keeps running until its deadline.
Why it matters: These are the first commands readers copy.
Smallest fix: Use --profile-duration-sec on guide line 10, and $capture.id in usage.md.
5. npm perfAnalyze() throws on partial results
The CLI uses a nonzero exit to mean "partial, stdout still usable", but callWinappCliCapture rejects on any nonzero exit, and npm-usage.md says non-zero throws. The usable result is only reachable through the undocumented error.stdout. Fixing #1 makes this rarer but doesn't remove it. Either resolve for the partial-data exit or document error.stdout. (winapp-cli-utils.ts:242)
6. Truncated resource names lose the part that tells them apart
The parsing table cuts long URIs at the end, so ms-resource:///Files/Controls/HomePage/... appears on two rows with different times, and the sample app shows two identical ms-resource:///Files/Microsoft.UI.Xaml;... rows. Truncate in the middle and keep the file name.
7. Two test fixtures are never built or run
TestApps/PerfNativeProbe and TestApps/PerfGcFixture (about 290 lines) are excluded from the test project and referenced by no test, script, or workflow, so they can break without anyone noticing. Please wire them into a test that builds and runs them against a real capture, so they stay current and catch regressions in native and GC recording.
8. Capture startup by launching the app paused, instead of asking users to edit their app
The guide's "Recording startup layout" section tells users to add a 10-second Thread.Sleep to Main, sometimes by disabling the generated entry point. The new skill says "never modify app code to make capture work", so an agent following both gets conflicting instructions. run --profile can close the startup gap itself: create the process suspended, start the private session for its PID, then resume. The repo already does this in GuestProcessHost (CREATE_SUSPENDED + ResumeThread). For packaged activation, IPackageDebugSettings.EnableDebugging starts the app suspended and passes its PID to a helper command; winapp already uses that interface for termination. This would work for any framework without app changes, and the sleep recipe could then be removed. Please check that the private session captures a process that starts suspended before relying on it.
What was exercised
- NativeAOT
dotnet publish -r win-x64succeeded. Targeted tests (Perf, RunCommand, AppLauncherService) passed: 460 passed, 2 skipped (the skips need long-path support disabled or a live WinUI PID). - Real apps:
samples/winui-unpackaged-app(run --profile,perf start/stop) and AI Dev Gallery arm64 (run --profile --detach,perf mark, navigation viawinapp ui invoke,perf stop, analyze over the marker range). - Sandbox: unpackaged and packaged
run --on sandbox --profile. - Not exercised: packaged execution-alias launch with
--profile, the npm wrapper at runtime, MSIX/NuGet packaging, and the full repository test suite.
Use only modern Application_LoadComponent events for load timings. Treat end-only ImageCacheDecode events as informational. Add regression coverage.
Fail run --profile --on sandbox explicitly instead of reporting success without recording. Add rejection tests and document that profiling is local-only. Will revisit sandbox support in a future PR.
…ies 15/16. The guide now clarifies that uninstrumented handler time remains in **Unclassified**, rather than implying callbacks cost zero. Schema, decoder, and analyzer versions are unchanged. Changes are uncommitted; pausing for your review before moving to item microsoft#4.
Preserve evidence, diagnostics, and exit code 1 for partial analysis. Keep genuine failures rejecting and document coverage handling.
Middle-truncate resource names in summary and parsing tables. Keep full URIs in JSON output and cover truncation boundaries.
Add real-capture coverage for native payloads and timed GC intervals. Fix the native probe's EventRegister signature and clean up processes.
Replace post-launch profiling attachment with a debugger-gated startup sequence that enables ETW providers before application code begins. - Add architecture-aware entry-point breakpoints and restore instructions and thread context before detaching the debugger and resuming execution. - Support unpackaged launches, packaged activation through a private startup helper, and execution aliases. - Scope package debugging to activation and validate helper ownership, package identity, process creation time, and activation thread identity. - Use credentialed worker status messages for readiness instead of polling capture files, avoiding concurrent manifest-read/write failures. - Terminate owned paused processes on setup failure or cancellation. - Preserve attach-only coverage when activation reuses an existing process. - Add native first-event capture, ARM64/x64/WOW64 execution, readiness, cancellation, failure cleanup, and control-channel regression coverage. - Update CLI, npm, guide, and plugin documentation to describe startup coverage and the exclusion of earlier DLL initialization and TLS callbacks
Ignore unclassified diagnostic decode errors when tracking operation boundaries, while retaining conservative handling of malformed known scopes. Bump the analyzer cache version to rebuild affected captures and add regression coverage for nested timings and cache invalidation.
Reload the private control registration after the capture worker disconnects so finalized startup recordings retain their bound target. Add regression coverage for completed recordings, genuine capture failures, and mismatched targets in editable capture manifests.
Disable debug-heap initialization for profiled executable and packaged launches. Reject startup profiling from an emulated x64 CLI on ARM64 Windows before building or launching, with guidance to use the ARM64 CLI. Add regression coverage and document the architecture requirement.
Per-user ETW sessions accept only process and executable-name filters, so Windows ignores the event-ID filter for XAML-Diagnostics and the .NET runtime. During UI-heavy work such as repeated page navigation, events the analyzer never uses can fill the old 128 MiB default within seconds. Raise the default size limit for `perf start --max-size-mib` and `run --profile-max-size-mib` from 128 to 512 MiB. When Windows skips event filtering, record a warning in capture.json that names the affected providers and tells users how to recover if recording stops with `size-limit`. Update the WinUI performance guide and the regenerated CLI schema, and add a test for the warning.
- Finish a capture as "target-exited" when the ETW session can't be stopped (error 87) because the app crashed and Windows Error Reporting is still holding it. Previously the recording ended as "failed". Stopping now waits up to 30 seconds for the app to exit, in attach mode only. - Free the private ETW session even when stopping it fails during Dispose. - Scope elevated captures of packaged apps to the package. The package full name now comes from a per-user lookup that works without admin. The shared lookup in AppLauncherService only works with admin, so elevated captures ran unfiltered and could not identify the app. - Show a clean error when the user declines the UAC prompt. It no longer has a double period or the unrelated note about the worker shutdown deadline. - Stop repeating capture warnings on every `perf mark`. - Skill: if the user declines elevation, don't retry; fall back to attach mode and say that early startup may be missing.
- The native fixture test expects the wrong cache version - Failed app identification widens elevated recording to other processes - Elevated writes use a replaceable output-directory path
|
Tried this on a packaged native C++/WinRT app (ARM64) and it worked well. The question I was chasing was a bit different, though: why the app was heavier (more memory, more threads, more total CPU), not why it was slower. A few things I ended up needing other tools for. Sharing in case any of them are useful; totally your call whether they fit here, in a follow-up, or not at all:
Happy to share more details on any of these if helpful. |
user/kernel/total CPU, private bytes, working set, thread count, and handle count. Captures sample approximately once per second; the default analysis shows a first/last/observed-maximum summary.
…dence Extend `winapp perf` to capture early startup and help distinguish expensive XAML work from process-wide resource costs and app-side composition submission. **Elevated startup recording** - Add `--profile-mode elevated` to start recording before app launch, while keeping the app unelevated. - Restrict collection to the identified app package or executable. Failed app identification stops recording setup rather than widening collection to other processes. - Protect output and control directories, including their ancestors, against replacement or redirection while the elevated recorder is active. - Report when activation reuses an existing process, so attachment is not mistaken for startup coverage. **Process-resource evidence** - Automatically sample user/kernel CPU, private bytes, working set, thread count, and handle count. - Include resource summaries in the default report and expose a paged `--view resources` timeline with marker and timestamp filtering. - Report CPU across all target-process threads, helping identify increased resource cost even when UI-thread timings remain unchanged. - Keep sampling failures explicit and allow resource queries without usable XAML events. **Composition evidence** - Decode WinUI `CommitMainDevice` timing and optionally collect DirectComposition surface-update calls: `DCompBeginDraw`, `DCompEndDraw`, and `DCompUpdateToken`. - Expose `DCompDeviceCommit` notifications with device/channel and batch identifiers. - Add the `composition` call family and a separate **Composition submission** category in the default UI-thread activity summary. - Report optional-provider availability without discarding usable XAML evidence. Update CLI documentation, the performance guide, shipped performance skill, and native/npm regression coverage. Schema, decoder, and analyzer versions remain **1**. ## Usage Example PowerShell ``` # Record startup before launch; Windows prompts to elevate only the recorder. winapp run . --profile .\traces\startup --profile-mode elevated --detach # Inspect the recording after it finishes. winapp perf analyze .\traces\startup # Compare process-wide CPU, memory, thread, and handle counters. winapp perf analyze .\traces\startup --view resources --json # Inspect app-side composition timings and commit notifications. winapp perf analyze .\traces\startup --view calls --family composition --json winapp perf analyze .\traces\startup --view events --event DCompDeviceCommit --json ``` ## Related Issue No linked issue. ## Type of Change - 🐛 Bug fix - ✨ New feature - 📝 Documentation - 🧪 Test update ## Checklist - New tests added for new functionality (if applicable) - Tested locally on Windows - Main README.md updated (if applicable) — not applicable - docs/usage.md updated (if CLI commands changed) - Language-specific guides updated (if applicable) - Sample projects updated to reflect changes (if applicable) — not applicable - Shipped skills updated in `plugins/winapp/skills/` (if CLI commands/workflows changed) ## Screenshots / Demo A real WinUI capture using the published x64 CLI recorded **855 timed composition calls**, including **45 main-device commits**, plus **120 DirectComposition commit notifications**, with no decode errors or incomplete timed calls. This verifies event collection and decoding; it is not a performance-improvement benchmark. ## Additional Notes ### Validation - Focused performance suite: **210 passed, 1 optional live-provider test skipped**. - npm suite: **324 passed**. - After restoring decoder version 1, the NativeAOT fixture/cache regression was rerun: **1 passed**. - Native x64 and ARM64 builds passed, including a fresh `.\scripts\build-cli.ps1 -SkipAll` after the version correction. - `npm run build` passed; `npm run build-copy-only` refreshed npm binaries after the final native build. - Schema-aware plugin validation passed. - Runtime composition evidence was exercised on x64; ARM64 was built but not tested on ARM64 hardware. **Full-suite validation remains incomplete:** an earlier complete CLI run hung in `PackagedSandboxMutationLockTests.ConcurrentPackagedSandboxRuns_SameDeployment_SerializeRegistrationNotLaunch`. The focused results above do not establish that every repository test passes. ### Interpretation limits Process counters cover the target process, not child processes or individual threads/modules. Sampling begins after process binding, runs approximately once per second, and can miss brief spikes. CPU summaries use actual retained samples without interpolating range boundaries; fewer than two samples means interval CPU is unavailable. Composition timings measure app-side elapsed API time, including possible waits. They do **not** measure GPU execution, DWM processing, displayed frames, FPS, or display latency. Commit notifications are not treated as timed calls or presented frames. This PR does not enable DXGI Present collection or add frame-presentation correlation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3e28624e-44d2-4095-8dca-04fb49e8bc93
Nikola Metulev (nmetulev)
left a comment
There was a problem hiding this comment.
🤖 AI-generated review (winappcli pr-review skill) — verify before acting.
Reviewed at fb8dfbab. Recording and analysis worked well on AI Dev Gallery (a large packaged WinUI app) in both attach and elevated mode. The fixes for overlapping load events, the removed "App callbacks" category, the --on sandbox rejection, the docs corrections and the wired-up fixtures all check out.
Must fix
1. The elevated recorder writes through paths an unelevated process can change
- What is wrong: With
--profile-mode elevated, the admin-level recorder writes<output>\trace.etlinto a directory that the normal user can still write to. It doesn't reserve the file or reject a link first. The directory lease only stops the folder from being renamed (FileShare.ReadWrite), so new files can still be created inside it. The recorder also reads its settings fromcontrol.jsonafter the UAC prompt, and that file sits in a folder where the user has FullControl. Any process that can readcontrol.jsoncan then sendarmover the pipe with an executable name of its choice. - Show me (reproduced): Developer Mode allows unprivileged symlinks.
- An unelevated background job waited for
.capture-ownerto appear in the output directory. - The job created
trace.etlas a symlink to%TEMP%\victim.txt. - I ran
winapp run AIDevGallery.csproj --profile <dir> --profile-mode elevated --detachand approved UAC.
- Actual: the victim file was overwritten with 15 MB of ETL data. The size limit didn't apply either, because the size check measures the 0-byte symlink.
- Expected: the recorder refuses a link or a file that already exists.
- The
control.jsonswap was identified by reading the code; I didn't run it.
- An unelevated background job waited for
- Why it matters: A process running as the user can turn the user's approval of a legitimate winapp UAC prompt into an admin-level overwrite of any file, such as one under
C:\Windows. Most winapp users have Developer Mode enabled. - Smallest fix: Either option works:
- (a) Have the elevated recorder create its ETL inside a directory it owns that normal-integrity processes can't write to, then publish the file after it stops. Pass the scope, limits and output directory to it as command-line arguments set before UAC, not through mutable
control.jsonor pipe messages. - (b) Move elevated mode to a follow-up PR. In my runs, attach mode already bound to AI Dev Gallery at about 0.09 ms (6 threads, 3.3 MiB private). It recorded
App.xbf(544 ms),MainWindow.xbfandHomePage.xbfparsing, so elevated mode added little for this app.
- (a) Have the elevated recorder create its ETL inside a directory it owns that normal-integrity processes can't write to, then publish the file after it stops. Pass the scope, limits and output directory to it as command-line arguments set before UAC, not through mutable
- Location:
PerfCaptureService.cs:31-114,PerfCaptureDirectoryLease.cs:39,PerfCaptureWorker.cs:60-64, 190-197, 361,PrivateEtwSession.cs:100-104
2. Attach captures of a real app always report partial results because of one undecodable diagnostic event
- What is wrong: WinUI's
FallbackErrorevent is an error-logging event, not a timing event. Windows' event decoder (TDH) can't parse it, and that one failure marks the whole analysis incomplete. - Show me:
winapp perf analyzeon 3 of 3 attach-mode AI Dev Gallery captures (startup, and navigation between markers).coverage.incompleteCalls: 0, butdecodeErrors: 2-6, allFallbackError | Unsupported structured or variable-length TDH property.- The tool prints "Results may be incomplete" and exits 1.
- Expected: exit 0, because nothing timing-related is missing.
- Why it matters: Scripts and agents treat exit 1 as failure. If the exit code is nonzero on almost every real capture, it can no longer signal real data loss.
- Smallest fix: Keep known diagnostic events like
FallbackErrorthat don't affect timing, but don't count them as missing coverage (or decode their struct). Unsupported begin and end events should still count. - Location:
WinUiEventDecoder.cs:219,PerfQuery.cs:227-229, 378-380,PerfCommand.cs:272
Non-blocking
The winapp skill router doesn't send performance requests to the new skill
- What is wrong:
plugins/winapp/com.github.copilot/agents/winapp.agent.mdhas no row forwinapp-performance. Its rule 3, "Ask before admin-only actions", doesn't mention--profile-mode elevated. - Smallest fix: Add a row for slow WinUI layout, scrolling, rendering or GC investigations that points to
winapp-performance, and list elevated profiling in rule 3.
What was exercised
- Build: NativeAOT
dotnet publish -c Release -r win-x64. - Attach-mode launch profiling:
winapp run … --profile … --detach --jsonreachedcompleted / duration. The trace was 37 MB for 30 s, and analysis took about 4.5 s. - Attach with markers:
perf start,mark, 5 page navigations withwinapp ui invoke,mark,stop. Analysis of the marked range worked. - App exit during capture: stop reason
target-exited, and the retained events still analyzed. The app's crash also happens without profiling, so it isn't related to this PR. - Elevated mode: coverage was
recorded from launch (elevated session), analysis exited 0, and noWinApp-Perfsession was left behind. - Sandbox:
--on sandbox --profilewas rejected before build. - Tests: targeted perf tests passed (analysis/resource/completion, ETW/lease, profile run modes).
validate-plugin-package.ps1andvalidate-llm-docs.ps1passed. - Not exercised: arm64, the npm test suite, and the full CLI suite.
…e 3 requires asking before --profile-mode elevated .
…er, which checks the pipe’s owner before sending any control request or credential. The previous CurrentUserOnly option checks against WindowsIdentity.Owner . For an elevated process, that can be the Administrators group, whereas our worker deliberately makes the pipe belong to WindowsIdentity.User —the actual user’s SID. That mismatch caused the CI failures. The helper explicitly compares the pipe owner with the user’s SID instead. It still rejects pipes owned by another user or by Administrators, and disposes the connection on failure. The normal controller and malformed-request tests use the same check. The server’s access permissions and request authentication are unchanged.
Analyze only complete, usable WinUI operation pairs. Keep unmatched events as raw evidence without invalidating unrelated timings or reporting missing-boundary failures. Preserve matched parent durations while leaving unreliable self/exclusive times unavailable. Reparent complete descendants past omitted scopes and reject captures with no usable timings instead of reporting zero cost. Classify FallbackError as diagnostic evidence so unsupported payloads do not make timing queries partial. Preserve event-query decode errors, recording-loss warnings, and existing GC coverage behavior.
ETW can deliver the enable notification to an in-process EventSource after EnableTraceEx2 returns, so the immediate IsEnabled assertion failed on CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
.NET 10 only enables an EventSource for ETW sessions that EnumerateTraceGuidsEx reports for the process. The CI runner does not report the process-private session, so the in-process EventSource never enabled and the diagnostic test wrote no events. Write the same self-describing events through EventWrite instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Places main's new winapp-ui-automation facts in the core (scoped queries without a selector, exact-name matching, click or hover at a point, horizontal wheel in the exclusive list, performance pointer) or in references (touch --angle and horizontal wheel examples in gestures.md, restoring a minimized window in dialogs-and-state.md). The winapp run output change lands in winapp-identity's run-project-mode.md. Also fixes what #837 broke in the benchmark: winapp-performance maps to winui.build in a new current map (the old map stays for earlier results), and its description says 'crash analysis' instead of 'crash dumps', which collided with a demand fixture name. Cross-skill relative links to winapp-performance now name the skill. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…enchmarks/ui (#1053) ## What and why Adds `benchmarks/ui/`, two repeatable benchmarks for `winapp ui`. Rerun them after a `winapp ui` change to see its effect. - **`run.ps1` (agent benchmark).** A Copilot CLI agent does seven natural-language tasks in inbox Calculator and a pinned WinUI 3 Gallery (`v2.9.3`, built once by `setup-gallery.ps1`). The tasks include real-world friction: the app isn't running, two instances are open, the window is minimized, a dialog was left open, and a cold start. The harness scores the final app state itself through UIA, the process count and the window state. It never trusts the agent's answer, and it flags collateral damage such as extra instances, the wrong window changed, or an app closed. The report shows the pass rate, wall time, turns, tool vs model time, tokens and credits. Methods: `winapp` (this repo's plugin and CLI) and `none` (no plugin, `winapp` hidden). - **`perf.ps1` (scripted, no agent, no credits).** It times single `winapp ui` commands (20 runs after a warm-up; median, p95 and max). It also runs fixed command sequences that mirror the agent tasks, scored with the same checks. Both benchmarks refuse to start when Calculator or the Gallery is already open (unless you pass `-Force`), close only the processes they launched (by PID), and restore your Calculator mode afterward. `run.ps1` resumes when you rerun it into the same `-OutDir`. Other tools can be benchmarked locally with `-Method <file>` or `-Adapter <file>`; the README documents both formats. `benchmarks/agents/lib/Benchmark.psm1` is reused, not copied. Its only change is additive: `Read-SessionEvents` now also returns tool time, API time and session duration, and `Format-Count` is exported. ## Before / after New scripts; nothing in `winapp` itself changes. Observed output of `perf.ps1` on current `main` (x64 Xeon, NativeAOT build of `901e4cde`, Calculator only; excerpt): ```text > .\benchmarks\ui\perf.ps1 -Operation startup,list-windows,search,inspect-calculator,get-property,get-value,invoke,wait-for,focus,screenshot -Sequence calc-add,calc-two-instances,calc-minimized-scientific startup winapp median 91 ms p95 93 ms errors 0 list-windows winapp median 102 ms p95 105 ms errors 0 search winapp median 259 ms p95 267 ms errors 0 get-value winapp median 144 ms p95 158 ms errors 0 invoke winapp median 177 ms p95 183 ms errors 0 focus winapp median 176 ms p95 182 ms errors 0 [1/3] calc-add | winapp ... pass | 1,914 ms | 10 commands [1/3] calc-two-instances | winapp ... pass | 1,383 ms | 7 commands [1/3] calc-minimized-scientific | winapp ... pass | 1,993 ms | 10 commands ``` Agent benchmark, first full run on 2026-10-09: branch `93b2cbb8` on main `a87a8304`, ARM64 Snapdragon X2 Elite, Copilot CLI 1.0.93, `claude-sonnet-5.5`, 5 iterations. **70/70 passed with no collateral damage**, in 32 minutes and 497.5 credits. | Method | Median wall time | Median turns | Credits (35 runs) | |---|---|---|---| | `winapp` | 19.4 s | 6 | 315.7 | | `none` | 21.8 s | 5 | 181.8 | `winapp` was 20–30% faster on the Gallery tasks, about even on simple Calculator tasks, and slower on the minimized-window task (36.9 s vs 15.0 s). It used about 1.7× the credits, mostly to load the UI-automation skill. ## Not tested / risks - The agent numbers above predate several `main` changes and were not re-run: minimized-window handling in `winapp ui` (#1054), the `winapp-performance` skill (#837), and selector changes (#1102, #1104). The `gallery-dialog-left-open` task also changed since then. It now asks to turn the switch off, because the switch starts on. The minimized-window result in particular is likely better now, because `perf.ps1`'s `calc-minimized-scientific` sequence, which failed at its first step on 10/9, passes 3/3 on current `main`. - I did not re-run the Gallery perf sequences here, because the Gallery is not installed on this machine. - Neither benchmark runs in CI: both drive the desktop, and the agent benchmark costs credits. Only the Pester suites are suitable for automation. ## Checklist - [x] Tests added or updated - [x] Tested locally on Windows <details><summary>Notes for reviewers</summary> Validated on the merged head: - `Invoke-Pester benchmarks\ui\tests`: 135/135 passed. - `Invoke-Pester benchmarks\tasks\tests`: 54/54 passed. - `Invoke-Pester benchmarks\agents\tests`: 157 passed, 2 failed. `main` has the same 2 failures from the `winapp-performance` skill, and #1060 fixes them. - `benchmarks\agents\run.ps1 -Lint` and `-Plan` give the same output as `main`: 1 error, which is the same known issue, and 1,584 sessions. - `benchmarks\ui\run.ps1 -Plan`, `run.ps1 -Lint` (0 errors) and `perf.ps1 -Plan` all succeed. </details> --------- Co-authored-by: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Copilot-Session: 0cf8e7b3-7171-42da-9482-89167c7668b8
) ## What and why The agent benchmark is broken on main since `winapp-performance` (#837) and `winui-devtools` (#943) shipped: - `benchmarks\agents\run.ps1 -Lint` reports 1 error, and benchmark Pester has 2 failures. - No capability map matches the current 14 winapp skills, so runs with the current plugin can't be scored. - `winapp perf ...` commands aren't recognized by the command extractor. This PR: - Adds a current `winapp` map for all 14 skills. `winapp-performance` maps to no capability for now: no scenario measures runtime performance, and crediting it as `winui.build` would let it pass unrelated build and crash scenarios. `winapp perf` commands are recorded but count toward nothing. - Keeps the previous maps so older results still score: 13 skills without `winapp-performance`, 13 without `winui-devtools`, and the original 12. - **User-visible:** changes one word in the `winapp-performance` skill description, "not crash dumps" → "not crash analysis". A held-out benchmark prompt mentions a "crash dump", so the old wording leaked into routing and failed lint. The meaning is the same. ## Before / after **Before (main)** ``` > .\benchmarks\agents\run.ps1 -Lint error demand c04-winui-hot-reload-toolbar-av leak-bigram shares 'crash dump' with skill winapp-performance Linted 258 prompts: 1 errors, 54 warnings. > Invoke-Pester benchmarks\agents\tests, benchmarks\tasks\tests, benchmarks\ui\tests Passed 346 Failed 2 ``` **After** ``` Linted 258 prompts: 0 errors, 54 warnings. Passed 350 Failed 0 ``` ## Not tested / risks No live agent runs. Validation covers lint, Pester, `-Plan`, and the plugin validator. ## Checklist - [x] Tests added or updated (they fail without the fix) - [x] Tested locally on Windows - [x] Docs, samples, and `plugins/winapp/skills/` updated if user-facing behavior changed <details><summary>Notes for reviewers</summary> Commands run on this branch: - `.\benchmarks\agents\run.ps1 -Lint`: 0 errors - `Invoke-Pester -Path .\benchmarks\agents\tests,.\benchmarks\tasks\tests,.\benchmarks\ui\tests`: 350 passed, 0 failed - `.\benchmarks\agents\run.ps1 -Plan`: runs (1710 sessions) - `.\scripts\validate-plugin-package.ps1`: passed Skill-set hashes (computed on main's skill names): current 14 = `e20195ae2f87`; without performance = `ea21288d7949` (main's former current map); without devtools = `93d52ca8d877`; without both = `4a4abc8b6541`. Giving `winapp-performance` its own capability would need at least two held-out scenarios (a test requires that for every capability), so it's left for a follow-up. Ported from the benchmark part of draft #1060 only; none of its skill splits are included. </details> --------- Co-authored-by: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72865eb6-685a-4ede-8516-17cfe8abcc86
Description
Add non-elevated WinUI 3 performance recording and offline analysis to help developers and agents investigate expensive XAML layout, rendering, and scrolling.
perf start,status,mark,stop, andanalyze. Select a running app by PID, process name, or window title, using the same matching rules aswinapp ui.run --profileacross packaged, unpackaged, and execution-alias launch paths. Recording runs in a separate worker, supports detached launches, and finalizes automatically when the target app exits.Usage Example
Launch and record a project:
Or record an already-running app and mark the interaction being investigated:
Replace
MyWinUIAppwith the app's process name, window title, or PID, andCAPTURE_IDwith theidreturned byperf start. Forrun --profile, use the returnedProfile.CaptureId. The output directory must be empty.After recording stops, find expensive work and expand its operation tree:
Replace
CALL_IDwith an operation ID returned by the queries. Omit--jsonfor readable console output.Related Issue
No related issue linked.
Type of Change
Checklist
plugins/winapp/skills/Additional Notes
Recording and interpretation
run --profileattaches after the PID becomes available; it does not guarantee complete startup coverage.Validation
AI Description
This section is auto-generated by AI when the PR is opened or updated. To opt out, delete this entire section including the marker comments.