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:
2026-06-21 01:05:49 +02:00
parent 61049a620c
commit b3e8a1adc6
4 changed files with 136 additions and 34 deletions

View File

@@ -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

View File

@@ -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);
@@ -348,11 +410,23 @@ void InjectionPanel::draw(bool debug_details)
if (server_.running()) if (server_.running())
{ {
ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid()); 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());
}
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_)

View File

@@ -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

View File

@@ -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);