Reconnect to an already-injected DLL (reuse it, survive a tool restart)

Disconnect -> reconnect now reuses the DLL already in the game instead of
injecting again, including across a tool restart or crash: a connected DLL keeps
its per-pid shared section (and worker) alive after the host goes away, so a
fresh host can find it and re-attach to the same section.

- hook_dll_alive(pid) (host/src/inject/dll_probe.cpp): detect a live DLL by
  opening the per-pid section and polling its heartbeat (returns as soon as a
  beat lands; a missing section or stalled worker reads as not-alive). It does
  not check magic -- a graceful disconnect zeroes magic but the DLL keeps
  beating and the worker never re-checks magic post-connect.
- InjectionPanel: the Inject and Connect button branches to reconnect_selected()
  when a live DLL is detected -- IpcServer::start() re-attaches to the SAME
  section the DLL still holds and re-publishes the subsystem state; no
  re-injection. Factored the shared post-connect setup (publish_subsystem_state
  / begin_liveness_tracking). The DLL needed no change -- it just resumes reading
  the re-attached section.
- A false not-alive is benign: the inject path still re-attaches an
  already-injected DLL (LoadLibrary no-ops), so the timeout only needs to clear
  the worker's ~250ms beat period with margin.

Test (mock_game_test test_reconnect): inject -> hooked -> graceful disconnect ->
drop the host handle (simulating a restart while the DLL keeps the section alive)
-> detect via heartbeat -> re-attach to the same section -> hooks re-install
without re-injecting -> and hook_dll_alive goes false once the game is gone.

Roadmap: both current tasks (graceful disconnect, reconnect) done -> removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:05:54 +02:00
parent 6d96531ac7
commit 0eb275daca
8 changed files with 213 additions and 26 deletions

View File

@@ -110,14 +110,6 @@ default** and covers anything the hooked path doesn't.
Completed work lives in **Lessons learned** + the test suite, not here. Completed work lives in **Lessons learned** + the test suite, not here.
### Current tasks
- **Reconnect to an already-injected DLL.** Support disconnect → reconnect reusing the DLL that's
already in the game, *including across a tool restart or crash*: the host detects the live DLL
(via its advancing IPC heartbeat on the per-pid section), re-attaches to the same shared section,
and resumes control without re-injecting. (DLL self-cleanup on host *crash* is explicitly **not**
required — relaunch, reconnect, then disconnect gracefully to clean up.)
### Future work ### Future work
- **Per-game profiles** — persist each game's subsystem / capture-mode / audio choices and re-apply - **Per-game profiles** — persist each game's subsystem / capture-mode / audio choices and re-apply
@@ -397,6 +389,14 @@ person/account to receive the stream.
sees the mirrored video, hears the audio, and that their controller drives the sees the mirrored video, hears the audio, and that their controller drives the
real game. real game.
**Disconnect / reconnect.** **Disconnect** asks the injected DLL to remove every hook so the game
behaves exactly as if it was never touched, then drops the channel — but leaves the DLL injected
(dormant). Clicking **Inject & Connect** on a game that still has a live DLL (left dormant, or
surviving a tool restart/crash — a connected DLL keeps its shared section alive) **reconnects** to it
and resumes, without injecting again. Closing the host also unhooks the game on the way out. So a
clean cycle is: connect → play → disconnect (game back to normal, DLL parked) → reconnect later. The
DLL is never force-unloaded; it goes away when the game exits.
Useful checks while developing without RPT: tick **Forward synthetic test input** Useful checks while developing without RPT: tick **Forward synthetic test input**
in the Injection panel to make the game move on its own (proving forwarding is the in the Injection panel to make the game move on its own (proving forwarding is the
source), and click away from the game to confirm focus spoofing keeps it running. source), and click away from the game to confirm focus spoofing keeps it running.

View File

@@ -13,6 +13,7 @@ add_executable(coop_host WIN32
src/inject/process_list.cpp src/inject/process_list.cpp
src/inject/window_list.cpp src/inject/window_list.cpp
src/inject/injector.cpp src/inject/injector.cpp
src/inject/dll_probe.cpp
src/inject/mkb_forward.cpp src/inject/mkb_forward.cpp
src/ipc/ipc_server.cpp src/ipc/ipc_server.cpp
src/capture/frame_renderer.cpp src/capture/frame_renderer.cpp

View File

@@ -0,0 +1,38 @@
#include "inject/dll_probe.hpp"
#include <cstdint>
#include <windows.h>
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop
{
bool hook_dll_alive(unsigned long pid, int timeout_ms)
{
// The per-pid section exists only while someone holds it; a connected DLL keeps it alive across
// a host restart. Open it (don't create), then confirm the DLL's worker is actually beating --
// a stale section with a dead worker (heartbeat frozen) must read as not-alive so we inject fresh.
// Poll rather than sample once: the worker only beats ~every 250 ms, so a single short read can
// straddle a gap and miss it; return the instant a beat lands, and give up after the timeout.
SharedMemory shm;
if (!shm.open(shared_memory_name(pid), sizeof(SharedBlock)))
{
return false;
}
auto* block = shm.as<SharedBlock>();
const std::uint32_t h0 = block->status.heartbeat.load(std::memory_order_acquire);
for (int waited = 0; waited < timeout_ms; waited += 25)
{
Sleep(25);
if (block->status.heartbeat.load(std::memory_order_acquire) != h0)
{
return true;
}
}
return false;
}
} // namespace coop

View File

@@ -0,0 +1,18 @@
// Detect whether our hook DLL is already injected and alive in a target process, so the host can
// reconnect to it (reusing the DLL) instead of injecting again -- including after a tool restart or
// crash, since a connected DLL keeps the per-pid IPC section alive.
#pragma once
namespace coop
{
// True if `pid` already hosts a live coop_hook DLL: the per-pid IPC section exists and its heartbeat
// advances within `timeout_ms` (the DLL's worker is still beating). Returns as soon as a beat lands,
// so it's fast when the DLL is healthy; the timeout must clear the worker's beat period (~250 ms), so
// the default leaves margin. A missing section, or one whose heartbeat has stalled (dead worker),
// reads as not-alive -> the caller should inject fresh (which still re-attaches an already-injected
// DLL, so a false negative is benign). The default clears several beat periods for margin; a healthy
// DLL is detected as soon as the first beat lands, so this returns quickly in the normal case.
bool hook_dll_alive(unsigned long pid, int timeout_ms = 1000);
} // namespace coop

View File

@@ -6,6 +6,7 @@
#include <windows.h> #include <windows.h>
#include "inject/dll_probe.hpp"
#include "inject/injector.hpp" #include "inject/injector.hpp"
#include "ui/app_chrome.hpp" #include "ui/app_chrome.hpp"
@@ -217,6 +218,48 @@ void InjectionPanel::refresh_processes()
processes_ = list_processes(); processes_ = list_processes();
} }
void InjectionPanel::publish_subsystem_state()
{
// Publish the desired subsystem state before the hook's next reconcile tick.
server_.set_subsystem_enabled(HookSubsys_Input, want_input_);
server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_);
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_);
server_.set_cursor_clip_allowed(!release_cursor_);
}
void InjectionPanel::begin_liveness_tracking()
{
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;
}
void InjectionPanel::reconnect_selected()
{
// Re-attach to a DLL that's already injected and alive (a prior session left it dormant after a
// graceful disconnect, or the tool restarted): bring the channel back up on the SAME per-pid
// section the DLL still holds and re-publish the desired subsystem state -- no re-injection.
if (!server_.start(selected_pid_))
{
status_ = "Failed to re-attach shared memory.";
status_color_ = kRed;
return;
}
publish_subsystem_state();
begin_liveness_tracking();
status_ = "Reconnected to " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) +
") -- reused the injected DLL.";
status_color_ = kGreen;
}
void InjectionPanel::inject_selected() void InjectionPanel::inject_selected()
{ {
if (selected_pid_ == 0) if (selected_pid_ == 0)
@@ -226,6 +269,15 @@ void InjectionPanel::inject_selected()
return; return;
} }
// If our DLL is already injected and alive in this target (left dormant by a graceful disconnect,
// or surviving a tool restart -- it keeps the section alive), reconnect to it instead of
// injecting a second time.
if (hook_dll_alive(selected_pid_))
{
reconnect_selected();
return;
}
// Bring up the shared-memory channel before injecting so the hook finds it // Bring up the shared-memory channel before injecting so the hook finds it
// immediately on load. // immediately on load.
if (!server_.start(selected_pid_)) if (!server_.start(selected_pid_))
@@ -235,26 +287,12 @@ void InjectionPanel::inject_selected()
return; return;
} }
// Publish the desired subsystem state before the hook's first reconcile tick. publish_subsystem_state();
server_.set_subsystem_enabled(HookSubsys_Input, want_input_);
server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_);
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_);
server_.set_cursor_clip_allowed(!release_cursor_);
const InjectResult result = inject_dll(selected_pid_, hook_dll_path()); const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
if (result.status == InjectStatus::Ok) if (result.status == InjectStatus::Ok)
{ {
injected_ = true; begin_liveness_tracking();
// 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;
} }

View File

@@ -164,7 +164,10 @@ public:
private: private:
void refresh_targets(); // refresh both the window list and the process list void refresh_targets(); // refresh both the window list and the process list
void refresh_processes(); void refresh_processes();
void inject_selected(); void inject_selected(); // inject fresh, OR reconnect if a live DLL is already in the target
void reconnect_selected(); // re-attach to an already-injected, live DLL (no re-inject)
void publish_subsystem_state(); // push the desired per-subsystem install state + cursor policy
void begin_liveness_tracking(); // mark connected: open the process handle, seed the heartbeat clock
// Graceful disconnect: ask the DLL to remove every hook (game returns to vanilla), wait // Graceful disconnect: ask the DLL to remove every hook (game returns to vanilla), wait
// (bounded) for it to take effect, then drop the channel. The DLL stays injected/dormant for a // (bounded) for it to take effect, then drop the channel. The DLL stays injected/dormant for a
// later reconnect; we never eject it. Used by the Disconnect button and the destructor. // later reconnect; we never eject it. Used by the Disconnect button and the destructor.

View File

@@ -221,7 +221,8 @@ add_test(NAME dx12_present_hook_test COMMAND dx12_present_hook_test)
# stresses hook/unhook cycles. Reuses the shipping shared-texture reader. Skips without D3D11. # stresses hook/unhook cycles. Reuses the shipping shared-texture reader. Skips without D3D11.
add_executable(mock_game_test add_executable(mock_game_test
mock_game_test.cpp mock_game_test.cpp
${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp) ${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp
${CMAKE_SOURCE_DIR}/host/src/inject/dll_probe.cpp)
target_include_directories(mock_game_test PRIVATE target_include_directories(mock_game_test PRIVATE
${CMAKE_SOURCE_DIR}/host/src ${CMAKE_SOURCE_DIR}/host/src
${CMAKE_SOURCE_DIR}/tools/mock_game) ${CMAKE_SOURCE_DIR}/tools/mock_game)

View File

@@ -24,6 +24,7 @@
#include <d3d11.h> #include <d3d11.h>
#include "capture/shared_texture.hpp" #include "capture/shared_texture.hpp"
#include "inject/dll_probe.hpp"
#include "coop/audio_ring.hpp" #include "coop/audio_ring.hpp"
#include "coop/log_ring.hpp" #include "coop/log_ring.hpp"
#include "coop/protocol.hpp" #include "coop/protocol.hpp"
@@ -1039,6 +1040,89 @@ void test_graceful_disconnect(const char* backend)
game.kill(); game.kill();
} }
// Reconnect contract: after a graceful disconnect (and even a simulated tool restart -- the host
// drops its section handle while the DLL keeps it alive), the host can detect the live DLL via its
// heartbeat (hook_dll_alive) and re-attach to the SAME per-pid section to resume control, without
// re-injecting. The DLL, still connected to that section, re-installs its hooks when the reconnected
// host re-enables them.
void test_reconnect(const char* backend)
{
std::printf("== reconnect to an already-injected DLL: %s ==\n", backend);
std::wstring wbackend;
for (const char* p = backend; *p != '\0'; ++p)
{
wbackend.push_back(static_cast<wchar_t>(*p));
}
MockGame game = MockGame::launch(wbackend + L" 30");
if (!game.ok)
{
check(false, "launch mock game (reconnect)");
return;
}
Sleep(800);
const std::uint32_t disabled = 1u << HookSubsys_Audio; // input+focus+video+mkb on; audio off
SharedMemory shm_a;
SharedBlock* block_a = make_ipc(shm_a, game.pid(), disabled);
if (block_a == nullptr || !inject_retry(game.pid()))
{
check(false, "inject mock game (reconnect)");
game.kill();
return;
}
bool installed = false;
for (int i = 0; i < 100 && game.alive() && !installed; ++i)
{
Sleep(50);
installed = installed_hook_count(block_a) > 0;
}
check(installed, "first connection installed hooks");
check(hook_dll_alive(game.pid()), "hook_dll_alive() detects the live DLL");
// Graceful disconnect: unhook everything, then simulate the host going away (drop our handle;
// the DLL keeps the section alive). This stands in for both an explicit disconnect and a restart.
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
block_a->control.subsystem_disabled[s].store(1u, std::memory_order_release);
}
for (int i = 0; i < 100 && installed_hook_count(block_a) != 0; ++i)
{
Sleep(50);
}
check(installed_hook_count(block_a) == 0, "graceful disconnect unhooked the game");
// Let the DLL settle back to steady heartbeating: the unhook tick runs several bounded drains, so
// the worker can briefly not beat right after it. A real reconnect targets an already-dormant DLL,
// not one in the microsecond after a mass-unhook.
Sleep(500);
shm_a.reset(); // host A "exits" -- only the DLL holds the section now
// The DLL is still alive and holding the section: the restarted host can find it...
check(hook_dll_alive(game.pid()), "DLL still detectable after the host dropped its handle");
// ...and reconnect by re-attaching to the SAME section (no re-inject) and re-enabling subsystems.
SharedMemory shm_b;
SharedBlock* block_b = make_ipc(shm_b, game.pid(), 0u); // re-attach, all subsystems on
if (block_b == nullptr)
{
check(false, "reconnect: re-attach to the section");
game.kill();
return;
}
bool reinstalled = false;
for (int i = 0; i < 100 && game.alive() && !reinstalled; ++i)
{
Sleep(50);
reinstalled = installed_hook_count(block_b) > 0;
}
check(reinstalled, "reconnect re-installed the hooks via the existing DLL (no re-inject)");
check(game.alive(), "game alive after reconnect");
game.kill();
Sleep(200);
check(!hook_dll_alive(game.pid()), "hook_dll_alive() false once the game (and DLL) is gone");
}
int main() int main()
{ {
kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll
@@ -1073,6 +1157,10 @@ int main()
// vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown). // vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown).
test_graceful_disconnect("dx11"); test_graceful_disconnect("dx11");
// Reconnect: detect the live DLL and re-attach to the same section (even across a simulated host
// restart) to resume control without re-injecting.
test_reconnect("dx11");
// Aggressive hook/unhook storm across every backend: a separate thread thrashes every // Aggressive hook/unhook storm across every backend: a separate thread thrashes every
// subsystem on/off while the game presents, to catch an unsafe install/remove race (the // subsystem on/off while the game presents, to catch an unsafe install/remove race (the
// "spamming Mirror video crashed Brotato" use-after-free). vk uses the early-load path. // "spamming Mirror video crashed Brotato" use-after-free). vk uses the early-load path.