From 593e170da87fda7d81769273a922f148f73b1768 Mon Sep 17 00:00:00 2001 From: Cameron Gutman Date: Sun, 7 Jan 2024 16:27:14 -0600 Subject: [PATCH] Implement graceful termination and group-based app tracking --- docs/source/about/setup.rst | 2 + src/platform/common.h | 16 ++++ src/platform/linux/misc.cpp | 27 +++++++ src/platform/macos/misc.mm | 27 +++++++ src/platform/windows/misc.cpp | 101 +++++++++++++++++++++++++ src/process.cpp | 53 +++++++++++-- src/process.h | 16 +++- src_assets/common/assets/web/apps.html | 30 ++++++++ 8 files changed, 262 insertions(+), 10 deletions(-) diff --git a/docs/source/about/setup.rst b/docs/source/about/setup.rst index bed5d8b735c..01806887cd0 100644 --- a/docs/source/about/setup.rst +++ b/docs/source/about/setup.rst @@ -550,6 +550,8 @@ Application List - ``name`` - The name of the application/game - ``output`` - The file where the output of the command is stored - ``auto-detach`` - Specifies whether the app should be treated as detached if it exits quickly + - ``wait-all`` - Specifies whether to wait for all processes to terminate rather than just the initial process + - ``exit-timeout`` - Specifies how long to wait in seconds for the process to gracefully exit (default: 5 seconds) - ``prep-cmd`` - A list of commands to be run before/after the application - If any of the prep-commands fail, starting the application is aborted diff --git a/src/platform/common.h b/src/platform/common.h index 3301803a916..ab0d085fc9a 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -618,6 +618,22 @@ namespace platf { void open_url(const std::string &url); + /** + * @brief Attempt to gracefully terminate a process group. + * @param native_handle The native handle of the process group. + * @return true if termination was successfully requested. + */ + bool + request_process_group_exit(std::uintptr_t native_handle); + + /** + * @brief Checks if a process group still has running children. + * @param native_handle The native handle of the process group. + * @return true if processes are still running. + */ + bool + process_group_running(std::uintptr_t native_handle); + input_t input(); void diff --git a/src/platform/linux/misc.cpp b/src/platform/linux/misc.cpp index 76c686ba2d8..543d6c38f28 100644 --- a/src/platform/linux/misc.cpp +++ b/src/platform/linux/misc.cpp @@ -249,6 +249,33 @@ namespace platf { lifetime::exit_sunshine(0, true); } + /** + * @brief Attempt to gracefully terminate a process group. + * @param native_handle The process group ID. + * @return true if termination was successfully requested. + */ + bool + request_process_group_exit(std::uintptr_t native_handle) { + if (kill(-((pid_t) native_handle), SIGTERM) == 0 || errno == ESRCH) { + BOOST_LOG(debug) << "Successfully sent SIGTERM to process group: "sv << native_handle; + return true; + } + else { + BOOST_LOG(warning) << "Unable to send SIGTERM to process group ["sv << native_handle << "]: "sv << errno; + return false; + } + } + + /** + * @brief Checks if a process group still has running children. + * @param native_handle The process group ID. + * @return true if processes are still running. + */ + bool + process_group_running(std::uintptr_t native_handle) { + return waitpid(-((pid_t) native_handle), nullptr, WNOHANG) >= 0; + } + struct sockaddr_in to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) { struct sockaddr_in saddr_v4 = {}; diff --git a/src/platform/macos/misc.mm b/src/platform/macos/misc.mm index 5281cc864ea..436763a00d3 100644 --- a/src/platform/macos/misc.mm +++ b/src/platform/macos/misc.mm @@ -252,6 +252,33 @@ lifetime::exit_sunshine(0, true); } + /** + * @brief Attempt to gracefully terminate a process group. + * @param native_handle The process group ID. + * @return true if termination was successfully requested. + */ + bool + request_process_group_exit(std::uintptr_t native_handle) { + if (killpg((pid_t) native_handle, SIGTERM) == 0 || errno == ESRCH) { + BOOST_LOG(debug) << "Successfully sent SIGTERM to process group: "sv << native_handle; + return true; + } + else { + BOOST_LOG(warning) << "Unable to send SIGTERM to process group ["sv << native_handle << "]: "sv << errno; + return false; + } + } + + /** + * @brief Checks if a process group still has running children. + * @param native_handle The process group ID. + * @return true if processes are still running. + */ + bool + process_group_running(std::uintptr_t native_handle) { + return waitpid(-((pid_t) native_handle), nullptr, WNOHANG) >= 0; + } + struct sockaddr_in to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) { struct sockaddr_in saddr_v4 = {}; diff --git a/src/platform/windows/misc.cpp b/src/platform/windows/misc.cpp index 455d5681817..71455837656 100644 --- a/src/platform/windows/misc.cpp +++ b/src/platform/windows/misc.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -904,6 +905,106 @@ namespace platf { lifetime::exit_sunshine(0, true); } + struct enum_wnd_context_t { + std::set process_ids; + bool requested_exit; + }; + + static BOOL CALLBACK + prgrp_enum_windows(HWND hwnd, LPARAM lParam) { + auto enum_ctx = (enum_wnd_context_t *) lParam; + + // Find the owner PID of this window + DWORD wnd_process_id; + if (!GetWindowThreadProcessId(hwnd, &wnd_process_id)) { + // Continue enumeration + return TRUE; + } + + // Check if this window is owned by a process we want to terminate + if (enum_ctx->process_ids.find(wnd_process_id) != enum_ctx->process_ids.end()) { + // Send an async WM_CLOSE message to this window + if (SendNotifyMessageW(hwnd, WM_CLOSE, 0, 0)) { + BOOST_LOG(debug) << "Sent WM_CLOSE to PID: "sv << wnd_process_id; + enum_ctx->requested_exit = true; + } + else { + auto error = GetLastError(); + BOOST_LOG(warning) << "Failed to send WM_CLOSE to PID ["sv << wnd_process_id << "]: " << error; + } + } + + // Continue enumeration + return TRUE; + } + + /** + * @brief Attempt to gracefully terminate a process group. + * @param native_handle The job object handle. + * @return true if termination was successfully requested. + */ + bool + request_process_group_exit(std::uintptr_t native_handle) { + auto job_handle = (HANDLE) native_handle; + + // Get list of all processes in our job object + bool success; + DWORD required_length = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST); + auto process_id_list = (PJOBOBJECT_BASIC_PROCESS_ID_LIST) calloc(1, required_length); + auto fg = util::fail_guard([&process_id_list]() { + free(process_id_list); + }); + while (!(success = QueryInformationJobObject(job_handle, JobObjectBasicProcessIdList, + process_id_list, required_length, &required_length)) && + GetLastError() == ERROR_MORE_DATA) { + free(process_id_list); + process_id_list = (PJOBOBJECT_BASIC_PROCESS_ID_LIST) calloc(1, required_length); + if (!process_id_list) { + return false; + } + } + + if (!success) { + auto err = GetLastError(); + BOOST_LOG(warning) << "Failed to enumerate processes in group: "sv << err; + return false; + } + else if (process_id_list->NumberOfProcessIdsInList == 0) { + // If all processes are already dead, treat it as a success + return true; + } + + enum_wnd_context_t enum_ctx = {}; + enum_ctx.requested_exit = false; + for (DWORD i = 0; i < process_id_list->NumberOfProcessIdsInList; i++) { + enum_ctx.process_ids.emplace(process_id_list->ProcessIdList[i]); + } + + // Enumerate all windows belonging to processes in the list + EnumWindows(prgrp_enum_windows, (LPARAM) &enum_ctx); + + // Return success if we told at least one window to close + return enum_ctx.requested_exit; + } + + /** + * @brief Checks if a process group still has running children. + * @param native_handle The job object handle. + * @return true if processes are still running. + */ + bool + process_group_running(std::uintptr_t native_handle) { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting_info; + + if (!QueryInformationJobObject((HANDLE) native_handle, JobObjectBasicAccountingInformation, &accounting_info, sizeof(accounting_info), nullptr)) { + auto err = GetLastError(); + BOOST_LOG(error) << "Failed to get job accounting info: "sv << err; + return false; + } + + return accounting_info.ActiveProcesses != 0; + } + SOCKADDR_IN to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) { SOCKADDR_IN saddr_v4 = {}; diff --git a/src/process.cpp b/src/process.cpp index 929e0114020..7042dd00c71 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -61,17 +61,46 @@ namespace proc { * @brief Terminates all child processes in a process group. * @param proc The child process itself. * @param group The group of all children in the process tree. + * @param exit_timeout The timeout to wait for the process group to gracefully exit. */ void - terminate_process_group(bp::child &proc, bp::group &group) { - if (group.valid()) { - BOOST_LOG(debug) << "Terminating child processes"sv; + terminate_process_group(bp::child &proc, bp::group &group, std::chrono::seconds exit_timeout) { + if (group.valid() && platf::process_group_running((std::uintptr_t) group.native_handle())) { + if (exit_timeout.count() > 0) { + // Request processes in the group to exit gracefully + if (platf::request_process_group_exit((std::uintptr_t) group.native_handle())) { + // If the request was successful, wait for a little while for them to exit. + BOOST_LOG(info) << "Successfully requested the app to exit. Waiting up to "sv << exit_timeout.count() << " seconds for it to close."sv; + + // group::wait_for() and similar functions are broken and deprecated, so we use a simple polling loop + while (platf::process_group_running((std::uintptr_t) group.native_handle()) && (--exit_timeout).count() >= 0) { + std::this_thread::sleep_for(1s); + } + + if (exit_timeout.count() < 0) { + BOOST_LOG(warning) << "App did not fully exit within the timeout. Terminating the app's remaining processes."sv; + } + else { + BOOST_LOG(info) << "All app processes have successfully exited."sv; + } + } + else { + BOOST_LOG(info) << "App did not respond to a graceful termination request. Forcefully terminating the app's processes."sv; + } + } + else { + BOOST_LOG(info) << "No graceful exit timeout was specified for this app. Forcefully terminating the app's processes."sv; + } + + // We always call terminate() even if we waited successfully for all processes above. + // This ensures the process group state is consistent with the OS in boost. group.terminate(); + group.detach(); } if (proc.valid()) { // avoid zombie process - proc.wait(); + proc.detach(); } } @@ -241,7 +270,15 @@ namespace proc { int proc_t::running() { - if (placebo || _process.running()) { + if (placebo) { + return _app_id; + } + else if (_app.wait_all && _process_group && platf::process_group_running((std::uintptr_t) _process_group.native_handle())) { + // The app is still running if any process in the group is still running + return _app_id; + } + else if (_process.running()) { + // The app is still running only if the initial process launched is still running return _app_id; } else if (_app.auto_detach && _process.native_exit_code() == 0 && @@ -265,7 +302,7 @@ namespace proc { proc_t::terminate() { std::error_code ec; placebo = false; - terminate_process_group(_process, _process_group); + terminate_process_group(_process, _process_group, _app.exit_timeout); _process = bp::child(); _process_group = bp::group(); @@ -566,6 +603,8 @@ namespace proc { auto working_dir = app_node.get_optional("working-dir"s); auto elevated = app_node.get_optional("elevated"s); auto auto_detach = app_node.get_optional("auto-detach"s); + auto wait_all = app_node.get_optional("wait-all"s); + auto exit_timeout = app_node.get_optional("exit-timeout"s); std::vector prep_cmds; if (!exclude_global_prep.value_or(false)) { @@ -625,6 +664,8 @@ namespace proc { ctx.elevated = elevated.value_or(false); ctx.auto_detach = auto_detach.value_or(true); + ctx.wait_all = wait_all.value_or(true); + ctx.exit_timeout = std::chrono::seconds { exit_timeout.value_or(5) }; auto possible_ids = calculate_app_id(name, ctx.image_path, i++); if (ids.count(std::get<0>(possible_ids)) == 0) { diff --git a/src/process.h b/src/process.h index c3b13d89534..433c7669902 100644 --- a/src/process.h +++ b/src/process.h @@ -38,10 +38,16 @@ namespace proc { std::vector prep_cmds; /** - * Some applications, such as Steam, - * either exit quickly, or keep running indefinitely. - * Steam.exe is one such application. - * That is why some applications need be run and forgotten about + * Some applications, such as Steam, either exit quickly, or keep running indefinitely. + * + * Apps that launch normal child processes and terminate will be handled by the process + * grouping logic (wait_all). However, apps that launch child processes indirectly or + * into another process group (such as UWP apps) can only be handled by the auto-detach + * heuristic which catches processes that exit 0 very quickly, but we won't have proper + * process tracking for those. + * + * For cases where users just want to kick off a background process and never manage the + * lifetime of that process, they can use detached commands for that. */ std::vector detached; @@ -53,6 +59,8 @@ namespace proc { std::string id; bool elevated; bool auto_detach; + bool wait_all; + std::chrono::seconds exit_timeout; }; class proc_t { diff --git a/src_assets/common/assets/web/apps.html b/src_assets/common/assets/web/apps.html index 21e3ced6954..36ddc5a9e6d 100644 --- a/src_assets/common/assets/web/apps.html +++ b/src_assets/common/assets/web/apps.html @@ -243,6 +243,28 @@

Applications

a launcher-type app is detected, it is treated as a detached app. + +
+ + +
+ This will continue streaming until all processes started by the app have terminated. + When unchecked, streaming will stop when the initial app process exits, even if other + app processes are still running. +
+
+ +
+ + +
+ Number of seconds to wait for all app processes to gracefully exit when requested to quit.
+ If unset, the default is to wait up to 5 seconds. If set to zero or a negative value, + the app will be immediately terminated. +
+
@@ -412,6 +434,8 @@

About Environment Variables

"exclude-global-prep-cmd": false, elevated: false, "auto-detach": true, + "wait-all": true, + "exit-timeout": 5, "prep-cmd": [], detached: [], "image-path": "" @@ -434,6 +458,12 @@

About Environment Variables

if (this.editForm["auto-detach"] === undefined) { this.editForm["auto-detach"] = true; } + if (this.editForm["wait-all"] === undefined) { + this.editForm["wait-all"] = true; + } + if (this.editForm["exit-timeout"] === undefined) { + this.editForm["exit-timeout"] = 5; + } this.showEditForm = true; }, showDeleteForm(id) {