Stop allocating 64 KB per path on the hooked call paths - #216
Conversation
|
I really like this idea of having a |
|
Yes, static lookup_t g_path_scratch;
static path_scratch_t *scratch_context(BOOL create)
{
if (create)
return LOOKUP_THREAD(&g_path_scratch, path_scratch_t);
return (path_scratch_t *)lookup_get(&g_path_scratch, (ULONG_PTR)GetCurrentThreadId(), NULL);
}It also fixes two problems in the TLS version:
Kept in its own table rather than Cost is one list walk per acquire and per release instead of a TEB read, over a list no longer than Two things in lookup.c, independent of this PR, are split out into #217: Checked in a POSIX harness (pool code verbatim, real lookup.c, recycled TIDs), not the MSVC build: 2000 threads over 32 TIDs → 33 entries / 132 slots, all reachable; the TLS version left 1604 slots unreachable after 400 threads. TSan's only reports are Happy to push this to the branch, and update the trade-off section of the description, if you're good with it. |
c08cdbf to
cf37041
Compare
…mat=1 Stacked on kevoreilly#215, which owns the per-thread logging context. What is left here is the serializer abstraction and the nanopb backend. `log.c`'s formatting loops no longer talk to BSON directly. They go through a `log_serializer_t` vtable, so the wire format is a runtime choice: log-format = 0 BSON (default, unchanged bytes on the wire) log-format = 1 Protocol Buffers (experimental) Existing result servers and custom agents see exactly what they saw before unless `log-format` is set, so nothing downstream has to change. The format is process-wide. `log_init()` sets `g_default_serializer` once, before any hook can log, and a stream that mixes BSON and protobuf frames is not supported, so `g_active_serializer` is just that pointer. Per-thread state lives in kevoreilly#215's `log_context_t`, reached through `LOOKUP_THREAD` (a `lookup_t` keyed by thread id). This branch adds one field to it: `pb_ctx`, the ~100 KB protobuf scratch, allocated on a thread's first protobuf log and never in BSON mode. The serializer callbacks take no context argument, so each BSON callback fetches the context itself. An argument appended through a `log_*` helper therefore costs two table walks, the helper's and the callback's. Restacked onto kevoreilly#215's LOOKUP_THREAD revision and current capemon (a65ba12): * The dynamic-TLS context (`TlsAlloc`/`TlsGetValue`) is replaced by kevoreilly#215's `LOOKUP_THREAD` table, as requested on kevoreilly#216: kevoreilly#216 (comment) * The per-thread `active_serializer` field is gone. `LOOKUP_THREAD` payloads start zeroed and the format never changes after `log_init()`, so the field could only mirror `g_default_serializer`. `log_init()` no longer patches the calling thread's context. * The record's "I" field uses a65ba12's `BSON_ID(index)`, in the explanation frame and in the record, whichever serializer is active. * `log_init()` keeps a65ba12's wowmon handle mapping, after the serializer selection. Carried over from the previous revisions: * No TEB `NtTib.ArbitraryUserPointer` slot. ntdll's loader parks the `FullDllName` pointer there across its `NtMapViewOfSection` call, and capemon hooks `NtMapViewOfSection`, so a hook firing during a module load would write serializer state over the loader's string. * No `DLL_THREAD_DETACH` cleanup. `hide_module_from_peb()` unlinks the module from the loader lists during `DLL_PROCESS_ATTACH`, so DllMain is never entered again and that cleanup would never run. * All three `g_mutex` acquisitions go through `loq_lock()`, which keeps the fast-path-then-bounded-spin shape from ada31ca instead of dropping straight into the 100-iteration spin. * Both serializer vtables use positional initializers. C99 designated initializers are not accepted by PlatformToolset v141 in C mode. * The `.github/workflows/` changes are dropped. kevoreilly#212 owns CI. The protobuf backend is experimental and lossy: `schema.proto` cannot yet represent capemon's full call model, and no host-side parser consumes it. `log_init()` says so over the pipe when it is enabled. Not compiled: there is no MSVC on the machine this was restacked on. TAG=agy CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f CONV=b3f21014-0e97-49b9-aa93-0601a28982fe
…mat=1 Stacked on kevoreilly#215, which owns the per-thread logging context. What is left here is the serializer abstraction and the nanopb backend. `log.c`'s formatting loops no longer talk to BSON directly. They go through a `log_serializer_t` vtable, so the wire format is a runtime choice: log-format = 0 BSON (default, unchanged bytes on the wire) log-format = 1 Protocol Buffers (experimental) Existing result servers and custom agents see exactly what they saw before unless `log-format` is set, so nothing downstream has to change. The format is process-wide. `log_init()` sets `g_default_serializer` once, before any hook can log, and a stream that mixes BSON and protobuf frames is not supported, so `g_active_serializer` is just that pointer. Per-thread state lives in kevoreilly#215's `log_context_t`, reached through `LOOKUP_THREAD` (a `lookup_t` keyed by thread id). This branch adds one field to it: `pb_ctx`, the ~100 KB protobuf scratch, allocated on a thread's first protobuf log and never in BSON mode. The serializer callbacks take no context argument, so each BSON callback fetches the context itself. An argument appended through a `log_*` helper therefore costs two table walks, the helper's and the callback's. Restacked onto kevoreilly#215's LOOKUP_THREAD revision and current capemon (a65ba12): * The dynamic-TLS context (`TlsAlloc`/`TlsGetValue`) is replaced by kevoreilly#215's `LOOKUP_THREAD` table, as requested on kevoreilly#216: kevoreilly#216 (comment) * The per-thread `active_serializer` field is gone. `LOOKUP_THREAD` payloads start zeroed and the format never changes after `log_init()`, so the field could only mirror `g_default_serializer`. `log_init()` no longer patches the calling thread's context. * The record's "I" field uses a65ba12's `BSON_ID(index)`, in the explanation frame and in the record, whichever serializer is active. * `log_init()` keeps a65ba12's wowmon handle mapping, after the serializer selection. Carried over from the previous revisions: * No TEB `NtTib.ArbitraryUserPointer` slot. ntdll's loader parks the `FullDllName` pointer there across its `NtMapViewOfSection` call, and capemon hooks `NtMapViewOfSection`, so a hook firing during a module load would write serializer state over the loader's string. * No `DLL_THREAD_DETACH` cleanup. `hide_module_from_peb()` unlinks the module from the loader lists during `DLL_PROCESS_ATTACH`, so DllMain is never entered again and that cleanup would never run. * All three `g_mutex` acquisitions go through `loq_lock()`, which keeps the fast-path-then-bounded-spin shape from ada31ca instead of dropping straight into the 100-iteration spin. * Both serializer vtables use positional initializers. C99 designated initializers are not accepted by PlatformToolset v141 in C mode. * The `.github/workflows/` changes are dropped. kevoreilly#212 owns CI. The protobuf backend is experimental and lossy: `schema.proto` cannot yet represent capemon's full call model, and no host-side parser consumes it. `log_init()` says so over the pipe when it is enabled. Not compiled: there is no MSVC on the machine this was restacked on. TAG=agy CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f CONV=b3f21014-0e97-49b9-aa93-0601a28982fe
Normalising a path needed a WIDE_STRING_LIMIT-sized buffer, and every hooked
call took a fresh one from the allocator. NtQueryInformationFile took two,
NtSetInformationFile took three, handle_new_file took two, and half of them
were callocs that zeroed all 64 KB first. On a sample doing sustained file or
registry I/O that is tens of thousands of 64 KB allocate/zero/free cycles a
second, none of which outlive the call that made them.
Replaced with a small per-thread pool. path_scratch_acquire() hands out one of
four slots, path_scratch_release() gives it back, and the buffer is reused for
the life of the thread instead of being returned to the allocator.
Slots are allocated lazily, so a thread that only ever needs one buffer holds
one. Past four live buffers, acquire falls back to malloc and release frees it,
so exhaustion degrades to the previous behaviour rather than failing. No
locking: the pool is per-thread. The slot claim is still interlocked, because a
structured exception handler can run on top of a hook on the same thread and
would otherwise be able to claim a slot between the test and the set.
The buffer is not zeroed, but its first character is. That is what the callocs
were actually providing: path_from_object_attributes() returns without writing
anything when the object is NULL or the name fails a read probe, and its
callers then rely on the buffer reading as an empty string. Nothing in these
paths looks past the terminator.
Converted: 24 sites in hook_file.c, 6 registry-path buffers in log.c, 2 in
log.c's %F handling, 2 in misc.c, 2 in pipe.c, 1 each in hook_process.c and
hook_special.c.
Three pre-existing leaks fixed on the way:
* hook_file.c new_file_path_ascii() and new_file_path_unicode() never freed
their buffer at all - 32 KB and 64 KB leaked per dropped-file notification.
* pipe.c _pipe_sprintf() allocated before the `if (s == NULL) return -1`
check in its %F handler, so a NULL argument leaked 64 KB.
* misc.c is_image_base_remapped() and prevent_module_reloading() dereferenced
both of their allocations without checking either.
The six registry-path buffers in log.c now also check for NULL before use;
allocsize there is bounded by a C_ASSERT against the slot size.
Trade-off worth being explicit about: this converts transient allocation into
retained per-thread memory. A thread that nests four deep holds 256 KB for the
rest of its life. Lazy slot allocation keeps the common one- or two-buffer case
at 64-128 KB, but on a sample that spawns very many path-touching threads this
is more resident memory than before. PATH_SCRATCH_SLOTS is a single constant if
that turns out to be the wrong balance.
TAG=agy
CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
scratch_context() now takes its per-thread pool from a LOOKUP_THREAD table instead of a TlsAlloc slot, as suggested in review. path_scratch_acquire() and path_scratch_release() are unchanged. - TlsGetValue() clears the thread's last error on every successful call. DeleteFileW, RemoveDirectoryW and both MoveFileWithProgress* ALT hooks release after the original call and after loq() has restored the error, so a failed call returned FALSE with GetLastError() == 0. lookup_get() touches no thread state. - Nothing runs at thread exit, so the TLS version leaked every dead thread's context and slots. Keyed by thread id, a thread handed a recycled id inherits the previous owner's slots, which bounds retained memory by distinct thread ids rather than by threads created. Not compiled. The pool code was checked in a POSIX harness against the real lookup.c with recycled thread ids. TAG=agy CONV=b3f21014-0e97-49b9-aa93-0601a28982fe
109e3b2 to
29bbf97
Compare
Normalising a path needs a
WIDE_STRING_LIMIT-sized buffer, and every hooked call took a fresh one from the allocator.NtQueryInformationFiletakes two,NtSetInformationFiletakes three,handle_new_filetakes two, and half of them arecallocs that zero all 64 KB first. On a sample doing sustained file or registry I/O that is tens of thousands of 64 KB allocate/zero/free cycles a second, none of which outlive the call that made them.This replaces them with a small per-thread pool.
What it does
Four slots per thread, allocated lazily. Past four live buffers, acquire falls back to
mallocand release frees it, so exhaustion degrades to the previous behaviour instead of failing. The slot claim is interlocked, because a structured exception handler can run on top of a hook on the same thread and would otherwise be able to claim a slot between the test and the set.Each thread's pool lives in a
lookup.htable keyed by thread id (LOOKUP_THREAD), as suggested in review. No other locking: an entry is only touched by the thread whose id it carries, andlookup_add()publishes it with a CAS.TlsGetValue()clears the thread's last error on every successful call.DeleteFileW,RemoveDirectoryWand bothMoveFileWithProgress*ALT hooks release after the original call and afterloq()has restored the error, so a failed call would return FALSE withGetLastError() == 0.lookup_get()touches no thread state.hook_info_t:hook_info()can return the shared statictmphookinfo, and five hooks memcpy the wholehook_info_tback after the original call.g_hook_infois keyed the same way, andenter_hook()already walks it on every hooked call.37 sites converted:
hook_file.clog.c%F/%Zhandlingmisc.cpipe.chook_process.c,hook_special.cWhy not zeroing the whole buffer
Only the first character is zeroed. That is what the
callocs were actually providing:path_from_object_attributes()(misc.c:1159) returns without writing anything when the object is NULL or the name failsour_isbadreadptr, and its callers then rely on the buffer reading as an empty string. Every consumer in these paths treats the buffer as a string and stops at the terminator — none read past it.Pre-existing bugs fixed on the way
hook_file.cnew_file_path_ascii()andnew_file_path_unicode()never freed their buffer at all. 32 KB and 64 KB leaked per dropped-file notification, which is one per new file the sample writes.pipe.c_pipe_sprintf()allocated before theif (s == NULL) return -1;in its%Fhandler, so a NULL argument leaked 64 KB.misc.cis_image_base_remapped()andprevent_module_reloading()dereferenced both of their allocations without checking either.log.cnow check for NULL before use.allocsizethere is bounded against the slot size by aC_ASSERT.Trade-off
This converts transient allocation into retained memory per thread id. A thread that nests four deep holds 256 KB, and nothing frees it when the thread exits: DllMain handles only process attach/detach, and the module is unlinked from the PEB, so
DLL_THREAD_DETACHnever reaches it. A thread handed a recycled id inherits the previous owner's slots, so the total is bounded by distinct thread ids rather than by threads created. Lazy slot allocation keeps the common one- or two-buffer case at 64–128 KB per id, but a sample running very many concurrent path-touching threads will hold more resident memory than before. A slot whose owner died holding it stays busy, so the next owner of that id reaches the heap fallback one slot sooner.I do not have a measurement to justify four rather than two — three is the deepest simultaneous use in the tree (
NtSetInformationFile), so four is that plus one.PATH_SCRATCH_SLOTSis a single constant if it is the wrong balance. Happy to drop it to three, or to cache only the first two slots and leave the rest heap-backed, if you would rather trade some of the win back for a lower ceiling.Deliberately not in this PR
18 of the converted sites do not check the result of the allocation and never did — they pass it straight into
path_from_handle()orensure_absolute_unicode_path(). This PR does not change that. It makes the failure rarer (a slot is only allocated once per thread) but it does not fix it.Adding those guards means 18 bespoke control-flow decisions about what a hook should do and what it should return when it cannot get a buffer, which is a different kind of change from a mechanical allocation swap, and not one I want to bundle into an unbuildable PR. It wants its own review.
Testing
Not compiled and not run. No MSVC available, and the
MSBuildworkflow isdisabled_manuallyat the repo level, so nothing in this series has been through a compiler. #212 fixes the workflow file but re-enabling it needs Settings → Actions.What was done instead:
path_scratch_acquire()the enclosing function is checked for a matchingpath_scratch_release()and for any surviving rawfree()of the same variable. All 37 balanced, no raw frees left. The one long span (NtDeviceIoControlFile, acquire at :722, release at :927) has nine intervening jumps, allgoto generic_log, which lands before the release.is_protected_objattr,_pipe_sprintf) are mutually exclusive paths.path_scratch_acquire()/path_scratch_release()are unchanged since) was extracted into a standalone harness, compiledgcc -std=c99 -Wall -Wextra -O2clean against POSIX stubs, and stress-tested: 16 threads × 20 000 iterations, nesting 1–7 deep to exercise the heap fallback, releasing in order and in reverse, verifying every live buffer is distinct and that a full-width sentinel written to one is never clobbered by another. PASS. Under ASan the only report is the retained slots themselves, which is the design.LOOKUP_THREADrevision was checked in a POSIX harness against the reallookup.cwith recycled thread ids: 2000 threads over 32 ids → 33 entries / 132 slots, all reachable (the TLS revision left 1604 slots unreachable after 400 threads). TSan's only reports arelookup_get()'s plain loads againstlookup_add()'s CAS, the same pattern asg_hook_info; none on the pool's own fields.None of that covers the MSVC build or a detonation.
Are these related?
No — this is self-contained and can be merged, reverted or deferred on its own. It uses upstream
lookup.has is. #217 (merged) madelookup_add()NULL-safe, so an allocation failure on a thread's first acquire falls back to the heap instead of faulting insidelookup_add(); nothing here needs it to build.Contrast with the
NDEBUGcase in #211, which genuinely is coupled: theassert()removals there are only correct because the same PR definesNDEBUG.Textual conflicts to expect (trial merges against
df8fc4dand every open branch in the series): onlyhook_process.cwith #209, on adjacent lines — #209 rewrites aLOQ_ntstatuscall and this PR replaces thefree(fname)after it. Keep both. The overlaps listed in earlier revisions of this description now merge cleanly.Series
#206, #207, #208, #209, #210, #211, #212, #213, #214, #215, #164, this one, #217 (merged).