#include "steam_process_tracker.h" #include #include #include #include #include #include #include #include #include #include #include #if defined(__linux__) #include #include #include #include #include #elif defined(_WIN32) #include #include #endif namespace platf::steam::lifecycle { namespace { std::string lower(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return value; } std::filesystem::path normalize(const std::filesystem::path &value) { if (value.empty()) { return {}; } std::error_code error; auto result = std::filesystem::weakly_canonical(value, error); if (error) { error.clear(); result = std::filesystem::absolute(value, error); if (error) { result = value; } } result = result.lexically_normal(); #if defined(_WIN32) // Windows paths are case-insensitive. The returned path is only used for // comparison; preserve the original process paths in the public result. auto text = lower(result.generic_string()); return std::filesystem::path(text); #else return result; #endif } std::string basename(const std::filesystem::path &value) { return lower(value.filename().string()); } bool looks_like_steam(const std::string &value) { const auto name = lower(std::filesystem::path(value).filename().string()); return name == "steam" || name == "steam.exe" || name == "steamwebhelper" || name == "steamwebhelper.exe" || name == "steamservice" || name == "steamservice.exe" || name == "steam-runtime" || name == "steam-runtime-sniper" || name == "steam-launch-wrapper"; } bool looks_like_compatibility_runtime(const process_info &process) { const auto exe = basename(process.executable); if (exe.find("proton") != std::string::npos || exe.find("wine") != std::string::npos || exe == "pressure-vessel" || exe == "pressure-vessel-wrap" || exe == "gamescope") { return true; } for (const auto &argument : process.command_line) { const auto value = lower(std::filesystem::path(argument).filename().string()); if (value.find("proton") != std::string::npos || value.find("wine") != std::string::npos || value == "pressure-vessel" || value == "pressure-vessel-wrap") { return true; } } return false; } bool command_line_contains_install(const process_info &process, const std::filesystem::path &install_dir) { for (const auto &argument : process.command_line) { // A Wine/Proton command line normally contains the Windows executable // as one argument. Test both the argument itself and the path before // the first quote/space so wrapper flags do not accidentally match. if (path_is_within(std::filesystem::path(argument), install_dir)) { return true; } const auto slash_argument = std::filesystem::path(argument).generic_string(); if (slash_argument.find('/') != std::string::npos && path_is_within(std::filesystem::path(slash_argument), install_dir)) { return true; } } return false; } bool direct_install_evidence(const process_info &process, const std::filesystem::path &install_dir) { return path_is_within(process.executable, install_dir) || path_is_within(process.cwd, install_dir) || command_line_contains_install(process, install_dir); } class procfs_provider final : public process_snapshot_provider { public: std::optional snapshot() override { return snapshot_processes(); } }; #if defined(__linux__) std::optional parse_pid(const char *name) { if (!name || !*name) { return std::nullopt; } process_id_t value = 0; for (const auto *cursor = name; *cursor; ++cursor) { if (*cursor < '0' || *cursor > '9') { return std::nullopt; } value = value * 10 + static_cast(*cursor - '0'); } return value == 0 ? std::nullopt : std::optional(value); } struct proc_stat_identity { process_id_t parent_pid = 0; std::uint64_t start_time_ticks = 0; }; std::optional read_proc_stat(process_id_t pid) { std::ifstream file("/proc/" + std::to_string(pid) + "/stat"); std::string line((std::istreambuf_iterator(file)), std::istreambuf_iterator()); if (line.empty()) { return std::nullopt; } // comm may contain spaces and ')' characters. The final ')' before the // state field is the reliable delimiter for /proc//stat. const auto close = line.rfind(')'); if (close == std::string::npos || close + 2 >= line.size()) { return std::nullopt; } std::string fields = line.substr(close + 2); // state, ppid, ..., starttime std::istringstream stream(fields); char state = 0; if (!(stream >> state)) { return std::nullopt; } proc_stat_identity identity; if (!(stream >> identity.parent_pid)) { return std::nullopt; } // Fields after the command name start at state=3. We consumed state and // ppid, so field 22 (starttime) follows fields 5 through 21. std::string ignored; for (int field = 5; field <= 21; ++field) { if (!(stream >> ignored)) { return std::nullopt; } } if (!(stream >> identity.start_time_ticks)) { return std::nullopt; } return identity; } std::filesystem::path read_link(const std::string &path) { std::error_code error; return std::filesystem::read_symlink(path, error); } std::vector read_command_line(process_id_t pid) { std::ifstream file("/proc/" + std::to_string(pid) + "/cmdline", std::ios::binary); std::string bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); std::vector result; std::size_t start = 0; for (std::size_t position = 0; position <= bytes.size(); ++position) { if (position == bytes.size() || bytes[position] == '\0') { if (position > start) { result.emplace_back(bytes.substr(start, position - start)); } start = position + 1; } } return result; } #elif defined(_WIN32) std::uint64_t file_time_ticks(const FILETIME &value) { ULARGE_INTEGER ticks {}; ticks.LowPart = value.dwLowDateTime; ticks.HighPart = value.dwHighDateTime; return ticks.QuadPart; } std::optional read_windows_process(const PROCESSENTRY32W &entry) { process_info info; info.pid = entry.th32ProcessID; info.parent_pid = entry.th32ParentProcessID; // Keep every enumerated PID in the baseline even when Windows denies a // query handle. This prevents a temporarily inaccessible existing process // from being mistaken for a process created by the game launch. info.executable = entry.szExeFile; HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); if (!process) { return info; } std::wstring image(32768, L'\0'); DWORD image_size = static_cast(image.size()); if (QueryFullProcessImageNameW(process, 0, image.data(), &image_size)) { image.resize(image_size); info.executable = image; } FILETIME creation {}, exit {}, kernel {}, user {}; if (GetProcessTimes(process, &creation, &exit, &kernel, &user)) { info.start_time_ticks = file_time_ticks(creation); } CloseHandle(process); return info; } bool process_has_window(process_id_t pid) { struct state_t { DWORD pid; bool found; } state {static_cast(pid), false}; EnumWindows([](HWND window, LPARAM parameter) -> BOOL { auto &state = *reinterpret_cast(parameter); DWORD owner = 0; GetWindowThreadProcessId(window, &owner); if (owner == state.pid) { state.found = true; PostMessageW(window, WM_CLOSE, 0, 0); } return TRUE; }, reinterpret_cast(&state)); return state.found; } std::optional windows_process_start_time(process_id_t pid) { HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast(pid)); if (!process) { return std::nullopt; } FILETIME creation {}, exit {}, kernel {}, user {}; const bool success = GetProcessTimes(process, &creation, &exit, &kernel, &user); CloseHandle(process); if (!success) { return std::nullopt; } return file_time_ticks(creation); } #endif class native_controller final : public process_controller { public: bool signal(process_id_t pid, signal_kind kind) override { #if defined(__linux__) const int value = kind == signal_kind::terminate ? SIGTERM : SIGKILL; return ::kill(static_cast(pid), value) == 0; #elif defined(_WIN32) if (kind == signal_kind::terminate && process_has_window(pid)) { return true; } HANDLE process = OpenProcess(PROCESS_TERMINATE, FALSE, static_cast(pid)); if (!process) { return false; } const bool stopped = TerminateProcess(process, 1) != FALSE; CloseHandle(process); return stopped; #else (void) pid; (void) kind; return false; #endif } bool alive(process_id_t pid) override { #if defined(__linux__) if (::kill(static_cast(pid), 0) == 0) { return true; } return errno == EPERM; #elif defined(_WIN32) HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, static_cast(pid)); if (!process) { return false; } const bool running = WaitForSingleObject(process, 0) == WAIT_TIMEOUT; CloseHandle(process); return running; #else (void) pid; return false; #endif } bool identity_matches(process_id_t pid, const process_info &expected) override { #if defined(__linux__) if (expected.start_time_ticks == 0) { return true; } const auto current = read_proc_stat(pid); return current && current->start_time_ticks == expected.start_time_ticks; #elif defined(_WIN32) // Creation time is stable for the lifetime of a Windows process and // protects the delayed force-stop path from signalling a reused PID. if (expected.start_time_ticks == 0) { return false; } const auto current = windows_process_start_time(pid); return current && *current == expected.start_time_ticks; #else (void) pid; (void) expected; return true; #endif } void sleep_for(std::chrono::milliseconds duration) override { std::this_thread::sleep_for(duration); } }; } // namespace bool path_is_within(const std::filesystem::path &path, const std::filesystem::path &directory) { if (path.empty() || directory.empty()) { return false; } const auto candidate = normalize(path); const auto root = normalize(directory); if (candidate.empty() || root.empty()) { return false; } auto candidate_it = candidate.begin(); auto root_it = root.begin(); for (; root_it != root.end(); ++root_it, ++candidate_it) { if (candidate_it == candidate.end()) { return false; } #if defined(_WIN32) if (lower(candidate_it->generic_string()) != lower(root_it->generic_string())) { #else if (candidate_it->generic_string() != root_it->generic_string()) { #endif return false; } } return true; } bool is_protected_steam_process(const process_info &process, const association_options &options) { if (std::find(options.protected_steam_roots.begin(), options.protected_steam_roots.end(), process.pid) != options.protected_steam_roots.end()) { return true; } if (looks_like_steam(process.executable.string())) { return true; } if (!process.command_line.empty() && looks_like_steam(process.command_line.front())) { return true; } return false; } association_result associate(const process_snapshot &baseline, const process_snapshot &after, const std::filesystem::path &install_dir, const association_options &options) { association_result result; result.tree.install_dir = normalize(install_dir); if (result.tree.install_dir.empty()) { result.reason = "process snapshot or install directory is unavailable"; return result; } std::map fresh; for (const auto &[pid, process] : after.processes) { // PID reuse is intentionally conservative: a PID present in the baseline // is never attributed to this launch. if (!baseline.processes.contains(pid)) { fresh.emplace(pid, process); } } if (fresh.empty()) { result.outcome = association_outcome::baseline_only; result.reason = "no process appeared after the launch baseline"; return result; } std::set selected; for (const auto &[pid, process] : fresh) { if (!is_protected_steam_process(process, options) && (direct_install_evidence(process, result.tree.install_dir) || (looks_like_compatibility_runtime(process) && command_line_contains_install(process, result.tree.install_dir)))) { selected.insert(pid); } } // Once a game/compatibility-root is found, retain all of its new children. // This handles launchers, Wine wineserver processes, and games that fork a // renderer outside the install directory. Steam processes themselves are // excluded but do not stop expansion: a game may briefly launch a Steam // helper while it is starting. bool expanded = true; while (expanded) { expanded = false; for (const auto &[pid, process] : fresh) { if (selected.contains(pid) || is_protected_steam_process(process, options)) { continue; } if (selected.contains(process.parent_pid)) { selected.insert(pid); expanded = true; } } } if (selected.empty()) { result.reason = "new processes had no install-directory or compatibility-runtime evidence"; result.outcome = association_outcome::untrackable; return result; } result.outcome = association_outcome::associated; for (const auto pid : selected) { result.tree.processes.emplace(pid, tracked_process {fresh.at(pid), false}); } // The lowest selected process whose parent is not selected is the launch // root. Determinism matters when multiple game binaries start together. for (const auto &[pid, process] : result.tree.processes) { if (!result.tree.processes.contains(process.info.parent_pid)) { result.tree.root_pid = pid; break; } } result.reason = "associated new game process tree"; return result; } std::optional snapshot_processes() { process_snapshot result; #if defined(__linux__) DIR *directory = ::opendir("/proc"); if (!directory) { return std::nullopt; } const uid_t self = ::getuid(); while (const auto *entry = ::readdir(directory)) { const auto pid = parse_pid(entry->d_name); if (!pid) { continue; } struct stat pid_attributes {}; const auto pid_directory = "/proc/" + std::to_string(*pid); if (::lstat(pid_directory.c_str(), &pid_attributes) != 0 || !S_ISDIR(pid_attributes.st_mode) || pid_attributes.st_uid != self) { // A PID that disappeared during enumeration is gone, not missing. Other // users' processes cannot be games this helper is allowed to stop. continue; } const auto parent = read_proc_stat(*pid); if (!parent) { result.complete = false; continue; } process_info info; info.pid = *pid; info.parent_pid = parent->parent_pid; info.start_time_ticks = parent->start_time_ticks; info.executable = read_link("/proc/" + std::to_string(*pid) + "/exe"); info.cwd = read_link("/proc/" + std::to_string(*pid) + "/cwd"); info.command_line = read_command_line(*pid); result.processes.emplace(*pid, std::move(info)); } ::closedir(directory); #elif defined(_WIN32) HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { return std::nullopt; } PROCESSENTRY32W entry {}; entry.dwSize = sizeof(entry); if (!Process32FirstW(snapshot, &entry)) { CloseHandle(snapshot); return std::nullopt; } do { if (entry.th32ProcessID == 0) { continue; } auto info = read_windows_process(entry); if (info) { result.processes.emplace(info->pid, std::move(*info)); } } while (Process32NextW(snapshot, &entry)); CloseHandle(snapshot); #else // Keep the provider usable on unsupported platforms. No fabricated process // is ever returned. #endif return result; } tracker::tracker(std::shared_ptr provider): provider_(std::move(provider)) { if (!provider_) { provider_ = std::make_shared(); } } bool tracker::begin(const std::filesystem::path &install_dir, const association_options &options) { clear(); const auto baseline = provider_->snapshot(); if (!baseline) { return false; } install_dir_ = install_dir; options_ = options; baseline_ = *baseline; begun_ = true; return true; } association_result tracker::finish() { if (!begun_) { association_result result; result.reason = "tracker was not started"; return result; } const auto after = provider_->snapshot(); if (!after) { association_result result; result.reason = "post-launch process snapshot is unavailable"; return result; } return finish(*after); } association_result tracker::finish(const process_snapshot &after) { if (!begun_) { association_result result; result.reason = "tracker was not started"; return result; } auto result = associate(baseline_, after, install_dir_, options_); if (result.associated()) { if (!tree_.empty()) { // Preserve already-associated processes and add descendants discovered // on later polls. This matters when the original game binary is a // short-lived launcher and its real renderer appears afterwards. for (const auto &[pid, process] : result.tree.processes) { const auto existing = tree_.processes.find(pid); if (existing == tree_.processes.end() || existing->second.info.start_time_ticks == 0 || process.info.start_time_ticks == 0 || existing->second.info.start_time_ticks == process.info.start_time_ticks) { tree_.processes.emplace(pid, process); } else { // PID reuse is a new identity. Replace the stale record so future // stop operations validate the new process identity. existing->second = process; } } for (const auto &[pid, process] : after.processes) { if (tree_.processes.contains(pid) || is_protected_steam_process(process, options_)) { continue; } if (tree_.processes.contains(process.parent_pid)) { tree_.processes.emplace(pid, tracked_process {process, false}); } } result.tree = tree_; } else { tree_ = result.tree; } } else if (!tree_.empty()) { const bool retained = std::any_of(tree_.processes.begin(), tree_.processes.end(), [&](const auto &entry) { const auto found = after.processes.find(entry.first); if (found == after.processes.end()) { return false; } return entry.second.info.start_time_ticks == 0 || found->second.start_time_ticks == 0 || entry.second.info.start_time_ticks == found->second.start_time_ticks; }); if (retained || !after.complete) { result.outcome = association_outcome::associated; result.tree = tree_; result.reason = "retained previously associated game process tree"; } else { tree_ = {}; } } return result; } bool tracker::has_live_processes(const process_snapshot ¤t) const { if (tree_.empty()) { return false; } for (const auto &[pid, process] : tree_.processes) { const auto found = current.processes.find(pid); if (found == current.processes.end()) { continue; } if (process.info.start_time_ticks == 0 || found->second.start_time_ticks == 0 || process.info.start_time_ticks == found->second.start_time_ticks) { return true; } } return !current.complete; } void tracker::clear() { baseline_ = {}; install_dir_.clear(); options_ = {}; tree_ = {}; begun_ = false; } stop_result stop_tree(const tracked_tree &tree, process_controller &controller, const stop_options &options) { stop_result result; if (tree.empty()) { return result; } std::vector order; order.reserve(tree.processes.size()); for (const auto &[pid, process] : tree.processes) { if (!process.protected_process) { order.push_back(pid); } else { ++result.skipped; } } // A stable descendant-first order prevents a parent from reaping or // detaching a child before the child receives the graceful signal. auto depth = [&](process_id_t pid) { std::size_t value = 0; std::set visited; auto current = pid; while (tree.processes.contains(current) && visited.insert(current).second) { const auto parent = tree.processes.at(current).info.parent_pid; if (!tree.processes.contains(parent)) { break; } current = parent; ++value; } return value; }; std::stable_sort(order.begin(), order.end(), [&](auto left, auto right) { const auto left_depth = depth(left); const auto right_depth = depth(right); return left_depth != right_depth ? left_depth > right_depth : left > right; }); std::vector signalled; for (const auto pid : order) { const auto &tracked = tree.processes.at(pid); if (controller.identity_matches(pid, tracked.info) && controller.alive(pid) && controller.signal(pid, signal_kind::terminate)) { signalled.push_back(pid); ++result.terminate_sent; } else { ++result.skipped; } } auto remaining = [&]() { return std::any_of(signalled.begin(), signalled.end(), [&](auto pid) { const auto &tracked = tree.processes.at(pid); return controller.identity_matches(pid, tracked.info) && controller.alive(pid); }); }; auto elapsed = std::chrono::milliseconds(0); const auto poll_period = options.poll_period > std::chrono::milliseconds(0) ? options.poll_period : std::chrono::milliseconds(1); while (remaining() && elapsed < options.grace_period) { controller.sleep_for(poll_period); elapsed += poll_period; } for (const auto pid : signalled) { const auto &tracked = tree.processes.at(pid); if (controller.identity_matches(pid, tracked.info) && controller.alive(pid) && controller.signal(pid, signal_kind::kill)) { ++result.kill_sent; } } result.complete = !remaining(); return result; } std::shared_ptr native_process_controller() { return std::make_shared(); } } // namespace platf::steam::lifecycle