Skip to content

Commit

Permalink
Implement graceful termination and group-based app tracking
Browse files Browse the repository at this point in the history
  • Loading branch information
cgutman committed Jan 9, 2024
1 parent d05a671 commit 593e170
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 10 deletions.
2 changes: 2 additions & 0 deletions docs/source/about/setup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/platform/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/platform/linux/misc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
27 changes: 27 additions & 0 deletions src/platform/macos/misc.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
101 changes: 101 additions & 0 deletions src/platform/windows/misc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <csignal>
#include <filesystem>
#include <iomanip>
#include <set>
#include <sstream>

#include <boost/algorithm/string.hpp>
Expand Down Expand Up @@ -904,6 +905,106 @@ namespace platf {
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
to_sockaddr(boost::asio::ip::address_v4 address, uint16_t port) {
SOCKADDR_IN saddr_v4 = {};
Expand Down
53 changes: 47 additions & 6 deletions src/process.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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 &&
Expand All @@ -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();

Expand Down Expand Up @@ -566,6 +603,8 @@ namespace proc {
auto working_dir = app_node.get_optional<std::string>("working-dir"s);
auto elevated = app_node.get_optional<bool>("elevated"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;
if (!exclude_global_prep.value_or(false)) {
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 12 additions & 4 deletions src/process.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ namespace proc {
std::vector<cmd_t> 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<std::string> detached;

Expand All @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions src_assets/common/assets/web/apps.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,28 @@ <h1>Applications</h1>
a launcher-type app is detected, it is treated as a detached app.
</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">
<label for="appImagePath" class="form-label">Image</label>
<div class="input-group dropup">
Expand Down Expand Up @@ -412,6 +434,8 @@ <h4>About Environment Variables</h4>
"exclude-global-prep-cmd": false,
elevated: false,
"auto-detach": true,
"wait-all": true,
"exit-timeout": 5,
"prep-cmd": [],
detached: [],
"image-path": ""
Expand All @@ -434,6 +458,12 @@ <h4>About Environment Variables</h4>
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) {
Expand Down

0 comments on commit 593e170

Please sign in to comment.