From 6aa047935168e15bcccaae78a13624717f861d2b Mon Sep 17 00:00:00 2001 From: zhaoyingzhen Date: Tue, 29 Sep 2026 16:38:38 +0800 Subject: [PATCH] fix: prevent tray startup stalls and follow the dock lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve each plugin group once in the existing loader and yield to the GUI event loop between plugin initializations, restoring the session environment for callbacks. Fetch session power properties asynchronously and serve cached values so a slow power service cannot block the group. Handle GetAll replies separately from live notifications, preserving properties updated in flight and refreshing invalidated properties after the pending request. Discard old requests when the service owner changes. Retry failed snapshots asynchronously after one second. Cover cache ordering and recovery with a manual isolated D-Bus integration test. Bind the tray target to the Dock with BindsTo, PartOf and After. Wait for the replacement Type=dbus Dock on restart; stop tray groups in parallel before the Dock. No extra launcher or readiness service is needed. Add isolated event-dispatch and manual systemd lifecycle tests, startup timing logs, and documentation of the final lifecycle. 在现有 loader 中仅解析一次插件分组,在插件初始化之间返回 GUI 事件循环, 并为回调恢复会话环境。异步获取会话电源属性并提供缓存读取,避免电源服务 响应缓慢时阻塞整个分组。单独处理 GetAll 回复,保留请求期间的实时属性更新, 并在当前请求结束后重新获取失效属性。服务拥有者变化时丢弃旧请求,快照失败后 间隔一秒异步重试,通过手动隔离 D-Bus 集成测试验证缓存时序与失败恢复。 托盘 target 通过 BindsTo、PartOf 和 After 绑定 Dock,重启时等待替换后的 Type=dbus Dock;退出时各托盘组并行停止,再停止 Dock。无需额外启动器或 就绪服务。增加隔离的事件分发测试、手动 systemd 生命周期测试、初始化耗时 日志,并记录最终生命周期。 Log: prevent tray startup stalls and follow the dock lifecycle Pms: BUG-378589 --- CMakeLists.txt | 5 + README.md | 45 +++- README.zh_CN.md | 38 ++- plugins/dde-dock/power/dbus/dbuspower.cpp | 113 ++++++-- plugins/dde-dock/power/dbus/dbuspower.h | 60 ++--- src/loader/main.cpp | 63 +++-- src/loader/pluginmanager.cpp | 28 +- src/loader/pluginmanager.h | 11 +- systemd/dde-tray-loader.target | 17 +- systemd/dde-tray-loader@.service.in | 16 +- tests/CMakeLists.txt | 37 +++ tests/power-dbus-session.conf | 15 ++ tests/powerdbuscache.cpp | 306 ++++++++++++++++++++++ tests/startupplugin.cpp | 59 +++++ tests/test_systemd_lifecycle.py | 263 +++++++++++++++++++ 15 files changed, 972 insertions(+), 104 deletions(-) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/power-dbus-session.conf create mode 100644 tests/powerdbuscache.cpp create mode 100644 tests/startupplugin.cpp create mode 100644 tests/test_systemd_lifecycle.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d8b6ffbca..a3960a91e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,3 +93,8 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/DdeDockConfig.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/DdeDock) configure_file(environments.h.in environments.h @ONLY) + +include(CTest) +if(BUILD_TESTING) + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md index a31282ca3..7b7e75417 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ $ cmake -Bbuild $ cmake --build build ``` -After building, a `dde-tray-loader` binary can be found inside the build folder, you can run it -by `dde-tray-loader -p xxx.so` to start an example. And you can optionally install it by: +After building, the executable is `build/src/loader/trayplugin-loader`. +With a running DDE Dock compositor, use `trayplugin-loader -p /path/to/plugin.so` +to load a plugin. You can optionally install it by: ```shell $ cmake --install build # only do this if you know what you are doing @@ -29,9 +30,47 @@ $ sudo apt build-dep . # install build dependencies $ dpkg-buildpackage -uc -us -nc -b # build binary package(s) ``` +## Tray lifecycle and tests + +`dde-shell@DDE.service` starts `dde-tray-loader.target`. The target waits for +the Dock's `Type=dbus` startup to complete, then starts the configured +`dde-tray-loader@.service` instances in parallel. Each instance runs +`trayplugin-loader --group ` and resolves its plugin list once. +Plugins within a group initialize on the GUI thread, yielding to the event +loop between plugins so initialized plugins can handle display events. + +The target uses `BindsTo=` and `PartOf=` to follow the Dock's lifetime and +restarts. On stop, the tray groups stop in parallel before the Dock. The +desktop and Dock have no mutual startup or shutdown ordering. Readiness is +provided by the Dock's bus name, registered after its compositor is ready; +there is no separate readiness service. + +Run the event-dispatch test with `ctest --test-dir build --output-on-failure`. +It uses an offscreen platform, the build-tree interface library, and a writable +cache under the build directory. No running desktop is needed. + +For systemd integration, run +`python3 tests/test_systemd_lifecycle.py ../dde-session` with a running user +systemd manager and `python3-gi`. This manual test creates temporary fixture +units to check startup, restart, failure recovery, and shutdown ordering; it +does not restart desktop services and is not part of package-build tests. + +For the power cache integration test, build and run it on an isolated bus: + +```shell +cmake --build build --target power-dbus-cache-test +dbus-run-session --config-file=tests/power-dbus-session.conf -- build/tests/power-dbus-cache-test +``` + +It checks failed `GetAll` recovery, delayed snapshots racing with live updates, +partial updates, invalidation, retry cancellation, and service restart. +Snapshots preserve properties updated in flight, and invalidation requests a +fresh snapshot. Failures retry asynchronously after one second; service owner +changes discard pending replies. This manual test is not part of CTest. + ## Getting Involved -- [Code contribution via GitHub](https://github.com/linuxdeepin/dde-shell/) +- [Code contribution via GitHub](https://github.com/linuxdeepin/dde-tray-loader/) - [Submit bug or suggestions to GitHub Issues or GitHub Discussions](https://github.com/linuxdeepin/developer-center/issues/new/choose) ## License diff --git a/README.zh_CN.md b/README.zh_CN.md index 290037c63..c71a0c67f 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -15,7 +15,9 @@ $ cmake -Bbuild $ cmake --build build ``` -构建完毕后,将可在构建目录内获得名为 `dde-tray-loader` 的二进制文件,可以执行`dde-tray-loader -p xxx.so`来运行托盘插件。此时你也可以通过如下方式进行安装: +构建完毕后,可执行文件位于 `build/src/loader/trayplugin-loader`。 +在 DDE Dock 合成器运行时,可以执行 `trayplugin-loader -p /path/to/plugin.so` 加载插件。 +也可以通过如下方式进行安装: ```shell $ cmake --install build # 当你知道这条命令的作用时再执行它 @@ -28,9 +30,41 @@ $ sudo apt build-dep . # 安装构建依赖 $ dpkg-buildpackage -uc -us -nc -b # 构建二进制软件包 ``` +## 托盘生命周期与测试 + +`dde-shell@DDE.service` 拉起 `dde-tray-loader.target`。target 等待 Dock 的 +`Type=dbus` 启动完成后,并行启动配置的 `dde-tray-loader@.service` 实例。 +每个实例执行 `trayplugin-loader --group `,只解析一次插件列表。 +同组插件在 GUI 线程上逐个初始化,插件之间返回事件循环,使已初始化的插件 +能够处理显示事件。 + +target 通过 `BindsTo=` 和 `PartOf=` 跟随 Dock 的生命周期及重启。 +停止时各托盘组并行退出,然后 Dock 退出;桌面与 Dock 之间没有启动或退出顺序约束。 +Dock 在合成器就绪后注册总线名称,以此提供就绪保证,无需单独的 ready 服务。 + +执行 `ctest --test-dir build --output-on-failure` 运行事件分发测试。 +测试使用 offscreen 平台、构建目录中的接口库和可写缓存,无需运行中的桌面。 + +如需验证 systemd 集成,在具备用户 systemd manager 和 `python3-gi` 的环境运行 +`python3 tests/test_systemd_lifecycle.py ../dde-session`。 +此手动测试通过临时模拟单元验证启动、重启、失败恢复及退出顺序,不重启实际桌面服务, +也不参与打包阶段的自动测试。 + +电源缓存集成测试需在隔离总线上执行: + +```shell +cmake --build build --target power-dbus-cache-test +dbus-run-session --config-file=tests/power-dbus-session.conf -- build/tests/power-dbus-cache-test +``` + +用例验证 `GetAll` 失败恢复、延迟快照与实时通知交错、部分属性更新、属性失效、 +重试取消及服务重启。快照保留请求期间已更新的属性,属性失效后重新获取快照; +失败后间隔一秒异步重试,服务拥有者变化时丢弃未完成的旧请求。 +此手动测试不参与 CTest。 + ## 参与贡献 -- [通过 GitHub 发起代码贡献](https://github.com/linuxdeepin/dde-shell/) +- [通过 GitHub 发起代码贡献](https://github.com/linuxdeepin/dde-tray-loader/) - [通过 GitHub Issues 与 GitHub Discussions 汇报缺陷与反馈建议](https://github.com/linuxdeepin/developer-center/issues/new/choose) ## 许可协议 diff --git a/plugins/dde-dock/power/dbus/dbuspower.cpp b/plugins/dde-dock/power/dbus/dbuspower.cpp index f2cc546b5..aa94b4052 100644 --- a/plugins/dde-dock/power/dbus/dbuspower.cpp +++ b/plugins/dde-dock/power/dbus/dbuspower.cpp @@ -1,39 +1,108 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: LGPL-3.0-or-later -/* - * This file was generated by qdbusxml2cpp version 0.8 - * Command line was: qdbusxml2cpp -c DBusPower -p dbuspower org.deepin.dde.Power1.xml - * - * qdbusxml2cpp is Copyright (C) 2015 Digia Plc and/or its subsidiary(-ies). - * - * This is an auto-generated file. - * This file may have been hand-edited. Look for HAND-EDIT comments - * before re-generating it. - */ - #include "dbuspower.h" -/* - * Implementation of interface class DBusPower - */ - DBusPower::DBusPower(QObject *parent) - : QDBusAbstractInterface("org.deepin.dde.Power1", "/org/deepin/dde/Power1", staticInterfaceName(), QDBusConnection::sessionBus(), parent) + : DDBusExtendedAbstractInterface("org.deepin.dde.Power1", "/org/deepin/dde/Power1", staticInterfaceName(), QDBusConnection::sessionBus(), parent) + , m_refreshTimer(this) { qRegisterMetaType("BatteryStateMap"); qDBusRegisterMetaType(); qRegisterMetaType("BatteryPercentageMap"); qDBusRegisterMetaType(); - QDBusConnection::sessionBus().connect(this->service(), this->path(), "org.freedesktop.DBus.Properties", "PropertiesChanged","sa{sv}as", this, SLOT(__propertyChanged__(QDBusMessage))); - QDBusConnection::systemBus().connect("org.freedesktop.UPower", "/org/freedesktop/UPower", "org.freedesktop.DBus.Properties", "PropertiesChanged", this, SLOT(__propertyChanged__(QDBusMessage))); + // DTK handles live notifications only. Process GetAll replies separately + // so a delayed snapshot cannot overwrite properties updated in flight. + connect(this, &DBusPower::propertyChanged, this, [this](const QString &name, const QVariant &value) { + if (m_pendingSnapshot) + m_liveProperties.insert(name); + updateProperties({{name, value}}); + }); + connect(this, &DBusPower::propertyInvalidated, this, [this](const QString &name) { + if (m_pendingSnapshot) + m_liveProperties.insert(name); + getAllProperties(); + }); + + m_refreshTimer.setSingleShot(true); + connect(&m_refreshTimer, &QTimer::timeout, this, &DBusPower::getAllProperties); + auto *ownerWatcher = new QDBusServiceWatcher(service(), connection(), QDBusServiceWatcher::WatchForOwnerChange, this); + connect(ownerWatcher, &QDBusServiceWatcher::serviceOwnerChanged, this, + [this](const QString &, const QString &, const QString &newOwner) { + m_refreshTimer.stop(); + if (m_pendingSnapshot) { + disconnect(m_pendingSnapshot, nullptr, this, nullptr); + m_pendingSnapshot->deleteLater(); + m_pendingSnapshot = nullptr; + } + m_refreshPending = false; + m_liveProperties.clear(); + // Let QDBusAbstractInterface update its owner before fetching again. + if (!newOwner.isEmpty()) + m_refreshTimer.start(0); + }); + + setSync(false); + getAllProperties(); } -DBusPower::~DBusPower() +void DBusPower::getAllProperties() { - QDBusConnection::sessionBus().disconnect(service(), path(), "org.freedesktop.DBus.Properties", "PropertiesChanged", "sa{sv}as", this, SLOT(propertyChanged(QDBusMessage))); - QDBusConnection::systemBus().disconnect("org.freedesktop.UPower", "/org/freedesktop/UPower", "org.freedesktop.DBus.Properties", "PropertiesChanged", this, SLOT(propertyChanged(QDBusMessage))); + if (m_pendingSnapshot) { + m_refreshPending = true; + return; + } + if (!isValid()) + return; + + m_refreshTimer.stop(); + m_liveProperties.clear(); + auto request = QDBusMessage::createMethodCall(service(), path(), + QStringLiteral("org.freedesktop.DBus.Properties"), QStringLiteral("GetAll")); + request << interface(); + m_pendingSnapshot = new QDBusPendingCallWatcher(connection().asyncCall(request), this); + connect(m_pendingSnapshot, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *watcher) { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + m_pendingSnapshot = nullptr; + const bool refresh = m_refreshPending; + m_refreshPending = false; + if (reply.isError()) { + if (isValid()) + m_refreshTimer.start(1000); + return; + } + + auto snapshot = reply.value(); + for (const auto &name : m_liveProperties) + snapshot.remove(name); + // An invalidation/request during GetAll needs a newer snapshot. + if (refresh) + m_refreshTimer.start(0); + updateProperties(snapshot); + }); } +void DBusPower::updateProperties(const QVariantMap &properties) +{ + const bool onBattery = properties.contains("OnBattery") ? properties.value("OnBattery").toBool() : m_onBattery; + const auto percentage = properties.contains("BatteryPercentage") + ? qdbus_cast(properties.value("BatteryPercentage")) : m_batteryPercentage; + const auto state = properties.contains("BatteryState") + ? qdbus_cast(properties.value("BatteryState")) : m_batteryState; + const bool onBatteryChanged = m_onBattery != onBattery; + const bool percentageChanged = m_batteryPercentage != percentage; + const bool stateChanged = m_batteryState != state; + // Publish the whole snapshot before notifying readers of any property. + m_onBattery = onBattery; + m_batteryPercentage = percentage; + m_batteryState = state; + if (onBatteryChanged) + Q_EMIT OnBatteryChanged(); + if (percentageChanged) + Q_EMIT BatteryPercentageChanged(); + if (stateChanged) + Q_EMIT BatteryStateChanged(); +} diff --git a/plugins/dde-dock/power/dbus/dbuspower.h b/plugins/dde-dock/power/dbus/dbuspower.h index 8e27fe7d1..a104d9a1c 100644 --- a/plugins/dde-dock/power/dbus/dbuspower.h +++ b/plugins/dde-dock/power/dbus/dbuspower.h @@ -1,26 +1,20 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: LGPL-3.0-or-later -/* - * This file was generated by qdbusxml2cpp version 0.8 - * Command line was: qdbusxml2cpp -c DBusPower -p dbuspower org.deepin.dde.Power1.xml - * - * qdbusxml2cpp is Copyright (C) 2015 Digia Plc and/or its subsidiary(-ies). - * - * This is an auto-generated file. - * Do not edit! All changes made to it will be lost. - */ - #ifndef DBUSPOWER_H_1467941176 #define DBUSPOWER_H_1467941176 +#include + #include #include #include #include +#include #include #include +#include #include #include @@ -32,29 +26,10 @@ Q_DECLARE_METATYPE(BatteryStateMap) /* * Proxy class for interface org.deepin.dde.Power1 */ -class DBusPower: public QDBusAbstractInterface +class DBusPower: public Dtk::Core::DDBusExtendedAbstractInterface { Q_OBJECT - Q_SLOT void __propertyChanged__(const QDBusMessage& msg) - { - QList arguments = msg.arguments(); - if (3 != arguments.count()) - return; - QString interfaceName = msg.arguments().at(0).toString(); - if (interfaceName !="org.deepin.dde.Power1") - return; - QVariantMap changedProps = qdbus_cast(arguments.at(1).value()); - foreach(const QString &prop, changedProps.keys()) { - const QMetaObject* self = metaObject(); - for (int i=self->propertyOffset(); i < self->propertyCount(); ++i) { - QMetaProperty p = self->property(i); - if (p.name() == prop) { - Q_EMIT p.notifySignal().invoke(this); - } - } - } - } public: static inline const char *staticInterfaceName() { return "org.deepin.dde.Power1"; } @@ -62,19 +37,23 @@ class DBusPower: public QDBusAbstractInterface public: explicit DBusPower(QObject *parent = 0); - ~DBusPower(); + ~DBusPower() override = default; + // Keep snapshots separate from DTK's live propertyChanged notifications. + void getAllProperties(); + + // Icon refreshes must not wait for the session power service to reply. Q_PROPERTY(bool OnBattery READ onBattery NOTIFY OnBatteryChanged) inline bool onBattery() const - { return qvariant_cast< bool >(property("OnBattery")); } + { return m_onBattery; } Q_PROPERTY(BatteryPercentageMap BatteryPercentage READ batteryPercentage NOTIFY BatteryPercentageChanged) inline BatteryPercentageMap batteryPercentage() const - { return qvariant_cast< BatteryPercentageMap >(property("BatteryPercentage")); } + { return m_batteryPercentage; } Q_PROPERTY(BatteryStateMap BatteryState READ batteryState NOTIFY BatteryStateChanged) inline BatteryStateMap batteryState() const - { return qvariant_cast< BatteryStateMap >(property("BatteryState")); } + { return m_batteryState; } public Q_SLOTS: // METHODS Q_SIGNALS: // SIGNALS @@ -82,6 +61,17 @@ public Q_SLOTS: // METHODS void OnBatteryChanged(); void BatteryPercentageChanged(); void BatteryStateChanged(); + +private: + void updateProperties(const QVariantMap &properties); + + QTimer m_refreshTimer; + QDBusPendingCallWatcher *m_pendingSnapshot = nullptr; + QSet m_liveProperties; + bool m_refreshPending = false; + bool m_onBattery = false; + BatteryPercentageMap m_batteryPercentage; + BatteryStateMap m_batteryState; }; namespace org { diff --git a/src/loader/main.cpp b/src/loader/main.cpp index d6a7caf10..b131c09eb 100644 --- a/src/loader/main.cpp +++ b/src/loader/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include @@ -74,8 +75,10 @@ class LoaderApplication : public Dtk::Widget::DApplication int main(int argc, char *argv[], char *envp[]) { - // ExecCondition checks membership without initializing the GUI or connecting - // to the dock compositor. Exit 1 skips an empty group; 255 reports an error. + QElapsedTimer startupTimer; + startupTimer.start(); + // Retained for callers of the old ExecCondition interface. Check membership + // without GUI initialization: 1 skips an empty group, 255 reports an error. if (argc > 1 && qstrcmp(argv[1], "--check-group") == 0) { QCoreApplication app(argc, argv); if (argc != 3 || !loader::isValidGroup(QString::fromLocal8Bit(argv[2]))) { @@ -205,7 +208,6 @@ int main(int argc, char *argv[], char *envp[]) return 2; } if (pluginPaths.isEmpty()) { - // Also handle a group becoming empty after ExecCondition ran. qWarning() << "No plugins in group" << pluginGroupName << ", exiting."; return 0; } @@ -227,29 +229,48 @@ int main(int argc, char *argv[], char *envp[]) PluginManager pluginManager; pluginManager.setPluginPaths(pluginPaths); - if (!pluginManager.loadPlugins()) { - qWarning() << "No valid plugins were loaded."; - return -1; - } - + // Plugin constructors/init use the private compositor environment. Between + // plugins, callbacks and commands must inherit the real session environment, + // just as they did after synchronous loading finished. + QMap pluginEnvs; + for (auto iter = oldEnvs.cbegin(); iter != oldEnvs.cend(); ++iter) + pluginEnvs.insert(iter.key(), qgetenv(iter.key())); + pluginEnvs.insert("QT_SCALE_FACTOR", qgetenv("QT_SCALE_FACTOR")); + oldEnvs.insert("QT_SCALE_FACTOR", QByteArray()); + const auto applyEnvs = [](const QMap &envs) { + for (auto iter = envs.cbegin(); iter != envs.cend(); ++iter) { + if (iter.value().isEmpty()) + qunsetenv(iter.key()); + else + qputenv(iter.key(), iter.value()); + } + }; + QObject::connect(&pluginManager, &PluginManager::pluginLoadStarted, &app, [&] { + applyEnvs(pluginEnvs); + }); + QObject::connect(&pluginManager, &PluginManager::pluginLoadFinished, &app, [&] { + applyEnvs(oldEnvs); + }); // Display name precedence: explicit -g override, else the --group name // (empty in -p mode), else fall back to the first loaded plugin's name. QString displayGroupName = parser.value(pluginGroupNameOption); if (displayGroupName.isEmpty()) displayGroupName = pluginGroupName; - if (displayGroupName.isEmpty()) - displayGroupName = pluginManager.loadedPlugins()[0]->pluginName(); - - app.setApplicationName(displayGroupName); - app.setApplicationDisplayName(displayGroupName); - setproctitle((QStringLiteral("tray plugin: ") + displayGroupName).toStdString().c_str()); - qunsetenv("QT_SCALE_FACTOR"); - for (auto iter = oldEnvs.begin(); iter != oldEnvs.end(); iter++) { - if (iter.value().isEmpty()) { - qunsetenv(iter.key()); - } else { - qputenv(iter.key(), iter.value()); + QObject::connect(&pluginManager, &PluginManager::loadingFinished, &app, [&](bool success) { + if (!success) { + qWarning() << "No valid plugins were loaded."; + app.exit(-1); + return; } - } + if (displayGroupName.isEmpty()) + displayGroupName = pluginManager.loadedPlugins()[0]->pluginName(); + + app.setApplicationName(displayGroupName); + app.setApplicationDisplayName(displayGroupName); + setproctitle((QStringLiteral("tray plugin: ") + displayGroupName).toStdString().c_str()); + qInfo() << "Tray group initialized:" << displayGroupName << "in" << startupTimer.elapsed() << "ms"; + }); + pluginManager.loadPlugins(); + applyEnvs(oldEnvs); return app.exec(); } diff --git a/src/loader/pluginmanager.cpp b/src/loader/pluginmanager.cpp index fb84cb560..1f4a53a74 100644 --- a/src/loader/pluginmanager.cpp +++ b/src/loader/pluginmanager.cpp @@ -9,6 +9,8 @@ #include #include +#include +#include PluginManager::PluginManager(QObject *parent) : QObject(parent) @@ -20,7 +22,7 @@ void PluginManager::setPluginPaths(const QStringList &paths) m_pluginPaths = paths; } -bool PluginManager::loadPlugins() +void PluginManager::loadPlugins() { for (const QString &path : m_pluginPaths) { QFileInfo pathInfo(path); @@ -33,13 +35,28 @@ bool PluginManager::loadPlugins() if (pathInfo.isDir()) { loadPluginsFromDir(path); } else if (pathInfo.isFile()) { - loadPlugin(path); + m_pendingPluginPaths.append(path); } else { qWarning() << "Unsupported path type:" << path; } } - return !m_loadedPlugins.isEmpty(); + QTimer::singleShot(0, this, &PluginManager::loadNextPlugin); +} + +void PluginManager::loadNextPlugin() +{ + if (m_pendingPluginPaths.isEmpty()) { + Q_EMIT loadingFinished(!m_loadedPlugins.isEmpty()); + return; + } + + // Give already initialized plugins a chance to paint and dispatch Wayland + // events before loading the next one. All widget work stays on this thread. + Q_EMIT pluginLoadStarted(); + loadPlugin(m_pendingPluginPaths.takeFirst()); + Q_EMIT pluginLoadFinished(); + QTimer::singleShot(0, this, &PluginManager::loadNextPlugin); } QVector PluginManager::loadedPlugins() const @@ -49,6 +66,8 @@ QVector PluginManager::loadedPlugins() const void PluginManager::loadPlugin(const QString &pluginFilePath) { + QElapsedTimer timer; + timer.start(); QStringList blacklistedPluginPaths; // TODO: use dconfig for this purpose. if (qgetenv("XDG_SESSION_TYPE") == "wayland") { @@ -96,6 +115,7 @@ void PluginManager::loadPlugin(const QString &pluginFilePath) qDebug() << "Loaded plugin:" << interface->pluginName(); (void)new dock::WidgetPlugin(interface, this); + qInfo() << "Initialized plugin:" << interface->pluginName() << "in" << timer.elapsed() << "ms"; } void PluginManager::loadPluginsFromDir(const QString &dirPath) @@ -112,7 +132,7 @@ void PluginManager::loadPluginsFromDir(const QString &dirPath) loadPluginsFromDir(entry.absoluteFilePath()); } else { if (entry.suffix() != "so") continue; - loadPlugin(entry.absoluteFilePath()); + m_pendingPluginPaths.append(entry.absoluteFilePath()); } } } diff --git a/src/loader/pluginmanager.h b/src/loader/pluginmanager.h index c93769651..c4acd49b0 100644 --- a/src/loader/pluginmanager.h +++ b/src/loader/pluginmanager.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -15,14 +15,21 @@ class PluginManager : public QObject explicit PluginManager(QObject *parent = nullptr); void setPluginPaths(const QStringList &paths); - bool loadPlugins(); + void loadPlugins(); QVector loadedPlugins() const; +Q_SIGNALS: + void pluginLoadStarted(); + void pluginLoadFinished(); + void loadingFinished(bool success); + private: + void loadNextPlugin(); void loadPlugin(const QString &pluginFilePath); void loadPluginsFromDir(const QString &dirPath); private: QStringList m_pluginPaths; + QStringList m_pendingPluginPaths; QVector m_loadedPlugins; }; diff --git a/systemd/dde-tray-loader.target b/systemd/dde-tray-loader.target index 35bb7b81e..d7f0f4184 100644 --- a/systemd/dde-tray-loader.target +++ b/systemd/dde-tray-loader.target @@ -1,10 +1,19 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: CC0-1.0 + [Unit] Description=DDE Tray Plugin Loader target # Tray plugin processes render into the dock's internal dockplugin Wayland # compositor exported by dde-shell. Their lifetime is therefore bound to the -# dock shell: when the shell stops or crashes, the loader instances are -# stopped as well and restarted together with the shell. +# dock shell: BindsTo stops them when the shell disappears, and PartOf also +# propagates explicit dock restarts. The dock acquires its Type=dbus name +# after its compositor is ready; After= waits for that startup/restart job. +# On shutdown the ordering reverses: the dock waits for the tray groups. +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target BindsTo=dde-shell@DDE.service After=dde-shell@DDE.service -# Stop with the session core without delaying its startup for dock readiness. -PartOf=dde-session-core.target +# Core shutdown already propagates through the dock; bind only to its lifetime. +PartOf=dde-shell@DDE.service diff --git a/systemd/dde-tray-loader@.service.in b/systemd/dde-tray-loader@.service.in index 8a4e4f7fd..1da299a26 100644 --- a/systemd/dde-tray-loader@.service.in +++ b/systemd/dde-tray-loader@.service.in @@ -1,12 +1,10 @@ [Unit] Description=DDE Tray Plugin Loader (%i) Documentation=https://github.com/linuxdeepin/dde-tray-loader -# Lifetime is bound to the dock shell transitively: the target's -# BindsTo=dde-shell@DDE.service stops the target (and via PartOf, this -# instance) whenever the shell stops or crashes, and the shell's -# Wants=dde-tray-loader.target re-pulls the target (and this instance) on -# shell restart. No direct BindsTo/After on the shell here — it would be -# redundant with the target-level coupling. +# The target binds our lifetime to the dock and propagates its restarts. +# The target waits for dock activation. Require its successful activation so a +# failed dock start cannot launch loaders against a missing compositor. +Requires=dde-tray-loader.target PartOf=dde-tray-loader.target After=dde-tray-loader.target # Match the former dock supervisor: one initial start and at most five crash @@ -16,11 +14,7 @@ StartLimitBurst=6 [Service] Type=simple -# dde-shell@DDE.service is Type=dbus with BusName=org.deepin.dde.Dock1, -# which is only acquired after the dockplugin compositor is ready, so by -# the time this unit starts it is safe to connect to the dockplugin socket. -# Empty groups skip ExecStart before any GUI or Wayland initialization. -ExecCondition=@CMAKE_INSTALL_FULL_LIBEXECDIR@/trayplugin-loader --check-group %i +# Resolve the group once in the loader; empty groups exit successfully. ExecStart=@CMAKE_INSTALL_FULL_LIBEXECDIR@/trayplugin-loader --group %i -platform wayland # Normal exits (including nonzero codes) and explicit TERM/KILL do not restart. Restart=on-abnormal diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 000000000..1593af515 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: CC0-1.0 + +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets DBus) + +foreach(stage RANGE 1 2) + add_library(startup-plugin-${stage} MODULE startupplugin.cpp) + target_include_directories(startup-plugin-${stage} PRIVATE ../interfaces) + target_compile_definitions(startup-plugin-${stage} PRIVATE TEST_PLUGIN_STAGE=${stage}) + target_link_libraries(startup-plugin-${stage} PRIVATE Qt${QT_VERSION_MAJOR}::Widgets) +endforeach() + +add_test(NAME tray-plugin-event-dispatch + COMMAND trayplugin-loader -platform offscreen -p + "$;$") +# The real loader initializes file logging before loading either test plugin. +# Package builders may have no writable home; keep its cache in this build. +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/tray-plugin-event-dispatch-cache") +set_tests_properties(tray-plugin-event-dispatch PROPERTIES + ENVIRONMENT "USE_DEFAULT_PLATFORMTHEME=1;QT_LOGGING_RULES=*.debug=false;DSG_DCONFIG_BACKEND_TYPE=FileBackend;WAYLAND_DISPLAY=tray-test-outer;QT_WAYLAND_SHELL_INTEGRATION=tray-test-shell;DSG_APP_ID=tray-test-app;QT_IM_MODULE=tray-test-im;QT_SCALE_FACTOR=2" + TIMEOUT 15) +# Debian disables build RPATH, and the interface library is not installed when +# dh_auto_test runs. Always test the library from this build, not a system copy. +set_property(TEST tray-plugin-event-dispatch APPEND PROPERTY ENVIRONMENT + "LD_LIBRARY_PATH=$" + "XDG_CACHE_HOME=${CMAKE_CURRENT_BINARY_DIR}/tray-plugin-event-dispatch-cache") + +# Manual integration test: needs an isolated D-Bus session, not a desktop. +# Keep it out of package-build tests, like test_systemd_lifecycle.py. +find_package(Dtk${DTK_VERSION_MAJOR} REQUIRED COMPONENTS Core) +add_executable(power-dbus-cache-test EXCLUDE_FROM_ALL + powerdbuscache.cpp + ../plugins/dde-dock/power/dbus/dbuspower.cpp + ../plugins/dde-dock/power/dbus/dbuspower.h) +target_include_directories(power-dbus-cache-test PRIVATE ../plugins/dde-dock/power/dbus) +target_link_libraries(power-dbus-cache-test PRIVATE Qt${QT_VERSION_MAJOR}::DBus Dtk${DTK_VERSION_MAJOR}::Core) diff --git a/tests/power-dbus-session.conf b/tests/power-dbus-session.conf new file mode 100644 index 000000000..3090b644f --- /dev/null +++ b/tests/power-dbus-session.conf @@ -0,0 +1,15 @@ + + + + + session + unix:tmpdir=/tmp + EXTERNAL + + + + + + + diff --git a/tests/powerdbuscache.cpp b/tests/powerdbuscache.cpp new file mode 100644 index 000000000..a16dc4cee --- /dev/null +++ b/tests/powerdbuscache.cpp @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Manual integration test; run on an isolated bus (see README). +#include "dbuspower.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +static const QString service = QStringLiteral("org.deepin.dde.Power1"); +static const QString path = QStringLiteral("/org/deepin/dde/Power1"); + +static void require(bool success, const char *message) +{ + if (!success) + throw std::runtime_error(message); +} + +static bool waitUntil(const std::function &condition) +{ + QElapsedTimer timer; + timer.start(); + while (!condition() && timer.elapsed() < 4000) { + QCoreApplication::processEvents(); + QThread::msleep(5); + } + return condition(); +} + +static void processFor(int milliseconds) +{ + QEventLoop loop; + QTimer::singleShot(milliseconds, &loop, &QEventLoop::quit); + loop.exec(); +} + +class PowerService : public QDBusVirtualObject +{ +public: + PowerService(double percentage, int failures) : m_percentage(percentage), m_failures(failures) {} + + QString introspect(const QString &) const override + { + return QStringLiteral("" + "" + "" + "" + ""); + } + + bool handleMessage(const QDBusMessage &message, const QDBusConnection &bus) override + { + if (message.interface() == QLatin1String("org.freedesktop.DBus.Properties")) { + const auto values = properties(); + QList reply; + if (message.member() == QLatin1String("GetAll")) { + ++m_getAllCount; + if (m_failures > 0) { + --m_failures; + // Report the same error as a timed-out request while keeping + // the bus name and all non-percentage properties unchanged. + bus.send(message.createErrorReply(QDBusError::NoReply, "Transient startup failure")); + return true; + } + reply << values; + } else if (message.member() == QLatin1String("Get")) { + reply << QVariant::fromValue(QDBusVariant(values.value(message.arguments().at(1).toString()))); + } else { + return false; + } + // A separate process keeps this delay realistic for synchronous callers. + QTimer::singleShot(800, this, [this, bus, message, reply] { + bus.send(message.createReply(reply)); + if (message.member() == QLatin1String("GetAll")) + ++m_getAllReplies; + }); + return true; + } + if (message.member() == QLatin1String("GetAllCount")) { + bus.send(message.createReply(QVariantList{m_getAllCount})); + return true; + } else if (message.member() == QLatin1String("GetAllReplies")) { + bus.send(message.createReply(QVariantList{m_getAllReplies})); + return true; + } else if (message.member() == QLatin1String("PercentageOnly")) { + m_percentage = 54; + auto changed = QDBusMessage::createSignal(path, "org.freedesktop.DBus.Properties", "PropertiesChanged"); + changed << service << QVariantMap{{"BatteryPercentage", properties().value("BatteryPercentage")}} << QStringList(); + bus.send(changed); + } else if (message.member() == QLatin1String("Advance")) { + m_percentage = 77; + m_onBattery = false; + m_state = 1; + auto changed = QDBusMessage::createSignal(path, "org.freedesktop.DBus.Properties", "PropertiesChanged"); + changed << service << properties() << QStringList(); + bus.send(changed); + } else if (message.member() == QLatin1String("Invalidate")) { + m_percentage = 66; + auto changed = QDBusMessage::createSignal(path, "org.freedesktop.DBus.Properties", "PropertiesChanged"); + changed << service << QVariantMap() << QStringList{QStringLiteral("BatteryPercentage")}; + bus.send(changed); + } else if (message.member() == QLatin1String("Quit")) { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } else { + return false; + } + bus.send(message.createReply()); + return true; + } + +private: + QVariantMap properties() const + { + return {{QStringLiteral("OnBattery"), m_onBattery}, + {QStringLiteral("BatteryPercentage"), QVariant::fromValue(BatteryPercentageMap{{"Display", m_percentage}})}, + {QStringLiteral("BatteryState"), QVariant::fromValue(BatteryStateMap{{"Display", m_state}})}}; + } + + double m_percentage; + int m_failures; + int m_getAllCount = 0; + int m_getAllReplies = 0; + bool m_onBattery = true; + quint32 m_state = 2; +}; + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + auto bus = QDBusConnection::sessionBus(); + if (app.arguments().value(1) == QLatin1String("--service")) { + PowerService fixture(app.arguments().value(2).toDouble(), app.arguments().value(3).toInt()); + if (!bus.registerVirtualObject(path, &fixture) || !bus.registerService(service)) + return 2; + std::puts("READY"); + std::fflush(stdout); + return app.exec(); + } + + QProcess fixture; + try { + const auto start = [&](int percentage, int failures = 0) { + fixture.start(app.applicationFilePath(), {"--service", QString::number(percentage), QString::number(failures)}); + require(fixture.waitForStarted(1000) && fixture.waitForReadyRead(3000), "fixture did not start"); + require(fixture.readAllStandardOutput().contains("READY"), "fixture did not acquire its bus name"); + require(bus.interface()->isServiceRegistered(service).value(), "fixture service is not registered"); + }; + const auto command = [&](const QString &member) { + const auto reply = bus.call(QDBusMessage::createMethodCall(service, path, service, member), QDBus::Block, 1000); + require(reply.type() == QDBusMessage::ReplyMessage, "fixture command failed"); + return reply; + }; + const auto percentage = [](const DBusPower &power) { + return power.batteryPercentage().value(QStringLiteral("Display"), -1); + }; + + start(55, 2); + { + DBusPower recovering; + require(waitUntil([&] { return command("GetAllCount").arguments().at(0).toInt() == 1; }), + "initial GetAll failure was not injected"); + command("PercentageOnly"); + require(waitUntil([&] { return percentage(recovering) == 54; }), "partial update was not received"); + require(!recovering.onBattery() && recovering.batteryState().isEmpty(), + "partial update unexpectedly supplied the missing properties"); + + int ticks = 0; + QTimer heartbeat; + QObject::connect(&heartbeat, &QTimer::timeout, &recovering, [&] { ++ticks; }); + heartbeat.start(50); + require(waitUntil([&] { return recovering.onBattery() + && recovering.batteryState().value("Display") == 2; }), + "cache did not recover after failed GetAll without a service restart"); + require(ticks >= 10, "retries did not preserve event-loop progress"); + require(percentage(recovering) == 54, "retry lost the current percentage"); + require(command("GetAllCount").arguments().at(0).toInt() == 3, "unexpected retry count"); + processFor(1300); + require(command("GetAllCount").arguments().at(0).toInt() == 3, "retry continued after success"); + } + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + + // Destruction must cancel a scheduled retry. + start(55, 100); + { + DBusPower cancelled; + require(waitUntil([&] { return command("GetAllCount").arguments().at(0).toInt() == 1; }), + "pending-retry failure was not injected"); + processFor(100); + } + processFor(1300); + require(command("GetAllCount").arguments().at(0).toInt() == 1, "retry survived proxy destruction"); + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + + // The fixture captures 55% at request time, then replies 800 ms later. + // Live updates must survive the old reply, including charging status. + start(55); + { + DBusPower ordered; + require(waitUntil([&] { return command("GetAllCount").arguments().at(0).toInt() == 1; }), + "snapshot was not requested"); + command("Advance"); + require(waitUntil([&] { return percentage(ordered) == 77 && !ordered.onBattery() + && ordered.batteryState().value("Display") == 1; }), + "live update did not arrive before the snapshot"); + bool rolledBack = false; + const auto check = [&] { + rolledBack |= percentage(ordered) != 77 || ordered.onBattery() + || ordered.batteryState().value("Display") != 1; + }; + QObject::connect(&ordered, &DBusPower::BatteryPercentageChanged, &ordered, check); + QObject::connect(&ordered, &DBusPower::OnBatteryChanged, &ordered, check); + QObject::connect(&ordered, &DBusPower::BatteryStateChanged, &ordered, check); + require(waitUntil([&] { return command("GetAllReplies").arguments().at(0).toInt() == 1; }), + "delayed snapshot was not sent"); + processFor(100); + require(!rolledBack && percentage(ordered) == 77 && !ordered.onBattery() + && ordered.batteryState().value("Display") == 1, + "delayed snapshot overwrote newer battery state"); + require(command("GetAllCount").arguments().at(0).toInt() == 1, + "live updates caused an unnecessary snapshot request"); + } + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + + // Only the percentage changes: the snapshot must still supply the + // other properties. An invalidation in flight needs another snapshot. + start(55); + { + DBusPower partial; + require(waitUntil([&] { return command("GetAllCount").arguments().at(0).toInt() == 1; }), + "partial-update snapshot was not requested"); + command("PercentageOnly"); + require(waitUntil([&] { return percentage(partial) == 54; }), "partial update was not received"); + require(waitUntil([&] { return partial.onBattery() && partial.batteryState().value("Display") == 2; }), + "snapshot did not fill properties missing from the live update"); + require(percentage(partial) == 54, "snapshot overwrote the live percentage"); + partial.getAllProperties(); + require(waitUntil([&] { return command("GetAllCount").arguments().at(0).toInt() == 2; }), + "invalidation snapshot was not requested"); + command("Invalidate"); + require(waitUntil([&] { return percentage(partial) == 66; }), + "in-flight invalidation was lost"); + require(command("GetAllCount").arguments().at(0).toInt() == 3, + "in-flight invalidation did not coalesce into one follow-up snapshot"); + } + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + + // Also exercise the service appearing after a proxy was constructed. + DBusPower lateService; + start(55); + QElapsedTimer elapsed; + elapsed.start(); + DBusPower power; + require(power.batteryPercentage().isEmpty() && power.batteryState().isEmpty() && !power.onBattery(), + "initial reads should return empty cached values"); + require(elapsed.elapsed() < 500, "property getters waited for the delayed service"); + + int changes = 0; + QObject::connect(&power, &DBusPower::BatteryPercentageChanged, &app, [&] { ++changes; }); + const bool ready = waitUntil([&] { return percentage(power) == 55 && percentage(lateService) == 55 + && power.onBattery() && power.batteryState().value("Display") == 2; }); + if (!ready) + qWarning() << percentage(power) << percentage(lateService) << power.onBattery() << power.batteryState() + << fixture.readAllStandardError(); + require(ready, + "initial asynchronous snapshot or late service discovery failed"); + command("Advance"); + require(waitUntil([&] { return percentage(power) == 77 && !power.onBattery() + && power.batteryState().value("Display") == 1 && changes >= 2; }), + "property change did not update the cache and notify the UI"); + command("Invalidate"); + require(waitUntil([&] { return percentage(power) == 66; }), "invalidated property was not refreshed"); + + // Restart while another asynchronous snapshot is pending. + power.getAllProperties(); + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + start(90); + require(waitUntil([&] { return percentage(power) == 90 && percentage(lateService) == 90; }), + "cache did not recover after service restart"); + command("Quit"); + require(fixture.waitForFinished(1000), "fixture did not quit"); + std::puts("PASS: snapshot ordering, partial updates, failed GetAll recovery, cancellation, invalidation and restart"); + return 0; + } catch (const std::exception &error) { + std::fprintf(stderr, "%s\n", error.what()); + fixture.kill(); + fixture.waitForFinished(1000); + return 1; + } +} diff --git a/tests/startupplugin.cpp b/tests/startupplugin.cpp new file mode 100644 index 000000000..18515d465 --- /dev/null +++ b/tests/startupplugin.cpp @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "pluginsiteminterface.h" + +#include + +static void checkSessionEnvironment() +{ + if (qgetenv("WAYLAND_DISPLAY") != "tray-test-outer" + || qgetenv("QT_WAYLAND_SHELL_INTEGRATION") != "tray-test-shell" + || qgetenv("DSG_APP_ID") != "tray-test-app" + || qgetenv("QT_IM_MODULE") != "tray-test-im" + || qEnvironmentVariableIsSet("QT_SCALE_FACTOR")) + std::exit(44); +} + +class StartupPlugin : public QObject, public PluginsItemInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID ModuleInterface_iid) + Q_INTERFACES(PluginsItemInterface) + +public: + const QString pluginName() const override + { + return QStringLiteral("startup-test-%1").arg(TEST_PLUGIN_STAGE); + } + + QWidget *itemWidget(const QString &) override { return nullptr; } + + void init(PluginProxyInterface *) override + { + if (qgetenv("WAYLAND_DISPLAY") != "dockplugin" + || qgetenv("QT_WAYLAND_SHELL_INTEGRATION") != "plugin-shell" + || qgetenv("DSG_APP_ID") != "org.deepin.dde.tray-loader" + || qgetenv("QT_IM_MODULE") != "wayland") + std::exit(43); + + if (TEST_PLUGIN_STAGE == 1) { + QTimer::singleShot(0, qApp, [] { + checkSessionEnvironment(); + qApp->setProperty("firstPluginEventDispatched", true); + }); + } else { + // Exercise the real loader/plugin boundary: the first plugin's + // queued work must run before the second plugin initializes. + if (!qApp->property("firstPluginEventDispatched").toBool()) + std::exit(42); + QTimer::singleShot(0, qApp, [] { + checkSessionEnvironment(); + qApp->quit(); + }); + } + } +}; + +#include "startupplugin.moc" diff --git a/tests/test_systemd_lifecycle.py b/tests/test_systemd_lifecycle.py new file mode 100644 index 000000000..c8e5cbb53 --- /dev/null +++ b/tests/test_systemd_lifecycle.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Manual integration test: python3 tests/test_systemd_lifecycle.py ../dde-session + +Requires a running user systemd manager and python3-gi. Uses uniquely named +temporary units and a private bus name; never stops the actual desktop services. +This is not a CTest test: Debian builders need no running user session. +""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + + +def fixture(role, label, logfile, busname): + from gi.repository import Gio, GLib + + loop = GLib.MainLoop() + owner_pid = None + + def mark(event): + with open(logfile, "a") as stream: + stream.write(json.dumps(dict(event=event, role=role, label=label, + pid=os.getpid(), owner_pid=owner_pid, + time=time.monotonic())) + "\n") + + def stop(): + mark("stopping") + time.sleep(float(os.environ.get("STOP_DELAY", "0"))) + mark("exited") + loop.quit() + return False + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, stop) + if role in ("dock", "desktop"): + def ready(): + name = busname if role == "dock" else busname + ".Desktop" + Gio.bus_own_name(Gio.BusType.SESSION, name, Gio.BusNameOwnerFlags.NONE, + None, lambda *args: mark("ready"), None) + return False + + GLib.timeout_add(int(os.environ.get("READY_DELAY_MS", "350")), ready) + elif role == "tray": + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + reply = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "GetConnectionUnixProcessID", + GLib.Variant("(s)", (busname,)), None, + Gio.DBusCallFlags.NONE, 1000, None) + owner_pid = reply.unpack()[0] + mark("started") + loop.run() + + +def ctl(*args, check=True): + result = subprocess.run(["systemctl", "--user", *args], text=True, + capture_output=True, timeout=15) + if check and result.returncode: + raise RuntimeError(f"systemctl {args}: {result.stdout}{result.stderr}") + return result.stdout + + +def state(unit): + return dict(line.split("=", 1) for line in + ctl("show", unit, "-p", "MainPID", "-p", "ActiveState", "-p", "SubState").splitlines()) + + +def until(predicate): + deadline = time.monotonic() + 12 + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + raise AssertionError("Timed out waiting for service state or fixture event") + + +def exercise(session_repo, dock_delay, tray_delay, work): + repo = Path(__file__).resolve().parents[1] + base = f"tray-lifecycle-test-{os.getpid()}" + dock, desktop = base + "-dock.service", base + "-desktop.service" + core, target = base + "-core.target", base + ".target" + groups = [base + "@one.service", base + "@two.service"] + bus = f"org.example.TrayLifecycle.p{os.getpid()}" + logfile = work / "events.jsonl" + directory = Path(os.environ["XDG_RUNTIME_DIR"]) / "systemd/user" + directory.mkdir(parents=True, exist_ok=True) + # Quote paths for systemd's command-line syntax (not a shell). + command = f'/usr/bin/python3 "{Path(__file__).resolve()}" --fixture' + + def start_command(role, label): + return f'{command} {role} {label} "{logfile}" {bus}' + + def renamed(source): + for old, new in { + "dde-shell@DDE.service": dock, + "dde-shell-plugin@org.deepin.ds.desktop.service": desktop, + "dde-session-core.target": core, + "dde-tray-loader.target": target, + "dde-tray-loader@": base + "@", + "org.deepin.dde.Dock1": bus, + }.items(): + source = source.replace(old, new) + return source + + dock_source = renamed((session_repo / "systemd/dde-session-core.target.wants/dde-shell@DDE.service").read_text()) + dock_source = "\n".join( + "ExecStart=" + start_command("dock", "dock") if line.startswith("ExecStart=") else line + for line in dock_source.splitlines() + if not line.startswith(("Requisite=", "After=dde-session-pre", "Requires=dbus", + "After=dbus", "Wants=org.desktopspec", "Slice="))) + dock_source = dock_source.replace("[Service]", f"Wants={desktop}\n[Service]") + dock_source += f"\nEnvironment=READY_DELAY_MS=350 STOP_DELAY={dock_delay}\nTimeoutStopSec=5s\n" + group_source = renamed((repo / "systemd/dde-tray-loader@.service.in").read_text()) + group_source = "\n".join( + "ExecStart=" + start_command("tray", "%i") if line.startswith("ExecStart=") else line + for line in group_source.splitlines() if not line.startswith("Slice=")) + group_source += f"\nEnvironment=STOP_DELAY={tray_delay}\nTimeoutStopSec=5s\n" + files = { + dock: dock_source, + desktop: f"[Unit]\nPartOf={core}\nBefore={core}\n[Service]\n" + f"Type=dbus\nBusName={bus}.Desktop\nEnvironment=READY_DELAY_MS=1000\n" + f"ExecStart={start_command('desktop', 'desktop')}\n", + core: f"[Unit]\nDefaultDependencies=no\nWants={dock} {desktop}\n", + target: renamed((repo / "systemd/dde-tray-loader.target").read_text()) + + "\nWants=" + " ".join(groups) + "\n", + base + "@.service": group_source, + } + + def rows(since=0): + events = [json.loads(line) for line in logfile.read_text().splitlines()] if logfile.exists() else [] + return [event for event in events if event["time"] >= since] + + def trays_started(old=None): + events = rows() + for unit in groups: + current = state(unit) + if current["ActiveState"] != "active" or (old and current["MainPID"] == old[unit]): + return False + if not any(r["role"] == "tray" and r["event"] == "started" + and str(r["pid"]) == current["MainPID"] for r in events): + return False + return True + + def check_generation(since): + events = rows(since) + ready = next(r for r in events if r["role"] == "dock" and r["event"] == "ready") + starts = [r for r in events if r["role"] == "tray" and r["event"] == "started"] + assert len(starts) == len(groups), starts + assert all(r["time"] >= ready["time"] and r["owner_pid"] == ready["pid"] for r in starts), events + assert state(target)["ActiveState"] == "active" + + all_units = [core, dock, desktop, target, *groups] + try: + for name, data in files.items(): + path = directory / name + assert not path.exists(), path + path.write_text(data) + ctl("daemon-reload") + ctl("start", core) + until(trays_started) + check_generation(0) + events = rows() + dock_start = next(r["time"] for r in events if r["role"] == "dock" and r["event"] == "started") + desktop_ready = next(r["time"] for r in events if r["role"] == "desktop" and r["event"] == "ready") + assert dock_start < desktop_ready, events + print("PASS: Dock starts before desktop readiness; trays wait for ready Dock", flush=True) + + for crash in (False, True): + old = {unit: state(unit)["MainPID"] for unit in groups} + since = time.monotonic() + if crash: + ctl("kill", "--kill-whom=main", "--signal=KILL", dock) + else: + ctl("restart", dock) + until(lambda: trays_started(old)) + check_generation(since) + assert all(not Path("/proc", pid).exists() for pid in old.values()) + print(f"PASS: {'crash recovery' if crash else 'explicit restart'} uses replacement Dock", flush=True) + + since = time.monotonic() + ctl("stop", core) + until(lambda: all(state(unit)["MainPID"] == "0" for unit in [dock, desktop, *groups])) + events = rows(since) + tray_stops = [r["time"] for r in events if r["event"] == "stopping" and r["role"] == "tray"] + tray_exits = [r["time"] for r in events if r["event"] == "exited" and r["role"] == "tray"] + dock_stop = next(r["time"] for r in events if r["role"] == "dock" and r["event"] == "stopping") + dock_exit = next(r["time"] for r in events if r["role"] == "dock" and r["event"] == "exited") + desktop_stop = next(r["time"] for r in events if r["role"] == "desktop" and r["event"] == "stopping") + assert len(tray_stops) == len(groups) and max(tray_stops) - min(tray_stops) < 0.5, events + assert len(tray_exits) == len(groups) and max(tray_exits) <= dock_stop, events + # Core shutdown must not make the desktop wait for a slow Dock. + # Startup and shutdown have no desktop/Dock ordering edge. + assert abs(desktop_stop - min(tray_stops)) < 0.5, events + if dock_delay > 0.5: + assert desktop_stop < dock_exit, events + if tray_delay > dock_delay: + assert desktop_stop < min(r["time"] for r in events if r["role"] == "tray" and r["event"] == "exited"), events + print(f"PASS: tray groups stop in parallel, Dock waits for their exit; " + f"Dock stops at {dock_stop-since:.3f}s and exits at {dock_exit-since:.3f}s, " + f"desktop stops at {desktop_stop-since:.3f}s", flush=True) + + delayed = dock_source.replace("READY_DELAY_MS=350", "READY_DELAY_MS=4000") + (directory / dock).write_text(delayed) + ctl("daemon-reload") + since = time.monotonic() + ctl("start", "--no-block", core) + until(lambda: state(dock)["SubState"] == "start" and + any(r["role"] == "dock" and r["event"] == "started" for r in rows(since))) + ctl("stop", core, dock) + until(lambda: all(state(unit)["ActiveState"] in ("inactive", "failed") for unit in all_units)) + assert not any(r["role"] == "tray" and r["event"] == "started" for r in rows(since)) + print("PASS: cancellation during Dock startup launches no trays", flush=True) + + failed = delayed.replace("TimeoutStartSec=30s", "TimeoutStartSec=1s").replace("Restart=always", "Restart=no") + (directory / dock).write_text(failed) + ctl("daemon-reload") + # Inactive fixtures can be unloaded between glob expansion and reset. + # Reset is best-effort cleanup; the state/event assertions below verify + # that the next start actually succeeds or fails as expected. + ctl("reset-failed", base + "*", check=False) + since = time.monotonic() + ctl("start", "--no-block", core) + until(lambda: state(dock)["ActiveState"] == "failed" and state(target)["ActiveState"] != "active") + assert state(target)["ActiveState"] != "active" + assert not any(r["role"] == "tray" and r["event"] == "started" for r in rows(since)) + print("PASS: Dock startup failure does not launch trays", flush=True) + + ctl("stop", core) + (directory / dock).write_text(dock_source) + ctl("daemon-reload") + ctl("reset-failed", base + "*", check=False) + since = time.monotonic() + ctl("start", groups[0]) + until(trays_started) + check_generation(since) + print("PASS: starting a tray instance recovers failed Dock via its target", flush=True) + except Exception: + print(logfile.read_text() if logfile.exists() else "No fixture events", file=sys.stderr) + raise + finally: + ctl("stop", *all_units, check=False) + ctl("reset-failed", *all_units, check=False) + for name in files: + (directory / name).unlink(missing_ok=True) + ctl("daemon-reload") + + +if __name__ == "__main__": + if sys.argv[1:2] == ["--fixture"]: + fixture(*sys.argv[2:]) + else: + if len(sys.argv) != 2: + sys.exit(__doc__) + for dock_delay, tray_delay in ((1.5, 0.1), (0.0, 1.5)): + print(f"Testing dock stop delay={dock_delay}s, tray stop delay={tray_delay}s", flush=True) + with tempfile.TemporaryDirectory(prefix="tray-lifecycle-") as directory: + exercise(Path(sys.argv[1]).resolve(), dock_delay, tray_delay, Path(directory))