当 Microsoft.UI.Reactor(Reactor)吞掉一个异常、越过一个 HRESULT 继续返回,或
以其他方式选择继续而不是抛出时,框架历来只是
丢下一句 Debug.WriteLine 然后继续。对
贡献者来说这没问题 —— 消息在 Debug 构建中会落到 Visual Studio 的 Output
窗口 —— 但它在 Release 中消失了,而 Release 恰恰是每个交付应用
运行的配置。Spec 044 修正了这一点:错误与
HRESULT 诊断现在经由 Microsoft-UI-Reactor
EventSource 路由(Release 可见、按关键字门控、无消费者监听时零分配),
并且既有的进程内开发工具
(reactor.logs)被扩展,使 MCP 智能体能在返回 stdout / stderr / 调试输出的同一次调用中
读取框架事件。
诊断¶
本页讲的是如何从真实应用中读取 Reactor 的诊断信息 —— 而不是如何新增事件。事件的发出流水线(关键字、 IsEnabled 门、事件 id 分配、EventPipe 与 ETW 的传输分工) 在 perf-instrumentation.md 里;如果你要扩展提供程序, 请从那里开始。
规则¶
决定走哪个通道的是受众,而不是严重级别。Debug.WriteLine
是为正在改框架本身的贡献者准备的;它的目标受众是「在 Visual
Studio 里开着检出代码、看 Output 窗口的人」。ReactorEventSource 是为
应用开发者、SRE 与支持工程师准备的;它的目标
受众是「运行已交付二进制、需要知道某个窗口为什么打不开的人」。
| 受众 | 通道 | Release 中可见? |
|---|---|---|
| 框架贡献者 | Debug.WriteLine、Debug.Assert |
否 |
| 应用开发者 / SRE | Microsoft-UI-Reactor EventSource |
是 |
| 不可达代码 | throw new UnreachableException(...) |
是(表现为崩溃) |
两个通道是互补的,而不是冗余的。RenderContext 中一次被吞掉的
异常会同时发出两者 —— 给应用开发者的有类型事件,
以及给运行 Debug 构建的贡献者的、更丰富的 Debug.WriteLine 镜像(带
异常消息)。该镜像是 [Conditional("DEBUG")],在 Release 中会被编译掉。
注意: 异常消息具有 PII 特征,绝不会进入 ETW 载荷。
ex.Message可能携带绝对路径、环境值、部分 表单值,以及导致失败的用户数据。有类型的事件载荷只携带 异常的类型(InvalidOperationException、COMException);同 UID 的dotnet-trace消费方只能看到类型,看不到其他任何东西。如果你 需要在自己的日志里拿到消息,请挂一个进程内订阅者(见下文ReactorTrace.Subscribe),并把消息 转发到你自有 ACL 之下的汇点。
哪些地方有插桩¶
提供程序的事件分布在一小组关键字上;spec 044 在
性能插桩页所记录的七个关键字之上新增了六个子系统关键字,
spec 049 又加了第七个(HotReload)。请挑出与你正在
排查的问题匹配的那些位:
| 关键字 | 位 | 覆盖内容 |
|---|---|---|
Errors |
0x20 |
通用的 SwallowedError / HResultFailed / Warning,加上 RenderError |
Hosting |
0x80 |
WindowOpened、WindowClosed、WindowDpiChanged、BackdropMaterializationFailed |
Persistence |
0x100 |
PersistenceRead、PersistenceWrite、PersistenceRejected |
Navigation |
0x200 |
NavigationRequested、NavigationCompleted、NavigationCancelled、缓存命中/未命中/逐出、过渡、深链接 |
Intl |
0x400 |
IntlMissingKey |
Theme |
0x800 |
ThemeApplyFailed |
Shell |
0x1000 |
JumpList* / ThumbnailToolbar* / Tray*(计划中) |
HotReload |
0x2000 |
跨编辑的 Hook 状态迁移(spec 049) |
用按位或组合这些位。最常见的「一切都在、且不会意外」的掩码
是 0x1FA0(Errors | Hosting | Persistence |
Navigation | Intl | Theme)—— 它丢掉了会产生逐状态写入刷屏的
冗长 State 与 EventDispatch 关键字。当你在排查
热重载为什么重置了某个组件的状态时,再加上 0x2000。
Warning 携带的是框架作者写的、针对可恢复
误配置的诊断信息,框架选择越过它继续 —— 例如无法解析的
.ApplyStyle() 键、无法落实的背景材质。它的载荷是
三个字符串:
| 字段 | 含义 |
|---|---|
category |
子系统标签,例如 Theme、Hosting |
operation |
稳定的操作标识符,例如 ApplyStyle |
message |
框架作者撰写的解释,点出有问题的键或属性 |
该消息由框架从开发者撰写的标识符组合而成, 绝不来自用户数据(spec 044 §6.2.1)。
NativeAOT: .NET NativeAOT 工具链把
EventSourceSupport功能开关默认为false,这会把整个EventSource面编译掉。 因此 NativeAOT 发布的应用不会发出这些事件中的任何一个 —— 包括Warning—— 直到它在项目文件中用<EventSourceSupport>true</EventSourceSupport>重新选择加入。DEBUG 下的Debug.WriteLine镜像不受影响。
采集跟踪¶
有四条路线。按消费方所在的位置来选。
环境变量 —— 零代码,输出到文件¶
要做一次不改动应用的快速本地采集,.NET 运行时可以
完全由环境变量驱动,写出 EventPipe 的 .nettrace 文件。
当问题只在交付构建上、在另一台机器上复现,或者你想把
跟踪交给别人时,这就是对的工具:
set DOTNET_EnableEventPipe=1
set DOTNET_EventPipeOutputPath=reactor.nettrace
set DOTNET_EventPipeConfig=Microsoft-UI-Reactor:0x1FA0:5
MyApp.exe
第三个变量是 <provider>:<keywords>:<level>。0x1FA0 就是
上面那个「一切都在、且不会意外」的掩码;级别 5 是 Verbose。运行
应用、复现问题、干净退出(运行时会在关闭时把
文件刷到磁盘)。在 Visual Studio 的
Performance Profiler → Events Viewer 中打开生成的 .nettrace。
dotnet-trace —— 附着到正在运行的进程¶
dotnet-trace 是做 EventPipe 采集的跨平台 CLI。
当应用已经在运行、而你想框定采集窗口时很有用:
dotnet-trace collect ^
--process-id <pid> ^
--providers Microsoft-UI-Reactor:0x1FA0:5 ^
--output reactor.nettrace
Ctrl+C 停止会话;.nettrace 落在工作
目录中。文件格式与环境变量路线相同 —— Events
Viewer 工作流也相同。
Visual Studio Performance Profiler¶
要用 GUI 工作流的话,Profiler 的 Events Viewer 接受同样的 provider:keyword:level 格式。Diagnostics → Performance Profiler → Events Viewer → Settings → Custom Provider:
时间轴把每个 Reactor 事件与 CPU 采样、GC 和
网络视图绑在一起,因此你能看到某个 NavigationCompleted 与它造成的
分配尖峰挨在一起。
进程内订阅¶
当消费方就是应用自身时 —— 自定义日志汇点、开发工具
叠加层、应用内诊断页 —— ReactorTrace.Subscribe
会返回一个 IDisposable 令牌,对每个匹配筛选条件的事件
触发回调:
public static IDisposable Subscribe(
Action<ReactorEvent> onEvent,
EventLevel level = EventLevel.Verbose,
EventKeywords keywords = (EventKeywords)(-1))
{
ArgumentNullException.ThrowIfNull(onEvent);
return new Subscription(onEvent, level, keywords);
}
支持多个并发订阅者;在令牌被释放之前,每个筛选条件
都独立生效。订阅者回调在发出事件的线程上运行(当事件
源自协调/渲染时,通常是 UI 调度器),因此请让工作
保持最小 —— 框架把这次调用包在 try/catch 里,好让有缺陷的
汇点无法传播到 EventSource.WriteEvent,但调度器
在这段时间内仍被阻塞。如果你的汇点要做任何昂贵的事,
请转发到队列。
ReactorTrace.Subscribe 不是文件采集 API。它存在的
原因是进程内消费方(开发工具、ILogger 适配器、
自定义诊断页)需要访问与环境变量路线写入磁盘的相同事件。
要拿 .nettrace 文件,请使用上面三条路线之一 —— 它们成本更低,
且输出的格式更丰富。
reactor.logs source=event —— MCP 集成¶
Reactor 的进程内开发工具(mur devtools)暴露一个 logs MCP
工具,用于排空所捕获的 Console.Out / Console.Error /
Debug.WriteLine 环形缓冲区。Spec 044 扩展了该工具,使
缓冲区也捕获 Microsoft-UI-Reactor ETW 事件,并通过一个
新的 source=event 筛选器呈现:
// Request
{
"method": "tools/call",
"params": {
"name": "logs",
"arguments": { "source": "event", "tail": 20, "level": "Warning" }
}
}
// Response — each entry now carries eventName / eventId
{
"entries": [
{
"seq": 142,
"ts": "2026-05-19T17:42:11.330Z",
"source": "event",
"level": "Warning",
"text": "SwallowedError category=Hosting operation=ReactorWindow.Close exceptionType=COMException",
"eventName": "SwallowedError",
"eventId": 16
}
],
"nextSeq": 143,
"dropped": 0
}
不传 source=event 的既有客户端看到的行为完全
不变 —— stdout / stderr / debug 筛选器仍返回它们
各自的专用流。eventName 与 eventId 字段存在于
每一个条目上,但对非事件来源为 null,因此写于 spec 044 之前的
客户端可以安全地忽略它们。
HR 风格的载荷字段以与迁移前 Debug.WriteLine 站点相同的 0x{X8} 形式
呈现(HResultFailed
category=Shell operation=JumpList.Begin hr=0x80004002),因此原本匹配旧形式
的日志 grep 仍然命中。
模式¶
读懂生产环境中发生过的一次吞异常¶
客户报告说某个窗口在特定机器上无法干净关闭。用环境变量路线
采集、在 Warning 级别筛选 Errors
关键字(0x20),并在 Events Viewer 中打开跟踪。
相关条目会是这样:
SwallowedError category=Hosting operation=ReactorWindow.Close exceptionType=COMException
HResultFailed category=Hosting operation=ReactorWindow.Close hr=0x80010108
操作标签是稳定的、由开发者撰写的 —— 在
Reactor 源码中搜索 "ReactorWindow.Close",你就能落到
DiagnosticLog.SwallowedError 的调用点上。异常类型与 HR
一起钉住了故障类别(这里是 RPC_E_DISCONNECTED ——
AppWindow 的 COM 代理在调用落地之前就被拆掉了),同时
从不泄漏用户可见的窗口标题。
把 ReactorTrace.Subscribe 接到逐窗口的调试叠加层上¶
一个想呈现「正在发生导航事件」的开发工具叠加层 不需要文件采集 —— 只要一个进程内订阅:
public sealed class NavigationOverlay : IDisposable
{
// ReactorEventSource 是 Reactor 内部的,因此应用是用
// 文档给出的位值、而不是符号来指名关键字。
private const EventKeywords NavigationKeyword = (EventKeywords)0x200;
private readonly IDisposable _subscription;
private readonly Queue<string> _ring = new();
public NavigationOverlay()
{
_subscription = ReactorTrace.Subscribe(
evt =>
{
var line = $"{evt.EventName} {string.Join(' ',
Enumerable.Range(0, evt.Payload.Count)
.Select(i => $"{evt.PayloadNames[i]}={evt.Payload[i]}"))}";
lock (_ring)
{
_ring.Enqueue(line);
while (_ring.Count > 50) _ring.Dequeue();
}
},
level: EventLevel.Verbose,
keywords: NavigationKeyword);
}
public void Dispose() => _subscription.Dispose();
}
Subscribe 已经应用了关键字掩码,因此回调不需要
再次检查 evt.Keywords。
该回调在调度器上运行(大多数导航事件都 源自那里)—— 对于 UI 叠加层,这恰恰是你想要的。 如果叠加层改为转发到后台汇点,请在做 I/O 之前 从调度器编组出去。
常见错误¶
把 Debug.WriteLine 当作 Release 诊断手段¶
// Don't:
try { window.AppWindow.Close(); }
catch (Exception ex)
{
Debug.WriteLine($"Close failed: {ex}"); // disappears in Release
}
private void CloseNativeWindowOnce()
{
if (_disposed || _nativeCloseRequested) return;
_nativeCloseRequested = true;
try { _window.Close(); }
catch (COMException ex) when (HResults.IsTeardownReentry(ex.HResult))
{ DiagnosticLog.SwallowedError(LogCategory.Hosting, "ReactorWindow.Close", ex); }
}
第一种写法对每一个交付的应用都是不可见的。第二种写法
在 Release 中于 Warning 级别、Keywords.Errors 之下发出到
Microsoft-UI-Reactor(没有消费者附着时零分配),并且
在 Debug 构建中把包含 ex.Message 的更丰富的一行镜像到
Debug.WriteLine。那个收窄的 catch 筛选器是有意为之:
spec 044 §6.7.2 要求 catch (COMException ex) when (ex.HResult is
HResults.X or HResults.Y) —— 绝不要裸的 catch (COMException),
因为那些「缺陷类」的 HRESULT 需要继续向外传播。
HResults.IsTeardownReentry 打包了标准的那四个
(RPC_E_DISCONNECTED、E_HANDLE、RPC_E_SERVERFAULT、
CO_E_OBJNOTCONNECTED);当某个调用点需要不同的集合时,
请逐个指名常量。
注意:
DiagnosticLog、LogCategory、HResults与ReactorEventSource都是 Reactor 程序集内部的 —— 这个模式是框架代码的 契约,不是你的应用会调用的 API。想要同样纪律的应用, 应让它自己的吞异常走自己的日志器;应用从 Reactor 消费的是事件流, 经由上面四条采集路线。
采集时没有钉住级别¶
默认是 Verbose 加上所有关键字。在一个繁忙应用上典型的 30 秒会话
会写出数百兆字节 —— 而 State 关键字事件在每次 UseState 写入时都会触发,
因此一个状态繁重的界面就会占满整个跟踪。两个都要钉住:
0x1FA0 是 Errors | Hosting | Persistence | Navigation | Intl |
Theme —— 那个「一切都在、且不会意外」的掩码。:5 是 Verbose。跟踪
会缩小一个数量级。
在 IsEnabled 门之外计算诊断载荷¶
public static void SwallowedError(LogCategory category, string? operation, Exception? ex)
{
// Cost-of-disabled: when no consumer enables Keywords.Errors at
// Warning the entire branch is skipped — no enum-to-string, no
// type-name materialization, no WriteEvent dispatch.
if (ReactorEventSource.Log.IsEnabled(EventLevel.Warning, ReactorEventSource.Keywords.Errors))
{
ReactorEventSource.Log.SwallowedError(
category.ToString(),
operation ?? string.Empty,
ex?.GetType().Name ?? string.Empty);
}
DebugSwallowedError(category, operation, ex);
}
DiagnosticLog.SwallowedError 把它的 category.ToString() 与
ex.GetType().Name 工作放在
ReactorEventSource.Log.IsEnabled(...) 门之内,而不是之外。这个
区别正是「没有消费者附着时零分配」保证的全部要点。如果日后某个辅助方法
先落实载荷、再门控,那个零分配回归
测试(DisabledKeyword_skips_ReactorEventSource_WriteEvent_payload_marshal)
会捕获它。配套的 HResultFailed 事件形态相同:
[Event(17, Level = EventLevel.Warning, Keywords = Keywords.Errors,
Message = "HResult failed (category={category}, op={operation}, hr=0x{hr:X8})")]
public void HResultFailed(string category, string operation, int hr)
{
if (IsEnabled(EventLevel.Warning, Keywords.Errors))
WriteEvent(17, category ?? string.Empty, operation ?? string.Empty, hr);
}
通过 ReactorTrace.Subscribe 转发 ex.Message¶
// Don't — capturing the exception and forwarding its message defeats the strip:
Exception? lastEx = null;
try { /* ... */ }
catch (Exception caught)
{
lastEx = caught;
DiagnosticLog.SwallowedError(LogCategory.Hosting, "MyOp", caught);
}
ReactorTrace.Subscribe(evt =>
{
// BAD: re-injects PII that the ETW payload deliberately excluded.
_logger.Warn(evt.EventName + ": " + string.Join(",", evt.Payload) + " " + lastEx?.Message);
});
框架已经把 ex.Message 从载荷中剥离了 ——
再从捕获的局部变量把它加回去,恰恰就是这个剥离所要
防止的 PII 泄漏。如果某个汇点需要这条消息,请在 catch
块内记录它(那里异常在作用域内,且汇点自身的 ACL 适用),
而不是在 ReactorTrace.Subscribe 边界处。
提示¶
reactor.logs source=event 是最快的读取方式。 在
开发工具会话内部,调用该 MCP 工具会立即返回最近 N 个事件,
无需启动 dotnet-trace 采集。当你需要把文件交给别人时用
环境变量路线;当你正坐在运行中的应用面前时用
MCP 工具。
关键字掩码就是受众预筛选器。 只订阅
Keywords.Errors 比订阅 (-1) 便宜得多,因为
框架的热路径协调/渲染代码会立刻丢掉它们的 IsEnabled
检查。一个长期存活的宽泛订阅,会在它存活的整个期间
抬高框架中每个热路径调用点的成本。
HR 字段在 reactor.logs 文本中以 0x{X8} 形式渲染。 MCP
工具的文本渲染会识别载荷字段名 hr、
hresult 与 hwnd,并把它们格式化为 8 位大写十六进制。
这与迁移前的 Debug.WriteLine 形式一致,因此既有的
日志 grep 能继续工作。
后续阅读¶
- 性能插桩 —— 发出流水线、关键字设计与 IsEnabled 门。如果你要新增事件,请先读这篇。
- DevTools 内部机制 —— 诊断事件流经的 MCP 服务器与
logs工具管道。 - 持久化 ——
PersistenceRead/PersistenceWrite/PersistenceRejected从哪里触发。 - 导航 —— 发出 Navigation 关键字事件的路径生命周期。