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))