nanopb: pluggable logging strategy (BSON default, protobuf opt-in) - #164
doomedraven wants to merge 3 commits into
Conversation
|
This is great, thank you! Much appreciated. I'm probably missing something super obvious, but I can't figure it out. I noticed that in the definition of Maybe it's worth adding a comment explaining |
54eedce to
2a735de
Compare
|
added comment in code |
ohhhh I see. Thank you! |
|
very nice ❤️ I'm low on time today to look into this, so will pick up again tomorrow, but just to note it's currently not compiling: |
|
The Root Cause & Technical Fixes Applied:
1 g_active_serializer->append_finish_array();
1 unsigned int our_len = (unsigned int)(g_active_serializer->get_size() - compare_offset); |
0181748 to
c3925c6
Compare
Test coverage: - BSON serialization (default mode) - Protobuf serialization (opt-in mode) - Runtime serializer switching - Thread-local serializer isolation (16 threads) - Concurrent mixed serializers (8 threads, BSON + Protobuf) - NULL safety in serializer access Verifies: 1. Strategy pattern implementation 2. Thread-safe serializer switching 3. Independent per-thread serializer contexts 4. Graceful fallback on NULL 5. No interference between BSON and Protobuf modes Run with: cd tests && make test-pluggable-serialization.exe && ./test-pluggable-serialization.exe
doomedraven
left a comment
There was a problem hiding this comment.
Thank you! I have successfully submitted a structural refactor to the backend code merging the protobuf state via standard TlsAlloc() memory architecture mapping instead of utilizing unallocated __declspec(thread) structures to fix the structural crash bugs observed in native DLL imports. Please review the updated branch.
d40f720 to
8222e3d
Compare
|
Force-pushed. History rewritten, so the earlier review comments no longer line up with the diff — sorry about that. Summary of what moved: Split out. The thread-local logging context is now #215, on its own. This branch is stacked on it and contains only the serializer vtable and the nanopb backend. #162, which was the first two commits here, is closed. Dropped the TEB slot. Dropped Positional initializers for both serializer vtables. Dropped the Restored the delay-load settings. The previous revision's @rkoumis the Still not compiled — the |
…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
8222e3d to
430e54b
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
430e54b to
5a8aac2
Compare
|
Checked against latest upstream ( Audit of synchronization primitives:
Fixes:
|
Stacked on #215, which owns the per-thread logging context. Merge that first; this branch contains #215's two commits, so the diff GitHub shows here will collapse to just the serializer work once #215 lands.
TLDR
log-format = 0(default) — BSON, byte-for-byte the same output as today.log-format = 1— Protocol Buffers, experimental.log.c's formatting loops no longer call BSON directly. They go through alog_serializer_tvtable thatlog_init()selects once, so the wire format becomes a runtime choice instead of a compile-time one. Nothing downstream changes unlesslog-formatis set.1. Impact on custom agents and result servers
Only if you opt in. #118 removed BSON outright in favour of nanopb, which would have broken every result server and analysis agent expecting BSON frames. Here BSON stays the default and the strategy is selected at
log_init().The schema question is real either way: BSON is self-describing, so a hook can append arbitrary keys at runtime. Protocol Buffers need a compile-time
schema.proto, so a new hooked field means recompiling and deploying capemon and the host-side decoder together.2. Defects fixed in #118's nanopb wrapper
Wide-string use-after-free.
log_wstringconverted to UTF-8 on the heap, registered the pointer with the nanopb callback, then freed it immediately. nanopb only serializes atprotobuf_finish, at the end ofloq. Reading the freed block was an access violation. Fixed with a per-thread bump-allocated scratch pad inprotobuf_context_t; strings and binary buffers are copied into it and stay alive untilprotobuf_finish.Silent payload drops. The nanopb output stream was a static 4 KB array, so any log over 4 KB — decrypted payloads, network buffers — failed
pb_encodeand vanished. Buffer is now 64 KB, in the per-thread context rather than on the stack.3. What changed in this revision
Restacked onto #215's
LOOKUP_THREADrevision (5cf5060) and currentcapemon(df8fc4d, which includes a65ba12's wowmon changes tolog.c):log_context_tis Give each thread its own BSON buffer and stop holding g_mutex across all of loq() #215'sLOOKUP_THREADentry (alookup_tkeyed by thread id), per Kev's comment on #216, instead of aTlsAllocslot. This branch adds one field to it,pb_ctx.active_serializer. The format is process-wide:log_init()setsg_default_serializerbefore any hook can log, and a stream mixing BSON and protobuf frames is unsupported.g_active_serializeris now that pointer, andlog_init()no longer patches the calling thread's context.get_log_context()itself, on top of thelog_*helper's own lookup. Passing the context through the vtable would make it one walk, but changes every callback signature in both backends; not done here.BSON_ID(index)in both the explanation frame and the record, whichever serializer is active. The wowmon handle mapping inlog_init()stays, after the serializer selection.g_bson/g_istr/g_active_serializerdeclaresctxbefore first use,loq()assigns it before serializing, nothing afterexit:touches it, and braces balance. Outsidelog.c, every file's diff is identical to the previous revision except one comment inlog_serializer.h.Earlier revision's problems, still fixed:
The TEB
NtTib.ArbitraryUserPointerslot is not free. ntdll's loader parks theFullDllNamepointer there across itsNtMapViewOfSectioncall so the debugger can see the module being mapped, and capemon hooksNtMapViewOfSection(hooks.c:127,:975,:1065,:1358):That is memory corruption on every module load. The context now comes from #215.
TlsThreadCleanup()never ran. It was wired toDLL_THREAD_DETACH, butcapemon.c:632callshide_module_from_peb()duringDLL_PROCESS_ATTACHandmisc.c:1072-1095unlinks the module from all three loader lists. The loader walks those to dispatch thread notifications, so DllMain is never entered again. Both the hook and the function are gone; contexts are per-thread and die with the process.All three
g_mutexacquisitions go throughloq_lock(), which keeps the fast-path-then-bounded-spin shape from ada31ca rather than 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, which is what
capemon.vcxprojtargets. The previous revision would not have compiled on VS2017 even after thefinish/append_finishfix.The
.github/workflows/changes are dropped. #212 owns CI. Thepr-build-test.ymlcopy carried on this branch targetedwindows-2019, which is no longer a runner image — every run on it queued for 24h and was cancelled without being scheduled.capemon.vcxprojnow carries only the fiveClCompileand sevenClIncludeadditions; the delay-load settings from 321a8e0 are preserved.4. Protobuf backend status
Experimental and lossy.
schema.protocannot represent capemon's call model yet — heterogeneous indexed arguments, nested%aarrays, the callerCaddress, the thread id — and no host-side parser consumes the output.log_init()emits aCRITICAL:notice over the pipe whenlog-format=1is set.announce_netlog()still announcesBSON; a real protobuf transport needs its own header and a matching reader.The per-index "explain" frame is BSON-only and is skipped in protobuf mode, so the stream is never a mix of BSON and protobuf frames. The
lastlogdedup is likewise BSON-only — it depends on byte-comparable frames with arepeatedcounter at a fixed offset.5. 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. Re-enabling needs Settings → Actions; #212 fixes the workflow file but cannot flip that switch.tests/test-pluggable-serialization.cis included but has not been built.6. Are these related?
This one genuinely is coupled: it cannot be reviewed or merged without #215, because the BSON buffer and the protobuf scratch it serializes into live in the context #215 introduces. That is the opposite of the independent batch (#206–#210, #213, #214), and similar in kind to the
NDEBUGcase in #211 where the assert removals are only correct because the same PR definesNDEBUG.#162 is closed — it was the first two commits of this branch, and it could not compile (GNU statement expressions in
log.c).Known textual conflicts in
log.c, both trivial rebases: #208 (logtbl_explainedbounds — this branch also readslogtbl_explained[index]with an unbounded index) and #209 (log_string/log_wstring).Series
#206, #207, #208, #209, #210, #211, #212, #213, #214, #215, this one, #216, #217 (merged).