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

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