leakhunt/dll/leakfix/src/dllmain.cpp
acbot 57b5e43d0e Initial commit — leak-hunt project complete
Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:07:58 +02:00

103 lines
3.8 KiB
C++

// dllmain.cpp — leakfix.dll entry point
//
// iter-5 (2026-05-20): patch application is deferred to a worker
// thread that sleeps ~30 seconds before applying. This matches the
// timing of the Python runtime patcher (tools/fleet_monitor.sh),
// which lands its patches well after Decal init is complete. The
// PE-import-load → DllMain → immediate apply_all_patches sequence
// used in iter-1..iter-4 lost the race with Decal's own hook
// installation and crashed some accounts (Unkle Leo most reliably).
// See feedback_dll_load_order_conflict.md.
//
// The SEH crash handler is still installed immediately so any
// crashes during the 30s window (including ones caused by Decal)
// are captured.
#include <windows.h>
#include "logging.h"
#include "patches.h"
#include "instr.h"
namespace {
bool g_skip_patches = false;
DWORD WINAPI deferred_patch_thread(LPVOID) {
// Give Decal / UtilityBelt time to finish their own hook
// installation before we lay our patches on top. 30s matches
// the Python cascade's observed-good timing.
Sleep(30000);
leakfix::logf("deferred-patch thread: woke after 30s, applying patches");
if (g_skip_patches) {
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
} else {
leakfix::apply_all_patches();
}
leakfix::instr_start_periodic_scan();
return 0;
}
void on_attach() {
char dll_path[MAX_PATH] = {0};
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
// Log next to the DLL itself
char log_path[MAX_PATH] = {0};
char* slash = nullptr;
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
if (slash) {
size_t prefix = (size_t)(slash - dll_path) + 1;
memcpy(log_path, dll_path, prefix);
strcpy(log_path + prefix, "leakfix.log");
} else {
strcpy(log_path, "leakfix.log");
}
leakfix::log_init(log_path);
leakfix::logf("attach: dll=%s (iter-5 deferred-patch)", dll_path);
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
// application. Instrumentation still runs. Used to bisect crashes:
// if the no-patches variant survives, the patches are the trigger.
char no_patches[8] = {0};
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
g_skip_patches = (no_patches[0] == '1');
// Crash handler installed immediately so the 30s pre-patch window
// is still observable if Decal/UB crashes the process.
leakfix::instr_install_crash_handler();
HANDLE h = CreateThread(nullptr, 0, deferred_patch_thread, nullptr, 0, nullptr);
if (h) {
CloseHandle(h);
leakfix::logf("deferred-patch thread spawned");
} else {
// CreateThread failure is extraordinary — fall back to the
// old in-DllMain apply so we at least get patches eventually.
leakfix::logf("CreateThread failed (err=%lu) — falling back to in-DllMain apply",
GetLastError());
if (!g_skip_patches) leakfix::apply_all_patches();
leakfix::instr_start_periodic_scan();
}
}
} // anon
// Exported stub so PE-import-table patching of acclient.exe can name
// a real symbol for the OS loader to resolve. Doing nothing is fine —
// just being present in the DLL is what makes the import valid.
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
return 0;
}
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
switch (reason) {
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(h);
on_attach();
break;
case DLL_PROCESS_DETACH:
leakfix::instr_stop_periodic_scan();
leakfix::logf("detach");
leakfix::log_close();
break;
}
return TRUE;
}