Skip to content

Stop allocating 64 KB per path on the hooked call paths - #216

Merged
kevoreilly merged 2 commits into
kevoreilly:capemonfrom
doomedraven:perf/path-scratch-pool
Sep 28, 2026
Merged

kevoreilly merged 2 commits into
kevoreilly:capemonfrom
doomedraven:perf/path-scratch-pool

Conversation

@doomedraven

@doomedraven doomedraven commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Normalising a path needs a WIDE_STRING_LIMIT-sized buffer, and every hooked call took a fresh one from the allocator. NtQueryInformationFile takes two, NtSetInformationFile takes three, handle_new_file takes two, and half of them are callocs 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

void *path_scratch_acquire(void);   // 64 KB, first character zeroed
void path_scratch_release(void *);  // NULL-safe

Four slots per thread, allocated lazily. Past four live buffers, acquire falls back to malloc and 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.h table 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, and lookup_add() publishes it with a CAS.

  • Not TLS: 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 would return FALSE with GetLastError() == 0. lookup_get() touches no thread state.
  • Not hook_info_t: hook_info() can return the shared static tmphookinfo, and five hooks memcpy the whole hook_info_t back after the original call.
  • Cost: one list walk per acquire and per release instead of a TEB read. g_hook_info is keyed the same way, and enter_hook() already walks it on every hooked call.

37 sites converted:

file sites
hook_file.c 24
log.c 6 registry-path buffers + 2 in %F/%Z handling
misc.c 2
pipe.c 2
hook_process.c, hook_special.c 1 each

Why 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 fails our_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.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, which is one per new file the sample writes.
  • pipe.c _pipe_sprintf() allocated before the if (s == NULL) return -1; 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 check for NULL before use. allocsize there is bounded against the slot size by a C_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_DETACH never 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_SLOTS is 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() or ensure_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 MSBuild workflow is disabled_manually at 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:

  • Pairing audited mechanically: for every path_scratch_acquire() the enclosing function is checked for a matching path_scratch_release() and for any surviving raw free() 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, all goto generic_log, which lands before the release.
  • Checked that no converted buffer is stored anywhere outliving its release, and that the two-free sites (is_protected_objattr, _pipe_sprintf) are mutually exclusive paths.
  • The pool itself (first revision, TLS-backed; path_scratch_acquire()/path_scratch_release() are unchanged since) was extracted into a standalone harness, compiled gcc -std=c99 -Wall -Wextra -O2 clean 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.
  • The LOOKUP_THREAD revision was checked in a POSIX harness against the real lookup.c with 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 are lookup_get()'s plain loads against lookup_add()'s CAS, the same pattern as g_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.h as is. #217 (merged) made lookup_add() NULL-safe, so an allocation failure on a thread's first acquire falls back to the heap instead of faulting inside lookup_add(); nothing here needs it to build.

Contrast with the NDEBUG case in #211, which genuinely is coupled: the assert() removals there are only correct because the same PR defines NDEBUG.

Textual conflicts to expect (trial merges against df8fc4d and every open branch in the series): only hook_process.c with #209, on adjacent lines — #209 rewrites a LOQ_ntstatus call and this PR replaces the free(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).

@kevoreilly

Copy link
Copy Markdown
Owner

I really like this idea of having a path_scratch_acquire() function to replace the mallocs but I would like to explore whether the thread-based scratch buffer can be achieved without using the Tls apis (e.g. TlsAlloc) but instead use the internal lookup functions (lookup.h) keyed with the thread id instead.

@doomedraven

Copy link
Copy Markdown
Contributor Author

Yes, LOOKUP_THREAD drops straight in. Only scratch_context() changes; acquire/release are untouched:

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:

  1. 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 returns FALSE with GetLastError() == 0. lookup_get() touches no thread state.
  2. Nothing runs at thread exit (DllMain only handles process attach/detach, and the module is unlinked from the PEB), so every dead thread's TLS context and slots leak. Keyed by TID, a thread handed a recycled id inherits the previous owner's slots, so retained memory is bounded by distinct TIDs rather than by threads created. A slot whose owner died holding it just stays busy.

Kept in its own table rather than hook_info_t: hook_info() can return the static tmphookinfo, and five hooks memcpy the whole hook_info_t back after the original call.

Cost is one list walk per acquire and per release instead of a TEB read, over a list no longer than g_hook_info, which enter_hook() already walks on every hooked call.

Two things in lookup.c, independent of this PR, are split out into #217: lookup_add() doesn't check calloc(), so OOM on a thread's first acquire would fault in lookup_add instead of falling back to the heap; and on x64 entry_t.data sits at offset 20, so every pointer in every payload is 4 mod 8 (UBSan flags the path_scratch_t accesses as misaligned). #217 adds the NULL check and widens entry_t.size to ULONG_PTR, which moves data to offset 24 without changing sizeof(entry_t). Neither PR depends on the other.

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 lookup_get's plain loads against lookup_add's CAS, the same pattern as g_hook_info, none on the pool's own fields.

Happy to push this to the branch, and update the trade-off section of the description, if you're good with it.

@doomedraven
doomedraven force-pushed the perf/path-scratch-pool branch from c08cdbf to cf37041 Compare September 25, 2026 15:11
doomedraven added a commit to doomedraven/capemon that referenced this pull request Sep 25, 2026
…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
@doomedraven
doomedraven marked this pull request as ready for review September 25, 2026 17:21
doomedraven added a commit to doomedraven/capemon that referenced this pull request Sep 26, 2026
…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
@doomedraven
doomedraven force-pushed the perf/path-scratch-pool branch from 109e3b2 to 29bbf97 Compare September 26, 2026 17:11
@kevoreilly
kevoreilly merged commit 4ad1219 into kevoreilly:capemon Sep 28, 2026
@doomedraven
doomedraven deleted the perf/path-scratch-pool branch September 28, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants