PAYATHON 2026

为何 Tmds.DBus 查询在控制台正常,却在 AvaloniaUI 应用中抛出异常?

排障赵师傅

结论

在 CallMethodAsync 的读取回调中完成响应解析,只返回 Rgb 等独立数据。不要返回原始 Message,再在回调外调用 GetBodyReader()。

问题出在这段代码:

var reply = await connection.CallMethodAsync(
    message,
    (result, _) => result);

var reader = reply.GetBodyReader();

这段代码将库管理的响应消息带出了回调。如果所用的 Tmds.DBus.Protocol 实现会回收消息或复用缓冲区,回调结束后再读取消息,就可能访问到失效的数据。这与当前异常出现的位置吻合。

用 ReadInt64() 跳过返回值头部的做法也需要修正。ReadOne 返回的是 variant,应先读取其中的类型签名,再解析颜色结构体。

题目没有提供包版本,仅凭堆栈还无法确认该版本内部如何回收消息。排查时先修复这两处用法,再检查版本和运行环境。

为什么控制台正常,AvaloniaUI 却抛出异常?

await 不会自动复制响应消息,也不会延长消息缓冲区的有效期。

控制台应用通常没有 UI 同步上下文,await 后的代码可能很快就接着执行,恰好在消息被回收前完成读取。因此,即使控制台返回了正确颜色,也不能证明这种写法可靠。

AvaloniaUI 应用中的 await 通常会捕获 UI 同步上下文。响应到达后,后续代码可能需要排队,等待回到 UI 线程执行。等到调用:

reply.GetBodyReader();

底层消息数据可能已经被回收或重置。构造 Reader 所需的缓冲区范围此时已经无效,就可能抛出题目中的 ArgumentOutOfRangeException。

具体调度顺序取决于库版本和运行时,读取消息不应依赖这个顺序。添加 Task.Run、延时或 ConfigureAwait(false),都无法保证消息仍然有效。

修复:在回调中读取颜色

示例沿用问题中的 DBusConnection API,假定所用版本提供 Reader.ReadSignature(string)。如果 API 不同,需要按对应版本调整签名读取代码。

using Tmds.DBus.Protocol;

public async Task<Rgb?> GetThemeHexAsync()
{
    try
    {
        var address = DBusAddress.Session;
        if (string.IsNullOrEmpty(address))
        {
            return null;
        }

        using var connection = new DBusConnection(address);
        await connection.ConnectAsync();

        var writer = connection.GetMessageWriter();
        writer.WriteMethodCallHeader(
            destination: "org.freedesktop.portal.Desktop",
            path: "/org/freedesktop/portal/desktop",
            @interface: "org.freedesktop.portal.Settings",
            member: "ReadOne",
            signature: "ss");

        writer.WriteString("org.freedesktop.appearance");
        writer.WriteString("accent-color");

        return await connection.CallMethodAsync(
            writer.CreateMessage(),
            (reply, _) =>
            {
                var reader = reply.GetBodyReader();

                // ReadOne 返回 variant,其内部预期为 (ddd)。
                reader.ReadSignature("(ddd)");
                reader.AlignStruct();

                var red = ToChannel(reader.ReadDouble());
                var green = ToChannel(reader.ReadDouble());
                var blue = ToChannel(reader.ReadDouble());

                return new Rgb(red, green, blue);
            });
    }
    catch (Exception e)
    {
        logger.Error(e, "Failed to read the desktop accent color.");
        return null;
    }
}

private static int ToChannel(double value)
{
    if (double.IsNaN(value) ||
        double.IsInfinity(value) ||
        value < 0.0 ||
        value > 1.0)
    {
        throw new InvalidOperationException(
            "The accent color component is outside [0, 1].");
    }

    return (int)(value * 255.0);
}

解析在消息有效期间完成,返回的 Rgb 不再依赖响应缓冲区,连接也会在调用结束后释放。如果应用需要频繁查询或订阅设置变化,可以复用连接,由持有连接的服务统一管理其生命周期。

为什么要删除 ReadInt64()?

请求中的:

signature: "ss"

描述的是两个输入字符串,与响应类型不同。

ReadOne 的响应是一个 variant。对于有效的 accent-color 值,variant 内部应为 (ddd),也就是包含三个 double 的结构体。

原代码:

reader.AlignStruct();
reader.ReadInt64();

没有按这个类型读取数据。在 (ddd) 的编码中,variant 的签名和后面的对齐填充可能恰好占八个字节,所以 ReadInt64() 看起来像是“跳过了头部”,但响应中实际没有这个整数。

应先读取签名,再按结构体规则对齐:

reader.ReadSignature("(ddd)");
reader.AlignStruct();

当前异常发生在 GetBodyReader(),还没有执行到 ReadInt64(),因此这两处问题需要分别修复。

按顺序排查剩余问题

1. 先验证消息生命周期

只移动解析位置,保持目标服务、方法和参数不变。

如果移到回调内解析后异常消失,就有较强的依据判断问题出在消息生命周期。如果在回调内调用 GetBodyReader() 仍然失败,就需要继续检查所用版本的实现,不能再将原因归结为 UI 线程恢复较慢。

回调应保持同步。不要在其中保存 Message 或 Reader,也不要安排稍后执行的解析任务。

2. 比较实际加载的依赖版本

分别在控制台项目和 AvaloniaUI 项目中执行:

dotnet list package --include-transitive

比较 Tmds.DBus.Protocol 的实际版本,再用以下表达式确认运行时加载的程序集:

typeof(DBusConnection).Assembly.FullName

项目文件中的直接引用不足以确定实际加载的版本。是否存在依赖冲突,也需要根据证据判断。

3. 从相同桌面会话验证 portal

在应用所在的桌面会话中执行:

busctl --user call \
  org.freedesktop.portal.Desktop \
  /org/freedesktop/portal/desktop \
  org.freedesktop.portal.Settings \
  ReadOne ss \
  org.freedesktop.appearance \
  accent-color

检查方法是否可用、返回值是否符合预期。如果终端查询成功而应用失败,再比较两者的启动环境,尤其是 DBUS_SESSION_BUS_ADDRESS。从终端、IDE、桌面启动器或沙箱启动时,环境可能不同。

portal 后端也可能不支持该设置,或没有可用的强调色。应用应保留默认主题作为回退,避免将所有读取失败都视为启动失败。

4. 保留完整异常

使用日志框架提供的异常重载,例如 Serilog:

logger.Error(e, "Failed to read the desktop accent color.");

这样便于查看异常类型、调用栈和内部异常。如果收到 D-Bus 错误响应,还应记录错误名称和消息,以区分方法不可用、设置不存在和本地解析失败。

Avalonia 初始化代码还需要处理什么?

读取失败时,GetThemeHexAsync() 会返回 null,调用方必须先检查结果。主题和资源更新也应在 UI 线程执行:

using Avalonia.Media;
using Avalonia.Threading;

private async Task LoadMaterialThemeAsync(IServiceProvider provider)
{
    using var scope = provider.CreateScope();

    var themeLoader =
        scope.ServiceProvider.GetRequiredService<ILinuxThemeLoader>();

    var rgb = await themeLoader.GetThemeHexAsync();

    if (rgb is not { } value)
    {
        return; // 保留已有主题。
    }

    await Dispatcher.UIThread.InvokeAsync(() =>
    {
        var color = new Color(
            255,
            (byte)value.Red,
            (byte)value.Green,
            (byte)value.Blue);

        var materialTheme =
            this.LocateMaterialTheme<CustomMaterialTheme>();

        materialTheme.BaseTheme = BaseThemeMode.Inherit;
        materialTheme.PrimaryColor = color;
        materialTheme.SecondaryColor = color;

        this.Resources["AccentColor"] = color;
    });
}

OnFrameworkInitializationCompleted() 的返回类型是 void。将它改为 async void 后,框架无法等待其中的异步初始化完成。更稳妥的做法是先同步完成主窗口和基础主题初始化,再启动一个自行捕获异常的异步任务,读取强调色。

这是启动流程中的另一个问题。题目中的 LoadConfigurationAsync(...).ConfigureAwait(false) 位于颜色查询之后,无法解释此前 GetBodyReader() 抛出的异常。它影响的是该次等待之后代码的执行上下文。

备注:内容仅供参考。