Give each thread its own BSON buffer and stop holding g_mutex across all of loq() - #215
Draft
doomedraven wants to merge 2 commits into
Draft
doomedraven wants to merge 2 commits into
doomedraven wants to merge 2 commits into
Conversation
doomedraven
added a commit
to doomedraven/capemon
that referenced
this pull request
Sep 16, 2026
…mat=1 Rebased onto kevoreilly#215, which owns the thread-local 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 held in the per-thread log context, 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. Relative to the previous revision of this branch: * The context is reached through dynamic TLS instead of the TEB `NtTib.ArbitraryUserPointer` slot. That slot is not free: ntdll's loader parks the `FullDllName` pointer there across its `NtMapViewOfSection` call, and capemon hooks `NtMapViewOfSection`, so a hook firing during a module load read a `PWSTR` out of the slot and wrote serializer state over the loader's string. * `TlsThreadCleanup()` and the `DLL_THREAD_DETACH` hook in capemon.c are gone. `hide_module_from_peb()` unlinks the module from the loader lists during `DLL_PROCESS_ATTACH`, so DllMain is never entered again and that cleanup never ran. * The `lookup_t` keyed by thread id is gone with it; `TlsGetValue` answers the same question without a list walk or a thread-id lookup. * 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, and the `pr-build-test.yml` copy that was carried here targeted `windows-2019`, which no longer exists as a runner image. 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. TAG=agy CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
This was referenced Sep 16, 2026
…all of loq()
`g_bson` and `g_istr` were process-wide statics, so `loq()` had to hold
`g_mutex` from the moment it was entered until the record was flushed. Every
hooked API call on every thread serialized against every other one, and the
lock covered the expensive part (format parsing, string conversion, BSON
building) as well as the cheap part.
This moves the serialization state into a per-thread context and narrows the
lock to the two regions that actually touch shared state:
* region A - `logtbl_explained`, `last_api_logged`, `lastlog` reset, and the
one-time schema "explanation" record;
* region B - the flush into the output buffer and the `lastlog` dedup slot.
Everything in between (the sizing pass, the emit pass, `log_string`,
`log_wstring`, `log_buffer`, ...) now runs unlocked against thread-local
memory.
How the context is reached, and three things this deliberately does not do:
* Not `__declspec(thread)`. Static TLS is resolved by the loader. It happens
to work when capemon is injected with LoadLibrary, which is the normal
path, but `ReflectiveInjectDllViaThread()` in loader/loader/Loader.c maps
the image by hand and nothing processes the TLS directory there.
* Not the TEB `NtTib.ArbitraryUserPointer` slot. It is not free: ntdll's
loader parks the `FullDllName` pointer there across its
`NtMapViewOfSection` call so the debugger can see the module name.
capemon hooks `NtMapViewOfSection` (hooks.c:127 and three more places), so
a hook firing during a module load would read a `PWSTR` out of that slot
and write BSON state over the loader's string.
* Not a `DLL_THREAD_DETACH` destructor. capemon.c calls
`hide_module_from_peb()` during `DLL_PROCESS_ATTACH`, and
misc.c:1072-1095 unlinks the module from InLoadOrderModuleList,
InInitializationOrderModuleList, InMemoryOrderModuleList and the hash
table. The loader walks those lists to dispatch thread notifications, so
DllMain is never entered again and a TLS callback would never fire.
The context is therefore allocated once per logging thread and never
reclaimed. That is intentional: it is about 40 bytes, the BSON payload itself
is still allocated and released per call by `bson_init`/`bson_destroy`, and
freeing contexts at teardown would race with threads still inside `loq()`.
The accessor macros are plain parenthesised expressions. GNU statement
expressions (`({ ... })`) are not accepted by cl.exe.
The bounded-spin lock acquisition from ada31ca is preserved verbatim, just
factored into `loq_lock()` so both regions use the same shape: one cheap
`TryEnterCriticalSection`, then up to 100 `SwitchToThread` retries, then drop
the record rather than stall a hooked API.
TAG=agy
CONV=b3280e17-abe0-4fed-ad0b-c2e7f65da90f
Replace the TlsAlloc/TlsGetValue/TlsSetValue context with LOOKUP_THREAD (lookup.h, 8f40674), the idiom added to replace per-thread state reached through the Tls APIs. It is the same thread-id-keyed scheme hook_info() uses for g_hook_info. g_bson and g_istr now expand to a local ctx. loq() and each serializing helper fetch it once, so a record costs one table walk in loq() plus one per helper call, rather than one TLS read per field access. Contexts are never freed. A thread given a dead thread's id inherits its context, which is safe because loq() starts every record with bson_init() (zeroes the whole bson struct). Retained memory is bounded by distinct thread ids. sizeof(log_context_t) is 312 bytes on x64 and 168 on x86; the old comment's "40-something bytes" missed the bson struct's 32-entry size_t stack. With kevoreilly#217, allocation failure on a thread's first log returns NULL and loq() drops the record, as before; without it, lookup_add() faults on the NULL calloc() result. TAG=agy CONV=b3f21014-0e97-49b9-aa93-0601a28982fe
doomedraven
force-pushed
the
fix/tls-logging-context
branch
from
September 25, 2026 16:40
a0ac33a to
5cf5060
Compare
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
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
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #162. Prerequisite for #164.
g_bsonandg_istrare process-wide statics, soloq()has to holdg_mutexfrom entry until the record is flushed. Every hooked API call on every thread serializes against every other one, and the lock covers the expensive part (format parsing, string conversion, BSON building) as well as the cheap part.This gives each thread its own serialization context and narrows the lock to the two regions that actually touch shared state.
What changes
g_bson/g_istrlookup.htable keyed by thread id (LOOKUP_THREAD)g_mutexholdloq()logtbl_explained,last_api_logged,lastlogreset, one-time schema record) and region B (output buffer +lastlogdedup)log_string,log_wstring,log_bufferOne file,
log.c, +93/−20.Why a thread-id lookup table
The first revision used
TlsAlloc/TlsGetValue. After Kev's comment on #216 it usesLOOKUP_THREADfromlookup.h(8f40674), which was added to replace exactly this kind of per-thread state. It is the same thread-id-keyed schemehook_info()uses forg_hook_info.Not
TlsAlloc/TlsGetValue. It works, but it uses up a process TLS index, andTlsGetValue()clears the thread's last error on every successful call. Every call in the first revision sat insideloq()'sget_lasterrors/set_lasterrorswindow, so that was not a bug here, but it is a trap for the next caller.Not
__declspec(thread). Static TLS is resolved by the loader. It works when capemon is injected with LoadLibrary, which is the normal path —hook_com.c:43andhook_tls.c:44-48already use it oncapemontoday and detonations are fine. ButReflectiveInjectDllViaThread()inloader/loader/Loader.c:646maps the image by hand and nothing processes the TLS directory there.Not the TEB
NtTib.ArbitraryUserPointerslot (fs:[0x14]/gs:[0x28]). It is not free. ntdll's loader parks theFullDllNamepointer there across itsNtMapViewOfSectioncall so the debugger can see the module name being mapped. capemon hooksNtMapViewOfSection(hooks.c:127,:975,:1065,:1358) andLdrLoadDll(hooks.c:98and five more). Sequence:That is memory corruption on every module load.
Not a
DLL_THREAD_DETACHdestructor.capemon.c:632callshide_module_from_peb()duringDLL_PROCESS_ATTACH, andmisc.c:1072-1095unlinks the module fromInLoadOrderModuleList,InInitializationOrderModuleList,InMemoryOrderModuleListand the hash table. The loader walks those lists to dispatch thread notifications, so DllMain is never entered again and a TLS callback would never fire either.Contexts are therefore never freed. A thread that is given a dead thread's id inherits that context, so retained memory is bounded by the number of distinct thread ids that have logged, not by the number of threads created. That is safe because
loq()starts every record withbson_init(), which zeroes the wholebsonstruct; a record the dead thread left half-built only leaks its buffer. Each context is 312 bytes on x64 and 168 on x86, because thebsonstruct carries a 32-entrysize_tstack; the first revision's "~40 bytes" was wrong. The BSON payload is still allocated and released per call bybson_init/bson_destroy, and freeing contexts at teardown would race with threads still insideloq().g_bson/g_istrexpand toctx->bson_obj/ctx->istr_buf, wherectxis a local thatloq()and each serializing helper fetch once. A record therefore costs one table walk inloq()plus one per helper call (roughly one per logged argument), instead of one TLS read per field access. The list holds one entry per thread id that has logged;hook_info()already walksg_hook_info, keyed the same way, on every hooked call. A function that usesg_bsonwithout actxin scope fails to compile.Behaviour preserved
The bounded-spin acquisition added in ada31ca is kept verbatim, factored into
loq_lock()so both regions use the same shape: one cheapTryEnterCriticalSection, then up to 100SwitchToThreadretries, then drop the record rather than stall a hooked API. Both regions are exit-balanced; a failed region-B acquisition doesbson_destroy()before bailing.The one observable difference under concurrency: because the lock is released between the explanation record and the flush, another thread's record can land in the
lastlogdedup slot in between.lastlogis already a single global slot shared across threads, so which record occupies it was already non-deterministic — this widens the window, it does not create the interleaving.Testing
Not compiled and not run. There is no MSVC on the machine this was written on, and the
MSBuildworkflow isdisabled_manuallyat the repo level, so no PR in this series has been through a compiler. Re-enabling it needs Settings → Actions; #212 fixes the workflow file but cannot flip that switch.The
TlsAllocharness from the first revision no longer applies. The context code is now a singleLOOKUP_THREADcall, and the table mechanics are the ones exercised by the harness described on #216 (recycled thread ids, ASan/UBSan/TSan). For this revision, checked by inspection with comments stripped: every function that usesg_bson/g_istrdeclaresctxbefore its first use,loq()assignsctxbefore it serializes anything, and nothing afterexit:touches it. The MSVC build, theloq()restructuring and a detonation run are still unverified.Are these related?
No, in the sense that matters for review: this is self-contained and can be merged, reverted or deferred on its own.
Contrast with the NDEBUG case in #211, which genuinely is coupled — the
assert()removals there are only correct because the same PR definesNDEBUG, and splitting them would leave the tree in a state where the asserts have side effects that vanish.No textual conflicts with the rest of the series:
git merge-treeof this branch against each of #206–#214 and #216 is clean, and thectxcheck above passes on each mergedlog.c.#217 is merged (
df8fc4d). With it, allocation failure on a thread's first log returns NULL andloq()drops the record, as theTlsAllocrevision did, and lookup payloads, and so this context, are pointer-aligned on x64.Rebased onto current
capemon(df8fc4d: a65ba12 plus the #217 merge). The rebase did not change either commit;git range-diffshows=for both.Why a new PR rather than a fix on #162
#162 cannot compile:
log.c:88-89on that branch definesg_bsonwith a GNU statement expression, which cl.exe rejects. It also still declaresstatic __declspec(thread) thread_log_context_t* g_tls_ctx_cache, the exact construct its own description argues is unsafe, and it hooksDLL_THREAD_DETACHfor cleanup that never runs. Its branch does not rebase onto currentcapemoncleanly. Rebuilding it as a single clean commit was less work than untangling it.#164 is restacked onto this revision (
5cf5060).Series
#206, #207, #208, #209, #210, #211, #212, #213, #214, this one, #164, #216, #217 (merged).
Draft, as with the rest of the series — none of it has been through a compiler yet.