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

@@ -69,6 +69,64 @@ InjectionPanel::InjectionPanel()
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()
{
processes_ = list_processes();
@@ -102,6 +160,14 @@ void InjectionPanel::inject_selected()
if (result.status == InjectStatus::Ok)
{
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_color_ = kGreen;
}
@@ -273,19 +339,6 @@ void InjectionPanel::draw_hook_status(bool debug_details)
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");
if (!injected_)
{
@@ -293,17 +346,26 @@ void InjectionPanel::draw_hook_status(bool debug_details)
return;
}
if (dll_alive_)
switch (target_state_)
{
case TargetState::Alive:
ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(),
status.heartbeat);
}
else
{
ImGui::TextColored(kRed, "Hook DLL not responding (no heartbeat).");
break;
case TargetState::Hung:
ImGui::TextColored(kRed, "Target not responding -- heartbeat stalled (frozen?).");
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);
ImGui::EndDisabled();
ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
draw_hook_list(status);
@@ -348,11 +410,23 @@ void InjectionPanel::draw(bool debug_details)
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"))
{
server_.stop();
injected_ = false;
close_target_handle();
status_ = "Stopped.";
status_color_ = kGrey;
}
@@ -401,8 +475,9 @@ void InjectionPanel::draw(bool debug_details)
ImGui::TextColored(status_color_, "%s", status_.c_str());
}
// Synthetic input only reaches the game if the XInput hook is installed.
ImGui::BeginDisabled(injected_ && !want_input_);
// Synthetic input only reaches the game if the XInput hook is installed and the
// 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::EndDisabled();
if (test_input_)