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:
16
README.md
16
README.md
@@ -110,14 +110,6 @@ default** and covers anything the hooked path doesn't.
|
||||
|
||||
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
|
||||
|
||||
- **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
|
||||
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**
|
||||
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.
|
||||
|
||||
@@ -13,6 +13,7 @@ add_executable(coop_host WIN32
|
||||
src/inject/process_list.cpp
|
||||
src/inject/window_list.cpp
|
||||
src/inject/injector.cpp
|
||||
src/inject/dll_probe.cpp
|
||||
src/inject/mkb_forward.cpp
|
||||
src/ipc/ipc_server.cpp
|
||||
src/capture/frame_renderer.cpp
|
||||
|
||||
38
host/src/inject/dll_probe.cpp
Normal file
38
host/src/inject/dll_probe.cpp
Normal 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
|
||||
18
host/src/inject/dll_probe.hpp
Normal file
18
host/src/inject/dll_probe.hpp
Normal 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
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "inject/dll_probe.hpp"
|
||||
#include "inject/injector.hpp"
|
||||
#include "ui/app_chrome.hpp"
|
||||
|
||||
@@ -217,6 +218,48 @@ void InjectionPanel::refresh_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()
|
||||
{
|
||||
if (selected_pid_ == 0)
|
||||
@@ -226,6 +269,15 @@ void InjectionPanel::inject_selected()
|
||||
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
|
||||
// immediately on load.
|
||||
if (!server_.start(selected_pid_))
|
||||
@@ -235,26 +287,12 @@ void InjectionPanel::inject_selected()
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish the desired subsystem state before the hook's first 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_);
|
||||
publish_subsystem_state();
|
||||
|
||||
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
|
||||
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;
|
||||
begin_liveness_tracking();
|
||||
status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ").";
|
||||
status_color_ = kGreen;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ public:
|
||||
private:
|
||||
void refresh_targets(); // refresh both the window list and the process list
|
||||
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
|
||||
// (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.
|
||||
|
||||
@@ -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.
|
||||
add_executable(mock_game_test
|
||||
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
|
||||
${CMAKE_SOURCE_DIR}/host/src
|
||||
${CMAKE_SOURCE_DIR}/tools/mock_game)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <d3d11.h>
|
||||
|
||||
#include "capture/shared_texture.hpp"
|
||||
#include "inject/dll_probe.hpp"
|
||||
#include "coop/audio_ring.hpp"
|
||||
#include "coop/log_ring.hpp"
|
||||
#include "coop/protocol.hpp"
|
||||
@@ -1039,6 +1040,89 @@ void test_graceful_disconnect(const char* backend)
|
||||
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()
|
||||
{
|
||||
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).
|
||||
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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user