Skip to content

fix(base): announce the EBR epoch before begin()'s first node dereference - #17

Merged
Force67 merged 1 commit into
devel5from
fix/lfomap-unguarded-begin-preamble
Sep 19, 2026
Merged

Force67 merged 1 commit into
devel5from
fix/lfomap-unguarded-begin-preamble

Conversation

@Force67

@Force67 Force67 commented Sep 18, 2026

Copy link
Copy Markdown
Owner

The bug

LockFreeOrderedHashMap::begin() walked the bucket chains looking for the first
live node before any ebr::Guard announced — the guard is only acquired
inside the iterator's constructor, after the preamble:

BucketIterator begin() const {
    for (mem_size i = 0; i < bucketCount; ++i) {
      Node* node = buckets[i].load(base::memory_order_acquire);
      while (node && node->is_deleted.load(base::memory_order_acquire))
        node = node->bucketNext.load(base::memory_order_acquire);   // unguarded
      if (node) return BucketIterator(this, i, node);               // guard announces here
    }
    return end();
}

The nodes that preamble steps over are deleted-but-not-yet-swept — exactly the
nodes a concurrent collect_garbage() pass unlinks, retires and frees. Under
sustained insert/remove churn the preamble can step into freed memory and hand
the traversal a garbage start node.

order_begin() had the same load-before-announce gap on a smaller window.

The field crash this explains

zetanet's reliable-transport retry scan range-fors the ack map on the outgoing
thread (ZPacketQueue::ProcessOutgoingPackets). Under minutes of gameplay
traffic (constant reliable packets + ack churn) it crashed with the traversal
dereferencing a near-null node:

segfault at 0x... ip ... in recreation
...
#0 tx::network::ZPacketQueue::ProcessOutgoingPackets at z_packet_queues.cc:314
   const i32 packet_age = static_cast<i32>(now - packet.last_send_time);

Core dumps confirm the faulting deref reads a chain node at ~null.

The fix

Announce the epoch before the first node load in begin() /
order_begin(), and hand the live guard to the iterator through a new
IteratorBase constructor, so the pin is continuous from before the first
dereference until the iterator dies. Verified:

  • the full existing lock_free_ordered_map_test.cc suite passes under ASan;
  • a stress reproducer (readers range-for the map while writer threads
    insert/remove churn) that ASan-crashes in the preamble on the parent commit
    no longer crashes there post-fix.

Known follow-up (not addressed here)

Under duplicate-heavy insert churn the container still exposes a deeper
iterator-protocol flaw (readers can observe a null node past the != check,
with or without the preamble fix, even with GC freeing disabled — so it is
structural, not lifetime-related). A reproducer and forensics are in the
follow-up issue; fixing it needs a proper analysis of the iterator protocol
and should not ride along with this strictly-correct change.


Rebased onto devel5 (2026-09-19)

The branch was cut from e68c2b8 and devel5 had moved 25 commits since, which
rewrote most of this header's formatting — hence the conflict. Rebased; both
conflicting hunks were the same lines reflowed, so the fix ported unchanged and
only its placement moved. begin() still drops the unguarded skip-deleted
preamble and leaves that walk to the iterator's own skip_deleted(), which now
runs inside the guard.

Re-verified on the rebased commit:

  • base_unittests — 972 tests, all pass;
  • the 14 LockFreeOrderedHashMap tests pass again under -fsanitize=address,undefined;
  • zetanet builds against it in its equilibrium configuration (ZNET_USE_STL=OFF)
    and its handshake + mp integration tests pass, including the stressed
    chaos-soak profile (drop/reorder/jitter 5/5/5, seeds 1-3).

…ence

begin() walked the bucket chains looking for the first live node before
the iterator's ebr::Guard announced — exactly the nodes GC sweeps,
retires and frees. Under concurrent insert/remove churn the preamble
could step into freed memory and hand the traversal a garbage start
node; callers that range-for the map (zetanet's packet-ack retry scan)
then fault on a near-null node.

Announce in begin()/order_begin() before the first load and hand the
live guard to the iterator through a new IteratorBase constructor, so
the pin is continuous from before the first dereference until the
iterator dies. No behavior change for single-threaded use.
@Force67
Force67 force-pushed the fix/lfomap-unguarded-begin-preamble branch from efb03b7 to 7e14fde Compare September 19, 2026 08:12
@Force67
Force67 merged commit c748885 into devel5 Sep 19, 2026
6 checks passed
Force67 added a commit to Force67/zetanet that referenced this pull request Sep 19, 2026
…n begin (#4)

Picks up Force67/equilibrium#17, which fixes the use-after-free behind the
ProcessOutgoingPackets crash: LockFreeOrderedHashMap::begin() dereferenced
deleted-but-unswept nodes before the iterator's EBR guard announced, so a
concurrent collect_garbage() could free the chain under the ack-map traversal.

The pin was 25 commits behind devel5, so this advances vendor/equilibrium by 26
commits rather than one. Beyond the fix, the notable ones are the STL and C
runtime removal, the three security bug bash passes, and the container
value-semantics work.

Verified with -DZNET_USE_STL=OFF: the library and the samples build, and
handshake_integration, mp_integration and the stressed chaos-soak profile
(drop/reorder/jitter 5/5/5, seeds 1-3) pass.
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.

1 participant