Implement graceful termination and group-based app tracking
This commit is contained in:
@@ -550,6 +550,8 @@ Application List
|
|||||||
- ``name`` - The name of the application/game
|
- ``name`` - The name of the application/game
|
||||||
- ``output`` - The file where the output of the command is stored
|
- ``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
|
- ``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
|
- ``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
|
- If any of the prep-commands fail, starting the application is aborted
|
||||||
|
|||||||
@@ -618,6 +618,22 @@ namespace platf {
|
|||||||
void
|
void
|
||||||
open_url(const std::string &url);
|
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_t
|
||||||
input();
|
input();
|
||||||
void
|
void
|
||||||
|
|||||||
@@ -249,6 +249,33 @@ namespace platf {
|
|||||||
lifetime::exit_sunshine(0, true);
|
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
|
struct sockaddr_in
|
||||||
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
||||||
struct sockaddr_in saddr_v4 = {};
|
struct sockaddr_in saddr_v4 = {};
|
||||||
|
|||||||
@@ -252,6 +252,33 @@ namespace platf {
|
|||||||
lifetime::exit_sunshine(0, true);
|
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
|
struct sockaddr_in
|
||||||
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
||||||
struct sockaddr_in saddr_v4 = {};
|
struct sockaddr_in saddr_v4 = {};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
|
#include <set>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
#include <boost/algorithm/string.hpp>
|
#include <boost/algorithm/string.hpp>
|
||||||
@@ -904,6 +905,106 @@ namespace platf {
|
|||||||
lifetime::exit_sunshine(0, true);
|
lifetime::exit_sunshine(0, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct enum_wnd_context_t {
|
||||||
|
std::set<DWORD> 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
|
SOCKADDR_IN
|
||||||
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
|
||||||
SOCKADDR_IN saddr_v4 = {};
|
SOCKADDR_IN saddr_v4 = {};
|
||||||
|
|||||||
@@ -61,17 +61,46 @@ namespace proc {
|
|||||||
* @brief Terminates all child processes in a process group.
|
* @brief Terminates all child processes in a process group.
|
||||||
* @param proc The child process itself.
|
* @param proc The child process itself.
|
||||||
* @param group The group of all children in the process tree.
|
* @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
|
void
|
||||||
terminate_process_group(bp::child &proc, bp::group &group) {
|
terminate_process_group(bp::child &proc, bp::group &group, std::chrono::seconds exit_timeout) {
|
||||||
if (group.valid()) {
|
if (group.valid() && platf::process_group_running((std::uintptr_t) group.native_handle())) {
|
||||||
BOOST_LOG(debug) << "Terminating child processes"sv;
|
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.terminate();
|
||||||
|
group.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proc.valid()) {
|
if (proc.valid()) {
|
||||||
// avoid zombie process
|
// avoid zombie process
|
||||||
proc.wait();
|
proc.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +270,15 @@ namespace proc {
|
|||||||
|
|
||||||
int
|
int
|
||||||
proc_t::running() {
|
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;
|
return _app_id;
|
||||||
}
|
}
|
||||||
else if (_app.auto_detach && _process.native_exit_code() == 0 &&
|
else if (_app.auto_detach && _process.native_exit_code() == 0 &&
|
||||||
@@ -265,7 +302,7 @@ namespace proc {
|
|||||||
proc_t::terminate() {
|
proc_t::terminate() {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
placebo = false;
|
placebo = false;
|
||||||
terminate_process_group(_process, _process_group);
|
terminate_process_group(_process, _process_group, _app.exit_timeout);
|
||||||
_process = bp::child();
|
_process = bp::child();
|
||||||
_process_group = bp::group();
|
_process_group = bp::group();
|
||||||
|
|
||||||
@@ -566,6 +603,8 @@ namespace proc {
|
|||||||
auto working_dir = app_node.get_optional<std::string>("working-dir"s);
|
auto working_dir = app_node.get_optional<std::string>("working-dir"s);
|
||||||
auto elevated = app_node.get_optional<bool>("elevated"s);
|
auto elevated = app_node.get_optional<bool>("elevated"s);
|
||||||
auto auto_detach = app_node.get_optional<bool>("auto-detach"s);
|
auto auto_detach = app_node.get_optional<bool>("auto-detach"s);
|
||||||
|
auto wait_all = app_node.get_optional<bool>("wait-all"s);
|
||||||
|
auto exit_timeout = app_node.get_optional<int>("exit-timeout"s);
|
||||||
|
|
||||||
std::vector<proc::cmd_t> prep_cmds;
|
std::vector<proc::cmd_t> prep_cmds;
|
||||||
if (!exclude_global_prep.value_or(false)) {
|
if (!exclude_global_prep.value_or(false)) {
|
||||||
@@ -625,6 +664,8 @@ namespace proc {
|
|||||||
|
|
||||||
ctx.elevated = elevated.value_or(false);
|
ctx.elevated = elevated.value_or(false);
|
||||||
ctx.auto_detach = auto_detach.value_or(true);
|
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++);
|
auto possible_ids = calculate_app_id(name, ctx.image_path, i++);
|
||||||
if (ids.count(std::get<0>(possible_ids)) == 0) {
|
if (ids.count(std::get<0>(possible_ids)) == 0) {
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ namespace proc {
|
|||||||
std::vector<cmd_t> prep_cmds;
|
std::vector<cmd_t> prep_cmds;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some applications, such as Steam,
|
* Some applications, such as Steam, either exit quickly, or keep running indefinitely.
|
||||||
* either exit quickly, or keep running indefinitely.
|
*
|
||||||
* Steam.exe is one such application.
|
* Apps that launch normal child processes and terminate will be handled by the process
|
||||||
* That is why some applications need be run and forgotten about
|
* 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<std::string> detached;
|
std::vector<std::string> detached;
|
||||||
|
|
||||||
@@ -53,6 +59,8 @@ namespace proc {
|
|||||||
std::string id;
|
std::string id;
|
||||||
bool elevated;
|
bool elevated;
|
||||||
bool auto_detach;
|
bool auto_detach;
|
||||||
|
bool wait_all;
|
||||||
|
std::chrono::seconds exit_timeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
class proc_t {
|
class proc_t {
|
||||||
|
|||||||
@@ -243,6 +243,28 @@
|
|||||||
a launcher-type app is detected, it is treated as a detached app.
|
a launcher-type app is detected, it is treated as a detached app.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- wait for all processes -->
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<label for="waitAll" class="form-check-label">Continue streaming until all app processes exit</label>
|
||||||
|
<input type="checkbox" class="form-check-input" id="waitAll" v-model="editForm['wait-all']"
|
||||||
|
true-value="true" false-value="false" />
|
||||||
|
<div class="form-text">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- exit timeout -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="exitTimeout" class="form-label">Exit Timeout</label>
|
||||||
|
<input type="text" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
|
||||||
|
v-model="editForm['exit-timeout']" />
|
||||||
|
<div id="exitTimeoutHelp" class="form-text">
|
||||||
|
Number of seconds to wait for all app processes to gracefully exit when requested to quit.<br>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="appImagePath" class="form-label">Image</label>
|
<label for="appImagePath" class="form-label">Image</label>
|
||||||
<div class="input-group dropup">
|
<div class="input-group dropup">
|
||||||
@@ -412,6 +434,8 @@
|
|||||||
"exclude-global-prep-cmd": false,
|
"exclude-global-prep-cmd": false,
|
||||||
elevated: false,
|
elevated: false,
|
||||||
"auto-detach": true,
|
"auto-detach": true,
|
||||||
|
"wait-all": true,
|
||||||
|
"exit-timeout": 5,
|
||||||
"prep-cmd": [],
|
"prep-cmd": [],
|
||||||
detached: [],
|
detached: [],
|
||||||
"image-path": ""
|
"image-path": ""
|
||||||
@@ -434,6 +458,12 @@
|
|||||||
if (this.editForm["auto-detach"] === undefined) {
|
if (this.editForm["auto-detach"] === undefined) {
|
||||||
this.editForm["auto-detach"] = true;
|
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;
|
this.showEditForm = true;
|
||||||
},
|
},
|
||||||
showDeleteForm(id) {
|
showDeleteForm(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user