Graceful disconnect: tell the DLL to unhook everything
On an explicit Disconnect and on graceful tool exit, the host now asks the injected DLL to remove every subsystem so the game runs exactly as if it was never hooked (each hook restores its original bytes). The DLL stays injected but dormant, ready for a later reconnect -- we never eject it. Before, both paths just dropped the IPC channel (IpcServer::stop) without telling the DLL, leaving the hooks active with frozen forwarded state until the game exited. - IpcServer::request_unhook_all() sets every subsystem_disabled flag (the DLL reconciles to fully unhooked on its next tick); all_hooks_removed() reads the hook registry back so the host can confirm the game is vanilla. - InjectionPanel::disconnect_graceful() requests the unhook, waits (bounded) for the registry to clear, then stops. Wired into the Disconnect button (700ms) and the destructor (300ms). The flags persist in the section the DLL keeps alive, so the unhook completes even if the host exits before confirming. Tests (failing first): - ipc_server_test: request_unhook_all() disables all subsystems; all_hooks_removed() tracks the registry. Deterministic, no game. - mock_game_test test_graceful_disconnect: inject -> hooks installed -> request unhook-all -> every hook removed (game vanilla) while the DLL stays alive (heartbeat advancing). Full suite still passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,16 @@ add_executable(audio_ring_test audio_ring_test.cpp)
|
||||
target_link_libraries(audio_ring_test PRIVATE coop_common)
|
||||
add_test(NAME audio_ring_test COMMAND audio_ring_test)
|
||||
|
||||
# Unit test for the host-side IpcServer control ops behind graceful disconnect:
|
||||
# request_unhook_all() disables every subsystem and all_hooks_removed() reads the hook
|
||||
# registry back. Opens a second view of the section to stand in for the hook. No game.
|
||||
add_executable(ipc_server_test
|
||||
ipc_server_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/host/src/ipc/ipc_server.cpp)
|
||||
target_include_directories(ipc_server_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
||||
target_link_libraries(ipc_server_test PRIVATE coop_common)
|
||||
add_test(NAME ipc_server_test COMMAND ipc_server_test)
|
||||
|
||||
# Unit test for the DetourGate safe-unhook coordinator (hook/src/hook_guard.hpp): drain() must block
|
||||
# while a detour Guard is in flight and return promptly otherwise. Fast/deterministic complement to
|
||||
# the mock_game_test hook/unhook storm. Header-only (just needs the hook include dir + threads).
|
||||
@@ -299,6 +309,7 @@ coop_output_subdir(tests
|
||||
hook_selftest
|
||||
dinput_hook_test
|
||||
audio_ring_test
|
||||
ipc_server_test
|
||||
detour_gate_test
|
||||
hook_install_test
|
||||
mkb_ring_test
|
||||
|
||||
103
tests/ipc_server_test.cpp
Normal file
103
tests/ipc_server_test.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
// Unit test for the host-side IpcServer control operations that drive graceful disconnect
|
||||
// (hook/src reconciles against these): request_unhook_all() must set every subsystem's
|
||||
// "disabled" flag so the injected DLL removes all hooks, and all_hooks_removed() must report
|
||||
// whether the DLL's hook registry currently has anything installed. No game / no DLL -- the
|
||||
// test opens a second view of the same section to act as the hook would (publish a registry,
|
||||
// read the control flags). Deterministic, no threads.
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "coop/protocol.hpp"
|
||||
#include "coop/shared_memory.hpp"
|
||||
#include "ipc/ipc_server.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
namespace
|
||||
{
|
||||
int g_failures = 0;
|
||||
void check(bool ok, const char* what)
|
||||
{
|
||||
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
|
||||
if (!ok)
|
||||
{
|
||||
++g_failures;
|
||||
}
|
||||
}
|
||||
|
||||
bool all_disabled(const SharedBlock* b)
|
||||
{
|
||||
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||
{
|
||||
if (b->control.subsystem_disabled[s].load(std::memory_order_acquire) != 1u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool none_disabled(const SharedBlock* b)
|
||||
{
|
||||
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||
{
|
||||
if (b->control.subsystem_disabled[s].load(std::memory_order_acquire) != 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
const unsigned long pid = GetCurrentProcessId(); // section name is per-pid; no real game needed
|
||||
|
||||
IpcServer server;
|
||||
if (!server.start(pid))
|
||||
{
|
||||
std::printf("FAIL: IpcServer::start\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A second view of the same section, standing in for the injected hook: it reads the control
|
||||
// flags the host writes and publishes the hook registry the host reads back.
|
||||
SharedMemory hook_view;
|
||||
if (!hook_view.open(shared_memory_name(pid), sizeof(SharedBlock)))
|
||||
{
|
||||
std::printf("FAIL: open hook view\n");
|
||||
return 1;
|
||||
}
|
||||
auto* block = hook_view.as<SharedBlock>();
|
||||
|
||||
// Fresh section: nothing disabled (0 = install), so the game gets every subsystem by default.
|
||||
check(none_disabled(block), "fresh section requests all subsystems installed");
|
||||
|
||||
// Graceful disconnect step 1: ask the DLL to remove every hook.
|
||||
server.request_unhook_all();
|
||||
check(all_disabled(block), "request_unhook_all() disables every subsystem");
|
||||
|
||||
// all_hooks_removed() reflects the DLL's registry. Empty registry => nothing installed.
|
||||
check(server.all_hooks_removed(), "all_hooks_removed() true when the registry is empty");
|
||||
|
||||
// Hook publishes one installed entry => something is still hooked.
|
||||
block->status.hook_entry_count = 1;
|
||||
block->status.hook_entries[0].installed = 1u;
|
||||
check(!server.all_hooks_removed(), "all_hooks_removed() false while a hook is still installed");
|
||||
|
||||
// Hook finishes removing it => back to "nothing installed".
|
||||
block->status.hook_entries[0].installed = 0u;
|
||||
check(server.all_hooks_removed(), "all_hooks_removed() true once every entry is uninstalled");
|
||||
|
||||
// Reconnect re-enables: clearing a subsystem's disabled flag requests it back (symmetric op).
|
||||
server.set_subsystem_enabled(HookSubsys_Input, true);
|
||||
check(block->control.subsystem_disabled[HookSubsys_Input].load(std::memory_order_acquire) == 0u,
|
||||
"re-enabling a subsystem clears its disabled flag (reconnect path)");
|
||||
|
||||
server.stop();
|
||||
std::printf(g_failures == 0 ? "PASS ipc_server_test\n" : "FAILED ipc_server_test (%d)\n", g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -958,6 +958,87 @@ void test_hook_storm(const char* backend, ID3D11Device* device, bool vk_early)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Number of hooks the DLL currently reports as installed (across all subsystems).
|
||||
std::uint32_t installed_hook_count(const SharedBlock* block)
|
||||
{
|
||||
std::uint32_t count = block->status.hook_entry_count;
|
||||
if (count > kMaxHookEntries)
|
||||
{
|
||||
count = kMaxHookEntries;
|
||||
}
|
||||
std::uint32_t installed = 0;
|
||||
for (std::uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
installed += block->status.hook_entries[i].installed != 0 ? 1u : 0u;
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
// Graceful disconnect contract (the host-side IpcServer::request_unhook_all path): once the host
|
||||
// asks for every subsystem off, the DLL must remove ALL its hooks -- the game runs as if it was
|
||||
// never touched -- yet stay alive (heartbeat advancing) so it can be reconnected later. Models what
|
||||
// the Disconnect button / tool-exit does (set all subsystem_disabled = 1) and asserts the outcome.
|
||||
void test_graceful_disconnect(const char* backend)
|
||||
{
|
||||
std::printf("== graceful disconnect: %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 (graceful disconnect)");
|
||||
return;
|
||||
}
|
||||
Sleep(800);
|
||||
|
||||
// Enable input + focus + video + MKB (all of which the mock exercises); leave audio off so the
|
||||
// test doesn't depend on an audio endpoint. Then inject.
|
||||
SharedMemory shm;
|
||||
SharedBlock* block = make_ipc(shm, game.pid(), 1u << HookSubsys_Audio);
|
||||
if (block == nullptr || !inject_retry(game.pid()))
|
||||
{
|
||||
check(false, "inject mock game (graceful disconnect)");
|
||||
game.kill();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the DLL to install its hooks.
|
||||
bool installed = false;
|
||||
for (int i = 0; i < 100 && game.alive() && !installed; ++i) // up to ~5 s
|
||||
{
|
||||
Sleep(50);
|
||||
installed = installed_hook_count(block) > 0;
|
||||
}
|
||||
check(installed, "hooks installed after inject (something to unhook)");
|
||||
const std::uint32_t hb0 = block->status.heartbeat.load(std::memory_order_relaxed);
|
||||
|
||||
// Graceful disconnect: request every subsystem removed (what request_unhook_all writes).
|
||||
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||
{
|
||||
block->control.subsystem_disabled[s].store(1u, std::memory_order_release);
|
||||
}
|
||||
|
||||
// The DLL must remove every hook -> game vanilla.
|
||||
bool vanilla = false;
|
||||
for (int i = 0; i < 100 && game.alive() && !vanilla; ++i) // up to ~5 s
|
||||
{
|
||||
Sleep(50);
|
||||
vanilla = installed_hook_count(block) == 0;
|
||||
}
|
||||
check(vanilla, "DLL removed every hook on request (game runs vanilla)");
|
||||
|
||||
// ... and stay alive (dormant), so a later reconnect can re-enable it.
|
||||
check(game.alive(), "game still alive after graceful disconnect");
|
||||
Sleep(400);
|
||||
const std::uint32_t hb1 = block->status.heartbeat.load(std::memory_order_relaxed);
|
||||
check(hb1 != hb0, "DLL heartbeat still advancing (injected but dormant)");
|
||||
|
||||
game.kill();
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll
|
||||
@@ -988,6 +1069,10 @@ int main()
|
||||
|
||||
test_av_and_hook_cycles(device);
|
||||
|
||||
// Graceful disconnect: the host asks the DLL to unhook everything; the game must return to
|
||||
// vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown).
|
||||
test_graceful_disconnect("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