re: @hebasto #36011 (comment)
I can't blame this PR's changes because they all look correct to me. However, they seem to have revealed a pre-existing race.
Findings from Claude with a suggestion at the end on how to obtain a backtrace (MALLOC_OPTIONS=S):
Thanks for the report. I looked at the Nov 5 output and at how OpenBSD's malloc produces that message, and I think two things are worth knowing before chasing this as a race.
The check that fired is sampled, so a deterministic bug can look intermittent. write to free mem 0xb5935add000[2568..2575]@4096 comes from validate_junk() in OpenBSD's malloc.c. At the default junk level it writes and later re-checks only four 8-byte words per freed page, at an offset chosen randomly per process (junk_loc = arc4random()). So an 8-byte stale write at a fixed offset is caught in roughly 1 run out of 128, a 64-byte write in about 1 out of 16. The check also runs late: a freed page-sized region sits in a 64-slot cache and is validated only when it is randomly evicted by a later free(), so the write could have happened well before the ~InitExecutor lines, anywhere after the region was freed.
The freed region is a 2049–4096 byte allocation, and probably not one of our classes. @4096 means a page-sized region, not a small chunk. OpenBSD places allocations between half a page and a page at the end of the page, and the message prints the page address, so page offset 2568 is roughly size − 1528 into the object. I dumped record layouts for the node, wallet and test TUs: nothing we allocate on the heap is in that size range except ChainstateManager (and that is too small to reach the offset). A container buffer, a libc++ std::deque block (exactly 4096 bytes) or a Qt-internal object fits better.
What this PR changes at runtime is only when a callback object is destroyed: at disconnect(), or at the end of an in-flight invocation on the invoking thread, instead of when the signal is destroyed. Every callback connected to a btcsignals signal in the tree owns nothing beyond its own captures (this or other raw pointers, a wrapped std::function, one std::string in init.cpp), so destroying it earlier frees a few small chunks and touches nothing else; the only new writes go into refcounted holder objects. It can perturb heap layout and timing (libc++'s atomic_load fallback takes a lock from a global 16-mutex pool), which is consistent with your "revealed a pre-existing race" reading, but it is not a plausible writer itself.
Since the regular ASan CI job runs test_bitcoin-qt too (and 30 local ASan/UBSan runs on Linux were clean), this looks like either an OpenBSD/libc++/static-Qt-specific problem or a rare race. The quickest way to find the writer is to make OpenBSD fault at the write instead of sampling it later:
ulimit -c unlimited
MALLOC_OPTIONS=S ./build/bin/test_bitcoin-qt
S turns on full junking and validation and disables the page cache, so a stale write to a freed page-sized region SIGSEGVs at the writing instruction (U alone, which unmaps freed pages, has the same effect for this size class). The core in egdb then gives the stack of the writer; if it is a race, a loop of 20–50 runs should hit it. I'm happy to dig further if you can get that backtrace.