Detect terminated / hung target and reflect it in the UI
The Injection panel kept showing "Attached" after the game exited. Now it tracks target liveness each frame (InjectionPanel::tick from the main loop, independent of panel visibility): - a SYNCHRONIZE|QUERY process handle taken at inject time -> WaitForSingleObject detects the process exiting (Terminated); - the hook heartbeat stalling for ~2 s while the process still exists flags a distinct Hung state (games here can freeze without exiting). The panel shows a clear colored banner per state and disables the subsystem hook/unhook controls and the synthetic-input toggle when the target isn't alive. game_hwnd() returns null once Terminated, so the Video and Audio panels drop to idle instead of chasing a dead window. Verified: x64 build + ctest 7/7 green; host launches and renders the panels without regression (screenshot smoke test). The interactive terminated/hung visual against a real game is best confirmed in a live session (ImGui injection can't be GUI-scripted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
12
README.md
12
README.md
@@ -75,18 +75,6 @@ is removed from this list once done — so the top item is always next. The
|
|||||||
self-verifiable tooling / UI / input items come first; the game-pipeline items that
|
self-verifiable tooling / UI / input items come first; the game-pipeline items that
|
||||||
need a real game (and Remote Play) to fully validate come last.
|
need a real game (and Remote Play) to fully validate come last.
|
||||||
|
|
||||||
- **Detect a terminated target and reflect it in the UI.** The Injection panel keeps
|
|
||||||
showing "Attached" after the game exits. Add a **Terminated** state: the host
|
|
||||||
already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top
|
|
||||||
of that, hold the `OpenProcess` handle from injection (or re-open with
|
|
||||||
`PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` /
|
|
||||||
`WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to
|
|
||||||
Terminated, gray out / disable the per-subsystem controls and mirror toggles, and
|
|
||||||
show a clear banner; the Video and Audio panels should drop to idle (their hook
|
|
||||||
channels are stale) rather than freezing on the last live frame/state. Note a live
|
|
||||||
process isn't proof it's running — also flag a **stalled heartbeat** (no advance
|
|
||||||
for ~2 s while the process still exists) as a distinct "hung / not responding"
|
|
||||||
state, since games here can freeze without exiting.
|
|
||||||
- **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid,
|
- **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid,
|
||||||
but the UI still holds the stale one. In the Terminated state, remember the
|
but the UI still holds the stale one. In the Terminated state, remember the
|
||||||
target's image name (the panel already keeps the selected exe name) and offer a
|
target's image name (the panel already keeps the selected exe name) and offer a
|
||||||
|
|||||||
@@ -69,6 +69,64 @@ InjectionPanel::InjectionPanel()
|
|||||||
refresh_processes();
|
refresh_processes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InjectionPanel::~InjectionPanel()
|
||||||
|
{
|
||||||
|
close_target_handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InjectionPanel::close_target_handle()
|
||||||
|
{
|
||||||
|
if (target_process_ != nullptr)
|
||||||
|
{
|
||||||
|
CloseHandle(target_process_);
|
||||||
|
target_process_ = nullptr;
|
||||||
|
}
|
||||||
|
target_state_ = TargetState::NotInjected;
|
||||||
|
dll_alive_ = false;
|
||||||
|
last_heartbeat_ = 0;
|
||||||
|
last_heartbeat_time_ = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InjectionPanel::tick()
|
||||||
|
{
|
||||||
|
update_liveness();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InjectionPanel::update_liveness()
|
||||||
|
{
|
||||||
|
if (!injected_)
|
||||||
|
{
|
||||||
|
target_state_ = TargetState::NotInjected;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process gone? The handle was opened with SYNCHRONIZE at inject time, so a
|
||||||
|
// signaled wait means it exited. This is authoritative even if the heartbeat
|
||||||
|
// happened to look alive a moment ago.
|
||||||
|
if (target_process_ != nullptr && WaitForSingleObject(target_process_, 0) == WAIT_OBJECT_0)
|
||||||
|
{
|
||||||
|
target_state_ = TargetState::Terminated;
|
||||||
|
dll_alive_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still running: alive vs hung from the hook heartbeat (advances ~4x/s). A live
|
||||||
|
// process whose heartbeat stalled for ~2 s is frozen, not gone -- a distinct state.
|
||||||
|
const std::uint32_t hb = server_.hook_status().heartbeat;
|
||||||
|
const double now = ImGui::GetTime();
|
||||||
|
if (hb != last_heartbeat_)
|
||||||
|
{
|
||||||
|
last_heartbeat_ = hb;
|
||||||
|
last_heartbeat_time_ = now;
|
||||||
|
dll_alive_ = true;
|
||||||
|
}
|
||||||
|
else if (now - last_heartbeat_time_ > 2.0)
|
||||||
|
{
|
||||||
|
dll_alive_ = false;
|
||||||
|
}
|
||||||
|
target_state_ = dll_alive_ ? TargetState::Alive : TargetState::Hung;
|
||||||
|
}
|
||||||
|
|
||||||
void InjectionPanel::refresh_processes()
|
void InjectionPanel::refresh_processes()
|
||||||
{
|
{
|
||||||
processes_ = list_processes();
|
processes_ = list_processes();
|
||||||
@@ -102,6 +160,14 @@ void InjectionPanel::inject_selected()
|
|||||||
if (result.status == InjectStatus::Ok)
|
if (result.status == InjectStatus::Ok)
|
||||||
{
|
{
|
||||||
injected_ = true;
|
injected_ = true;
|
||||||
|
// Track liveness: a SYNCHRONIZE|QUERY handle lets us notice the game exiting,
|
||||||
|
// and seeding the heartbeat clock avoids a spurious "hung" before the first beat.
|
||||||
|
close_target_handle(); // drop any handle from a previous target
|
||||||
|
target_process_ = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, selected_pid_);
|
||||||
|
target_state_ = TargetState::Alive;
|
||||||
|
last_heartbeat_ = 0;
|
||||||
|
last_heartbeat_time_ = ImGui::GetTime();
|
||||||
|
dll_alive_ = true;
|
||||||
status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ").";
|
status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ").";
|
||||||
status_color_ = kGreen;
|
status_color_ = kGreen;
|
||||||
}
|
}
|
||||||
@@ -273,19 +339,6 @@ void InjectionPanel::draw_hook_status(bool debug_details)
|
|||||||
|
|
||||||
const HookStatusView status = server_.hook_status();
|
const HookStatusView status = server_.hook_status();
|
||||||
|
|
||||||
// DLL liveness from the heartbeat (advances ~4x/s while the worker runs).
|
|
||||||
const double now = ImGui::GetTime();
|
|
||||||
if (status.heartbeat != last_heartbeat_)
|
|
||||||
{
|
|
||||||
last_heartbeat_ = status.heartbeat;
|
|
||||||
last_heartbeat_time_ = now;
|
|
||||||
dll_alive_ = true;
|
|
||||||
}
|
|
||||||
else if (now - last_heartbeat_time_ > 1.5)
|
|
||||||
{
|
|
||||||
dll_alive_ = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SeparatorText("Hook status");
|
ImGui::SeparatorText("Hook status");
|
||||||
if (!injected_)
|
if (!injected_)
|
||||||
{
|
{
|
||||||
@@ -293,17 +346,26 @@ void InjectionPanel::draw_hook_status(bool debug_details)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dll_alive_)
|
switch (target_state_)
|
||||||
{
|
{
|
||||||
|
case TargetState::Alive:
|
||||||
ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(),
|
ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(),
|
||||||
status.heartbeat);
|
status.heartbeat);
|
||||||
}
|
break;
|
||||||
else
|
case TargetState::Hung:
|
||||||
{
|
ImGui::TextColored(kRed, "Target not responding -- heartbeat stalled (frozen?).");
|
||||||
ImGui::TextColored(kRed, "Hook DLL not responding (no heartbeat).");
|
break;
|
||||||
|
case TargetState::Terminated:
|
||||||
|
ImGui::TextColored(kRed, "Target process has exited.");
|
||||||
|
break;
|
||||||
|
case TargetState::NotInjected:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The game is gone or frozen -> its hooks can't act on toggles, so lock them.
|
||||||
|
ImGui::BeginDisabled(target_state_ != TargetState::Alive);
|
||||||
draw_subsystem_controls(status);
|
draw_subsystem_controls(status);
|
||||||
|
ImGui::EndDisabled();
|
||||||
ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
|
ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
|
||||||
|
|
||||||
draw_hook_list(status);
|
draw_hook_list(status);
|
||||||
@@ -347,12 +409,24 @@ void InjectionPanel::draw(bool debug_details)
|
|||||||
ImGui::Begin("Injection");
|
ImGui::Begin("Injection");
|
||||||
|
|
||||||
if (server_.running())
|
if (server_.running())
|
||||||
|
{
|
||||||
|
if (target_state_ == TargetState::Terminated)
|
||||||
|
{
|
||||||
|
ImGui::TextColored(kRed, "Target (pid %lu) has terminated.", server_.target_pid());
|
||||||
|
}
|
||||||
|
else if (target_state_ == TargetState::Hung)
|
||||||
|
{
|
||||||
|
ImGui::TextColored(kRed, "Target (pid %lu) is not responding.", server_.target_pid());
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid());
|
ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid());
|
||||||
|
}
|
||||||
if (ImGui::Button("Disconnect"))
|
if (ImGui::Button("Disconnect"))
|
||||||
{
|
{
|
||||||
server_.stop();
|
server_.stop();
|
||||||
injected_ = false;
|
injected_ = false;
|
||||||
|
close_target_handle();
|
||||||
status_ = "Stopped.";
|
status_ = "Stopped.";
|
||||||
status_color_ = kGrey;
|
status_color_ = kGrey;
|
||||||
}
|
}
|
||||||
@@ -401,8 +475,9 @@ void InjectionPanel::draw(bool debug_details)
|
|||||||
ImGui::TextColored(status_color_, "%s", status_.c_str());
|
ImGui::TextColored(status_color_, "%s", status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthetic input only reaches the game if the XInput hook is installed.
|
// Synthetic input only reaches the game if the XInput hook is installed and the
|
||||||
ImGui::BeginDisabled(injected_ && !want_input_);
|
// target is actually alive to receive it.
|
||||||
|
ImGui::BeginDisabled(injected_ && (!want_input_ || target_state_ != TargetState::Alive));
|
||||||
ImGui::Checkbox("Forward synthetic test input", &test_input_);
|
ImGui::Checkbox("Forward synthetic test input", &test_input_);
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
if (test_input_)
|
if (test_input_)
|
||||||
|
|||||||
@@ -16,10 +16,28 @@
|
|||||||
namespace coop
|
namespace coop
|
||||||
{
|
{
|
||||||
|
|
||||||
|
// Liveness of the injected target, surfaced in the UI so a dead/hung game is obvious.
|
||||||
|
enum class TargetState
|
||||||
|
{
|
||||||
|
NotInjected, // no hook loaded
|
||||||
|
Alive, // process running and the hook heartbeat is advancing
|
||||||
|
Hung, // process still exists but the heartbeat stalled (not responding)
|
||||||
|
Terminated, // process has exited
|
||||||
|
};
|
||||||
|
|
||||||
class InjectionPanel
|
class InjectionPanel
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
InjectionPanel();
|
InjectionPanel();
|
||||||
|
~InjectionPanel();
|
||||||
|
|
||||||
|
InjectionPanel(const InjectionPanel&) = delete;
|
||||||
|
InjectionPanel& operator=(const InjectionPanel&) = delete;
|
||||||
|
|
||||||
|
// Recompute target liveness (terminated / hung / alive). Call once per frame from
|
||||||
|
// the main loop, independent of panel visibility, so game_hwnd() and the mirror
|
||||||
|
// panels react to a dead target even while the Injection panel is hidden.
|
||||||
|
void tick();
|
||||||
|
|
||||||
// `debug_details` shows the verbose hook diagnostics (per-slot poll table,
|
// `debug_details` shows the verbose hook diagnostics (per-slot poll table,
|
||||||
// focus-API counts, input-path detection); off shows a general summary.
|
// focus-API counts, input-path detection); off shows a general summary.
|
||||||
@@ -29,12 +47,24 @@ public:
|
|||||||
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
|
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
|
||||||
void publish(const std::array<PadInfo, kMaxPads>& pads);
|
void publish(const std::array<PadInfo, kMaxPads>& pads);
|
||||||
|
|
||||||
// The injected game's main window, as reported by the hook (null if none).
|
// The injected game's main window, as reported by the hook (null if none). A
|
||||||
|
// terminated target's HWND is stale/invalid, so report none -- the capture and
|
||||||
|
// audio panels then drop to idle instead of chasing a dead window.
|
||||||
[[nodiscard]] HWND game_hwnd() const
|
[[nodiscard]] HWND game_hwnd() const
|
||||||
{
|
{
|
||||||
|
if (target_state_ == TargetState::Terminated)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
return reinterpret_cast<HWND>(server_.hook_status().game_hwnd);
|
return reinterpret_cast<HWND>(server_.hook_status().game_hwnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current liveness of the injected target (for other panels / status).
|
||||||
|
[[nodiscard]] TargetState target_state() const
|
||||||
|
{
|
||||||
|
return target_state_;
|
||||||
|
}
|
||||||
|
|
||||||
// The hook's full diagnostics back-channel (other panels read the audio
|
// The hook's full diagnostics back-channel (other panels read the audio
|
||||||
// render-stream counts from here).
|
// render-stream counts from here).
|
||||||
[[nodiscard]] HookStatusView hook_status() const
|
[[nodiscard]] HookStatusView hook_status() const
|
||||||
@@ -78,6 +108,8 @@ public:
|
|||||||
private:
|
private:
|
||||||
void refresh_processes();
|
void refresh_processes();
|
||||||
void inject_selected();
|
void inject_selected();
|
||||||
|
void update_liveness(); // recompute target_state_ from process + heartbeat
|
||||||
|
void close_target_handle(); // close target_process_ and reset liveness state
|
||||||
void draw_subsystem_controls(const HookStatusView& status);
|
void draw_subsystem_controls(const HookStatusView& status);
|
||||||
void draw_hook_list(const HookStatusView& status);
|
void draw_hook_list(const HookStatusView& status);
|
||||||
void draw_hook_status(bool debug_details);
|
void draw_hook_status(bool debug_details);
|
||||||
@@ -105,6 +137,12 @@ private:
|
|||||||
std::uint32_t last_heartbeat_ = 0;
|
std::uint32_t last_heartbeat_ = 0;
|
||||||
double last_heartbeat_time_ = 0.0;
|
double last_heartbeat_time_ = 0.0;
|
||||||
bool dll_alive_ = false;
|
bool dll_alive_ = false;
|
||||||
|
|
||||||
|
// Target-process liveness: a SYNCHRONIZE|QUERY handle taken at inject time lets
|
||||||
|
// us notice the game exiting (WaitForSingleObject) vs merely hanging (heartbeat
|
||||||
|
// stalled while the process still exists).
|
||||||
|
HANDLE target_process_ = nullptr;
|
||||||
|
TargetState target_state_ = TargetState::NotInjected;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace coop
|
} // namespace coop
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ int run()
|
|||||||
#endif
|
#endif
|
||||||
input->poll();
|
input->poll();
|
||||||
injection.publish(input->pads());
|
injection.publish(input->pads());
|
||||||
|
injection.tick(); // refresh target liveness before the mirror panels read game_hwnd()
|
||||||
const HWND game = injection.game_hwnd();
|
const HWND game = injection.game_hwnd();
|
||||||
capture.set_target(game);
|
capture.set_target(game);
|
||||||
audio.set_target(game);
|
audio.set_target(game);
|
||||||
|
|||||||
Reference in New Issue
Block a user