Skip to content

当 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 里;如果你要扩展提供程序, 请从那里开始。

Reactor 诊断流 —— 框架的某个 catch 站点委托给 DiagnosticLog,后者在 Release 路径上发出到 ReactorEventSource,并在 Debug 构建中镜像到 Debug.WriteLine;四类消费方读取该事件面:环境变量 / dotnet-trace 采集、Visual Studio Profiler、进程内的 ReactorTrace.Subscribe 辅助方法,以及 reactor.logs MCP 工具

规则

决定走哪个通道的是受众,而不是严重级别。Debug.WriteLine 是为正在改框架本身的贡献者准备的;它的目标受众是「在 Visual Studio 里开着检出代码、看 Output 窗口的人」。ReactorEventSource 是为 应用开发者、SRE 与支持工程师准备的;它的目标 受众是「运行已交付二进制、需要知道某个窗口为什么打不开的人」。

受众 通道 Release 中可见?
框架贡献者 Debug.WriteLineDebug.Assert
应用开发者 / SRE Microsoft-UI-Reactor EventSource
不可达代码 throw new UnreachableException(...) 是(表现为崩溃)

两个通道是互补的,而不是冗余的。RenderContext 中一次被吞掉的 异常会同时发出两者 —— 给应用开发者的有类型事件, 以及给运行 Debug 构建的贡献者的、更丰富的 Debug.WriteLine 镜像(带 异常消息)。该镜像是 [Conditional("DEBUG")],在 Release 中会被编译掉。

注意: 异常消息具有 PII 特征,绝不会进入 ETW 载荷。 ex.Message 可能携带绝对路径、环境值、部分 表单值,以及导致失败的用户数据。有类型的事件载荷只携带 异常的类型InvalidOperationExceptionCOMException);同 UID 的 dotnet-trace 消费方只能看到类型,看不到其他任何东西。如果你 需要在自己的日志里拿到消息,请挂一个进程内订阅者(见下文 ReactorTrace.Subscribe),并把消息 转发到你自有 ACL 之下的汇点。

哪些地方有插桩

提供程序的事件分布在一小组关键字上;spec 044 在 性能插桩页所记录的七个关键字之上新增了六个子系统关键字, spec 049 又加了第七个(HotReload)。请挑出与你正在 排查的问题匹配的那些位:

关键字 覆盖内容
Errors 0x20 通用的 SwallowedError / HResultFailed / Warning,加上 RenderError
Hosting 0x80 WindowOpenedWindowClosedWindowDpiChangedBackdropMaterializationFailed
Persistence 0x100 PersistenceReadPersistenceWritePersistenceRejected
Navigation 0x200 NavigationRequestedNavigationCompletedNavigationCancelled、缓存命中/未命中/逐出、过渡、深链接
Intl 0x400 IntlMissingKey
Theme 0x800 ThemeApplyFailed
Shell 0x1000 JumpList* / ThumbnailToolbar* / Tray*(计划中)
HotReload 0x2000 跨编辑的 Hook 状态迁移(spec 049)

用按位或组合这些位。最常见的「一切都在、且不会意外」的掩码 是 0x1FA0Errors | Hosting | Persistence | Navigation | Intl | Theme)—— 它丢掉了会产生逐状态写入刷屏的 冗长 StateEventDispatch 关键字。当你在排查 热重载为什么重置了某个组件的状态时,再加上 0x2000

Warning 携带的是框架作者写的、针对可恢复 误配置的诊断信息,框架选择越过它继续 —— 例如无法解析的 .ApplyStyle() 键、无法落实的背景材质。它的载荷是 三个字符串:

字段 含义
category 子系统标签,例如 ThemeHosting
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 就是 上面那个「一切都在、且不会意外」的掩码;级别 5Verbose。运行 应用、复现问题、干净退出(运行时会在关闭时把 文件刷到磁盘)。在 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:

Microsoft-UI-Reactor:0x1FA0:5

时间轴把每个 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 筛选器仍返回它们 各自的专用流。eventNameeventId 字段存在于 每一个条目上,但对非事件来源为 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_DISCONNECTEDE_HANDLERPC_E_SERVERFAULTCO_E_OBJNOTCONNECTED);当某个调用点需要不同的集合时, 请逐个指名常量。

注意: DiagnosticLogLogCategoryHResultsReactorEventSource 都是 Reactor 程序集内部的 —— 这个模式是框架代码的 契约,不是你的应用会调用的 API。想要同样纪律的应用, 应让它自己的吞异常走自己的日志器;应用从 Reactor 消费的是事件流, 经由上面四条采集路线。

采集时没有钉住级别

DOTNET_EventPipeConfig=Microsoft-UI-Reactor

默认是 Verbose 加上所有关键字。在一个繁忙应用上典型的 30 秒会话 会写出数百兆字节 —— 而 State 关键字事件在每次 UseState 写入时都会触发, 因此一个状态繁重的界面就会占满整个跟踪。两个都要钉住:

DOTNET_EventPipeConfig=Microsoft-UI-Reactor:0x1FA0:5

0x1FA0Errors | Hosting | Persistence | Navigation | Intl | Theme —— 那个「一切都在、且不会意外」的掩码。:5Verbose。跟踪 会缩小一个数量级。

在 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 工具的文本渲染会识别载荷字段名 hrhresulthwnd,并把它们格式化为 8 位大写十六进制。 这与迁移前的 Debug.WriteLine 形式一致,因此既有的 日志 grep 能继续工作。

后续阅读

  • 性能插桩 —— 发出流水线、关键字设计与 IsEnabled 门。如果你要新增事件,请先读这篇。
  • DevTools 内部机制 —— 诊断事件流经的 MCP 服务器与 logs 工具管道。
  • 持久化 —— PersistenceRead / PersistenceWrite / PersistenceRejected 从哪里触发。
  • 导航 —— 发出 Navigation 关键字事件的路径生命周期。