diff --git a/CMakeLists.txt b/CMakeLists.txt index 21ddaff..9d910f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ include(FeatureSummary) # Options option(BUILD_MAN_PAGES "Build man pages" OFF) +option(BUILD_TESTING "Build unit tests" OFF) option(WERROR "Build with -Werror" OFF) set(CMAKE_CXX_STANDARD 20) @@ -163,6 +164,11 @@ add_subdirectory(data) add_subdirectory(services) add_subdirectory(src) +if(BUILD_TESTING) + enable_testing() + add_subdirectory(test) +endif() + # Display feature summary feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES) diff --git a/README.zh_CN.md b/README.zh_CN.md index bc60bf4..b91cc27 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1 +1,32 @@ -TODO: +# DDM + +`ddm` 项目是基于 `SDDM` 的显示管理器分支。 + +## 依赖项 + +检查 `debian/control` 中的构建时和运行时依赖项,或者使用 `cmake` 来检查缺失的所需依赖项。 +## Building + +常规的 CMake 构建步骤适用,简而言之: + +```shell +$ cmake -Bbuild +$ cmake --build build +$ cmake --install build # 只有在你知道自己在做什么的情况下才这样做。 +``` + +提供了一个 `debian` 文件夹,用于在 *deepin* Linux 桌面发行版下构建该软件包。 要构建该包,请使用以下命令: + +```shell +$ sudo apt build-dep . # install build dependencies +$ dpkg-buildpackage -uc -us -nc -b # build binary package(s) +``` + +## 参与方式 + +- [通过 GitHub 提交代码](https://github.com/linuxdeepin/ddm/) +- [向 GitHub 问题或 GitHub 讨论中提交错误或建议](https://github.com/linuxdeepin/developer-center/issues/new/choose) + +## 许可证 + +**ddm** 采用 GPL-2.0+ 许可证。有关详细信息,请参阅 REUSE 文件。 diff --git a/src/daemon/Auth.cpp b/src/daemon/Auth.cpp index ee467a4..d7847b3 100644 --- a/src/daemon/Auth.cpp +++ b/src/daemon/Auth.cpp @@ -7,6 +7,7 @@ #include "DaemonApp.h" #include "DdeSeatdControl.h" +#include "ForkExitGuard.h" #include "Login1Manager.h" #include "Login1Session.h" #include "SignalHandler.h" @@ -281,6 +282,26 @@ namespace DDM { return -1; } + // Install the forked-child exit bypass here, in the daemon, while + // libc locks are still consistent and atexit() is safe to call. + // + // Exit handlers are inherited across fork(): without the bypass, a + // forked child calling exit() would run the daemon's cleanup + // handlers (e.g. libQt6DBus joining its dispatcher thread), which + // deadlock because those threads do not exist after fork(). The + // bypass is registered last, so it runs first (LIFO) in every + // descendant -- grandchildren forked by PAM modules (e.g. + // pam_gnome_keyring) and QProcess children included -- and _exit(0)s + // before any inherited handler is reached. The daemon itself is + // excluded via the owner PID check and keeps its regular cleanup. + // + // This must never be done from a pthread_atfork child handler: + // atexit() is not async-signal-safe and may block forever on libc + // internal locks inherited in a locked state from another thread of + // the forking process. + if (!ForkExitGuard::install()) + qWarning() << "[Auth] Failed to install the forked-child exit bypass"; + sessionLeaderPid = fork(); switch (sessionLeaderPid) { case -1: { @@ -319,11 +340,20 @@ namespace DDM { env.insert(QStringLiteral("LOGNAME"), QString::fromLocal8Bit(pw->pw_name)); } + // Neither this process nor anything forked from it may run the + // exit handlers inherited from the daemon: they were registered + // before fork() and may wait on threads that no longer exist + // (e.g. libQt6DBus joining its dispatcher thread blocks forever + // after fork()). The session leader therefore terminates with + // _exit() directly, and descendants forked by PAM modules which + // call exit() (e.g. pam_gnome_keyring) are terminated by the + // ForkExitGuard handler the daemon installed before fork(). + // Open session auto sessionEnv = openSessionInternal(env); if (!sessionEnv.has_value()) { qCritical() << "[SessionLeader] Failed to open session. Exit now."; - exit(1); + _exit(1); } env = *sessionEnv; @@ -331,11 +361,11 @@ namespace DDM { xdgSessionId = env.value(QStringLiteral("XDG_SESSION_ID")).toInt(); if (xdgSessionId <= 0) { qCritical() << "[SessionLeader] Invalid XDG_SESSION_ID from pam_open_session()"; - exit(1); + _exit(1); } if (write(pipefd[1], &xdgSessionId, sizeof(int)) != sizeof(int)) { qCritical() << "[SessionLeader] Failed to write XDG_SESSION_ID to parent process!"; - exit(1); + _exit(1); } // RUN!!! @@ -344,14 +374,14 @@ namespace DDM { session.start(command, type, cookie); if (!session.waitForStarted()) { qCritical() << "[SessionLeader] Failed to start session process. Exit now."; - exit(1); + _exit(1); } // Send session PID to parent sessionPid = session.processId(); if (write(pipefd[1], &sessionPid, sizeof(qint64)) != sizeof(qint64)) { qCritical() << "[SessionLeader] Failed to write session PID to parent process!"; - exit(1); + _exit(1); } qInfo() << "[SessionLeader] Session started with PID" << sessionPid; @@ -360,11 +390,11 @@ namespace DDM { // Handle session end if (session.exitStatus() == QProcess::CrashExit) { qCritical() << "[SessionLeader] Session process crashed. Exit now."; - exit(1); + _exit(1); } qInfo() << "[SessionLeader] Session process finished with exit code" << session.exitCode() << ". Exiting."; - exit(session.exitCode()); + _exit(session.exitCode()); } default: { // Parent process diff --git a/src/daemon/ForkExitGuard.h b/src/daemon/ForkExitGuard.h new file mode 100644 index 0000000..9e88fc4 --- /dev/null +++ b/src/daemon/ForkExitGuard.h @@ -0,0 +1,71 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifndef DDM_FORKEXITGUARD_H +#define DDM_FORKEXITGUARD_H + +#include +#include +#include + +namespace DDM::ForkExitGuard { + /** PID of the process which installed the exit bypass. Forked children + * inherit this value unchanged, so inside a child it still refers to + * the original (daemon) process. -1 means "not installed". */ + inline pid_t s_ownerPid{ -1 }; + + /** + * atexit(3) handler terminating every process except the one which + * installed it (i.e. every fork()ed descendant) immediately with + * _exit(0), so that exit() in a forked child skips all the other + * exit handlers. + * + * Exit handlers registered via atexit()/__cxa_atexit() (such as Qt's + * static cleanup, e.g. libQt6DBus joining its dispatcher thread) are + * inherited across fork(), but the threads and other resources they + * operate on are not: running them in a forked child can deadlock + * forever. Exit handlers run in LIFO order, so this handler bypasses + * every handler registered before the last install() call. + */ + inline void bypassInheritedCleanup() { + if (::getpid() != s_ownerPid) + ::_exit(0); + } + + /** + * Installs (or refreshes) the exit bypass for forked children. + * + * Must be called from the original daemon process before fork(), + * where all libc locks are in a consistent state. It must never be + * called from a pthread_atfork child handler or any other post-fork + * child context: atexit() is not async-signal-safe and may block + * forever acquiring libc internal locks which were inherited in a + * locked state from another thread of the forking process. + * + * Calling this again before every fork() re-registers the handler at + * the end of the exit handler list, so that it also runs before any + * handler registered since the previous install(). The duplicate + * registrations are harmless in the owner process, where the handler + * is a no-op and the regular cleanup runs unchanged. + * + * @return true on success, false if the handler could not be registered + */ + inline bool install() { + if (::atexit(bypassInheritedCleanup) != 0) + return false; + s_ownerPid = ::getpid(); + return true; + } + + /** Whether the exit bypass has been installed in this process image. */ + inline bool isInstalled() { + return s_ownerPid != -1; + } + + /** PID of the process which installed the bypass, -1 if not installed. */ + inline pid_t ownerPid() { + return s_ownerPid; + } +} + +#endif // DDM_FORKEXITGUARD_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..47ca103 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,15 @@ +find_package(Qt6 CONFIG REQUIRED Test) + +add_executable(tst_forkexitguard + tst_forkexitguard.cpp +) + +target_include_directories(tst_forkexitguard PRIVATE + "${CMAKE_SOURCE_DIR}/src/daemon" +) + +target_link_libraries(tst_forkexitguard PRIVATE + Qt6::Test +) + +add_test(NAME tst_forkexitguard COMMAND tst_forkexitguard) diff --git a/test/tst_forkexitguard.cpp b/test/tst_forkexitguard.cpp new file mode 100644 index 0000000..433d6f3 --- /dev/null +++ b/test/tst_forkexitguard.cpp @@ -0,0 +1,278 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "ForkExitGuard.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace DDM; + +namespace { + constexpr char selfTestEnv[] = "DDM_FORKEXITGUARD_SELFTEST"; + + // Canary exit handler used to verify which handlers get to run before + // ForkExitGuard bypasses them. When armed, it writes one byte into a + // pipe watched by the test process; an armed write from a forked child + // means the inherited handler was NOT bypassed. + int s_canaryFd = -1; + bool s_canaryArmed = false; + + void canaryExitHandler() { + if (s_canaryArmed && s_canaryFd >= 0) { + const char marker = 'C'; + const ssize_t written = ::write(s_canaryFd, &marker, 1); + Q_UNUSED(written); + } + } + + // Disarm the canary and forget its pipe write end (parent side only). + void disarmCanary() { + s_canaryArmed = false; + s_canaryFd = -1; + } + + int waitChild(pid_t pid) { + int status = -1; + while (::waitpid(pid, &status, 0) == -1 && errno == EINTR) { } + return status; + } + + // Run this test binary again as a helper process in the given self-test + // mode and return it via outProc (already finished). + bool runSelfTest(const char *mode, QProcess &outProc) { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QLatin1String(selfTestEnv), QLatin1String(mode)); + outProc.setProcessEnvironment(env); + outProc.setProgram(QCoreApplication::applicationFilePath()); + outProc.start(); + if (!outProc.waitForStarted(5000)) + return false; + return outProc.waitForFinished(10000); + } +} + +class TestForkExitGuard : public QObject { + Q_OBJECT +private Q_SLOTS: + void testInitialState(); + void testInstall(); + void testHandlerIsNoOpInOwnerProcess(); + void testHandlerExitsInForkedChild(); + void testChildExitBypassesOlderHandlers(); + void testReinstallSupersedesNewerHandlers(); + void testOwnerExitPreservesNormalCleanup(); + void testDescendantExitIsBypassed(); + void testHandlerNoOpInOwnerSubprocess(); +}; + +void TestForkExitGuard::testInitialState() +{ + // Must run first: a fresh process image has no guard installed yet. + QVERIFY(!ForkExitGuard::isInstalled()); + QCOMPARE(ForkExitGuard::ownerPid(), static_cast(-1)); +} + +void TestForkExitGuard::testInstall() +{ + QVERIFY(ForkExitGuard::install()); + QVERIFY(ForkExitGuard::isInstalled()); + QCOMPARE(ForkExitGuard::ownerPid(), getpid()); + + // Installing again refreshes the registration (moves the handler to the + // end of the LIFO list) and must keep the owner PID unchanged. + QVERIFY(ForkExitGuard::install()); + QVERIFY(ForkExitGuard::isInstalled()); + QCOMPARE(ForkExitGuard::ownerPid(), getpid()); +} + +void TestForkExitGuard::testHandlerIsNoOpInOwnerProcess() +{ + // In the owner process the handler must simply return, so the regular + // exit cleanup keeps running. If it wrongly _exit()ed, this test process + // would die here; testHandlerNoOpInOwnerSubprocess pins that case down + // from the outside. + QVERIFY(ForkExitGuard::isInstalled()); + ForkExitGuard::bypassInheritedCleanup(); + QVERIFY(ForkExitGuard::isInstalled()); +} + +void TestForkExitGuard::testHandlerExitsInForkedChild() +{ + QVERIFY(ForkExitGuard::isInstalled()); + + const pid_t pid = fork(); + QVERIFY(pid >= 0); + if (pid == 0) { + // Child: our PID differs from the recorded owner, so the handler + // must terminate us immediately with status 0. + ForkExitGuard::bypassInheritedCleanup(); + // Only reached if the handler wrongly returned. + _exit(123); + } + + const int status = waitChild(pid); + QVERIFY(WIFEXITED(status)); + QCOMPARE(WEXITSTATUS(status), 0); +} + +void TestForkExitGuard::testChildExitBypassesOlderHandlers() +{ + int pipeFd[2]; + QCOMPARE(pipe(pipeFd), 0); + + // Register the canary BEFORE installing the guard: it models daemon-era + // handlers (e.g. Qt static cleanup) registered before fork(). The guard + // is then the newest handler and must run first (LIFO) in the child. + s_canaryFd = pipeFd[1]; + s_canaryArmed = true; + QCOMPARE(atexit(canaryExitHandler), 0); + QVERIFY(ForkExitGuard::install()); + + const pid_t pid = fork(); + QVERIFY(pid >= 0); + if (pid == 0) { + close(pipeFd[0]); + ::exit(42); + } + close(pipeFd[1]); + disarmCanary(); + + const int status = waitChild(pid); + QVERIFY(WIFEXITED(status)); + // The guard flattened exit(42) into _exit(0). + QCOMPARE(WEXITSTATUS(status), 0); + + // The canary must not have run in the child, i.e. nothing was written. + struct pollfd pfd {}; + pfd.fd = pipeFd[0]; + pfd.events = POLLIN; + QCOMPARE(poll(&pfd, 1, 0), 0); + close(pipeFd[0]); +} + +void TestForkExitGuard::testReinstallSupersedesNewerHandlers() +{ + int pipeFd[2]; + QCOMPARE(pipe(pipeFd), 0); + + // Register the canary AFTER the previous install(), then install() + // again: handlers registered between two fork()s must also be bypassed + // in children, because the refreshed registration runs first (LIFO). + s_canaryFd = pipeFd[1]; + s_canaryArmed = true; + QCOMPARE(atexit(canaryExitHandler), 0); + QVERIFY(ForkExitGuard::install()); + + const pid_t pid = fork(); + QVERIFY(pid >= 0); + if (pid == 0) { + close(pipeFd[0]); + ::exit(7); + } + close(pipeFd[1]); + disarmCanary(); + + const int status = waitChild(pid); + QVERIFY(WIFEXITED(status)); + QCOMPARE(WEXITSTATUS(status), 0); + + struct pollfd pfd {}; + pfd.fd = pipeFd[0]; + pfd.events = POLLIN; + QCOMPARE(poll(&pfd, 1, 0), 0); + close(pipeFd[0]); +} + +void TestForkExitGuard::testOwnerExitPreservesNormalCleanup() +{ + // Subprocess: installs the guard, registers a canary and exit(7)s. + // The owner process must keep its regular cleanup (canary runs) and + // its exit code (the guard must not fire for the owner). + QProcess proc; + QVERIFY(runSelfTest("owner-exit", proc)); + QCOMPARE(proc.exitStatus(), QProcess::NormalExit); + QCOMPARE(proc.exitCode(), 7); + QVERIFY(QString::fromLocal8Bit(proc.readAllStandardOutput()).contains(QStringLiteral("CANARY"))); +} + +void TestForkExitGuard::testDescendantExitIsBypassed() +{ + // Subprocess: installs the guard, forks, and the child exit(9)s. + // The child inherits the guard, so it must exit with status 0 without + // running any inherited cleanup; the harness prints the child's code. + QProcess proc; + QVERIFY(runSelfTest("descendant-exit", proc)); + QCOMPARE(proc.exitStatus(), QProcess::NormalExit); + QCOMPARE(proc.exitCode(), 0); + QCOMPARE(QString::fromLocal8Bit(proc.readAllStandardOutput()).trimmed(), QStringLiteral("0")); +} + +void TestForkExitGuard::testHandlerNoOpInOwnerSubprocess() +{ + // Subprocess: installs the guard and calls the handler directly. + // A wrongly firing guard would kill the harness silently with status 0, + // so success is proven by the SURVIVED marker on stdout. + QProcess proc; + QVERIFY(runSelfTest("owner-noop", proc)); + QCOMPARE(proc.exitStatus(), QProcess::NormalExit); + QCOMPARE(proc.exitCode(), 0); + QVERIFY(QString::fromLocal8Bit(proc.readAllStandardOutput()).contains(QStringLiteral("SURVIVED"))); +} + +int main(int argc, char *argv[]) +{ + const QByteArray selfTest = qgetenv(selfTestEnv); + + if (selfTest == "owner-noop") { + if (!ForkExitGuard::install()) + return 1; + // Must return instead of terminating the process. + ForkExitGuard::bypassInheritedCleanup(); + puts("SURVIVED"); + return 0; + } + + if (selfTest == "owner-exit") { + // Registered before the guard, so it runs after the guard (LIFO): + // reaching it proves the guard did not fire for the owner. + atexit([]() { + puts("CANARY"); + }); + if (!ForkExitGuard::install()) + return 1; + ::exit(7); + } + + if (selfTest == "descendant-exit") { + if (!ForkExitGuard::install()) + return 1; + const pid_t pid = fork(); + if (pid == 0) + ::exit(9); + if (pid < 0) + return 1; + int status = -1; + while (::waitpid(pid, &status, 0) == -1 && errno == EINTR) { } + if (!WIFEXITED(status)) + return 1; + printf("%d\n", WEXITSTATUS(status)); + return 0; + } + + QCoreApplication app(argc, argv); + TestForkExitGuard tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "tst_forkexitguard.moc"