Skip to content

refactor: std container migration stage 2 — the protected surface (#60) - #72

Open
Segfaultd wants to merge 26 commits into
release/v0.23.0from
refactor/60-std-migration-stage2
Open

Segfaultd wants to merge 26 commits into
release/v0.23.0from
refactor/60-std-migration-stage2

Conversation

@Segfaultd

Copy link
Copy Markdown
Member

Stage 2 of the DataStructures:: → std container migration (#60): every container in a protected or private section, plus the locals and internal signatures that move with them. No public API changes.

Based on release/v0.23.0, following PR #71 (stage 1).

Subsystems migrated

file containers
statistics_history OrderedList → sorted vector, Hash → unordered_map
rpc4_plugin 2 × Hash → unordered_map, OrderedList → sorted vector, Queue → deque
replica_manager3 working lists → vector (swap-remove preserved)
team_manager, team_balancer registries → vector; both now free of DataStructures::
message_filter OrderedList → sorted vector, Hash → unordered_map
tcp_interface 6 members → vector/deque
ds_table protected query helpers → vector
reliability_layer 11 protected members
peer 8 members → vector/deque
thread_pool 3 queues → deque; now free of DataStructures::
packetized_tcp Queue×5 → deque, Map → std::map; now free of stage-2 types

Protected/private references across the public headers: 133 → 80.

Tests

12 new files, 132 cases. Eight of these subsystems had no tests at all. Tests land before the migration they cover, and every migration is mutation-tested — mutations listed per commit, including the ones that survived and why.

Bugs found and fixed

Nine, each with a test that fails against the old code.

where what
rpc4_plugin UnregisterLocalCallback returned false after a successful removal
statistics_history ~TrackedObject iterated an empty local list, leaking every queue it owned
thread_pool Clear() returned holding runThreadsMutex — the next lock, including the destructor's, deadlocked
signaled_event SetEvent/WaitOnEvent used the condition variable before InitEvent created it. Undefined; on glibc it hangs forever. Reachable via ThreadPool::AddInput
tcp_interface accept() failure (−1) read as success and stored as a client socket → FD_SET(-1, …). Aborts under Release fortification, corrupts silently in Debug
tcp_interface a connection accepted with all slots full was leaked; the recovery branch was unreachable and would have closed the listener
tcp_interface Stop() closed descriptor 0 once per unused slot (stdin), then the next socket in the process got number 0 and was read as "no socket"
tcp_interface two use-after-frees on the connect path, and getaddrinfo's list leaked on every attempt
packetized_tcp Stop() left freed pointers queued (double free, dangling Receive); the destructor leaked the queue; ID_DOWNLOAD_PROGRESS carried another packet's bitSize

One I introduced and fixed in this branch: commit 35f8f0c2 replaced GetIndexOf with std::find arithmetic in tcp_interface but kept the old (unsigned)-1 comparison, where std::find(...) - begin() yields size(). Four lookups tested the wrong thing, two of them inverted — DetachPlugin on an unattached plugin indexed past the end and swap-removed a live plugin. Fixed in 09ca549b, with an audit of every index lookup this branch converted; no raw std::find(...) - begin() remains in Source/.

Not migrated, and why

  • pluginListTS / pluginListNTS in peer, and the CallPluginCallbacks parameter — handed straight to ReliabilityLayer::Update and friends, whose signatures are public. Same shape as Table::columns behind the public GetColumns(). Public-API stage.
  • cloud_server (17), fully_connected_mesh2 (7) and the smaller plugins — need multi-peer harnesses that do not exist yet.
  • MemoryPool, ThreadsafeAllocatingQueue, ByteQueue — not stage-2 types.

Also

  • docs/advanced/custom-memory-management.rst promised allocator coverage the containers never had. _USE_RAK_MEMORY_OVERRIDE defaults to 0, so OP_NEW/OP_NEW_ARRAY are plain new and the std containers use std::allocator; corrected, including that this is not a regression, since DataStructures::List already allocated through OP_NEW_ARRAY. Dropped RAKNET_MEMORY_TRACKING, which does not exist.
  • CLAUDE.md's Docker recipe copied the source tree into the container. With ~780MB of local build output that copy silently truncated a source file — gcc reporting "unterminated #if" on a file that compiles everywhere. Now builds from the read-only mount, with the sanitizer recipe added.

Verification

gate result
macOS full suite 482 / 482
macOS Debug build + unit 390 / 390
Linux Docker Debug 482 / 482
Linux Docker Release 482 / 482
Linux ASAN + LeakSanitizer clean on the new suites

Release was not green at first: it aborted 11 tests while Debug passed, which is how the accept() bug surfaced. New integration tests were repeat-run for flakiness (10–20×); one flake in my own port helper was root-caused to ephemeral-port reuse against TCPInterface's lack of SO_REUSEADDR and fixed by retrying port selection, not by retrying an assertion.

Closes #60 for the protected/private surface.

https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c

…y migrate

StatisticsHistory keeps two container-backed stores -- `objects`, a
DataStructures::OrderedList keyed by objectId, and per-object `dataQueues`, a
DataStructures::Hash of key -> sample queue. Stage 2 of #60 moves both to the
standard library, and the only existing suite covers the plugin's sampling
clock, touching neither.

The property that matters is order. Three public methods expose the index of
`objects` directly -- GetObjectIndex, GetObjectAtIndex, RemoveObjectAtIndex --
so "index" means position in ascending objectId order, not insertion order.
That rules out std::map for the replacement (no O(1) indexed access) and makes
an unsorted std::vector a silent renumbering of every index.

Mutation-checked: appending instead of inserting at the sorted position, which
is the mistake a careless OrderedList -> std::vector migration makes, fails 5
of these 16 and none of the 4 pre-existing sampling tests.
…containers

Part of #60, stage 2. Both stores are protected members, so no public
declaration changes here.

- `objects`: DataStructures::OrderedList<uint64_t, TrackedObject*, Comp> ->
  std::vector<TrackedObject*> kept sorted by objectId, searched with
  std::lower_bound. Deliberately NOT std::map: GetObjectIndex,
  GetObjectAtIndex and RemoveObjectAtIndex all publish this index, so being
  both sorted and O(1) indexable is a public contract.
- `dataQueues`: DataStructures::Hash<RakString, ..., 32, RakString::ToInteger>
  -> std::unordered_map with a hasher reusing that same ToInteger.
- GetHistorySorted's local OrderedList -> std::vector + std::sort with the
  unchanged comparators. Equivalent rather than approximately so: the
  comparators tie-break on the key name after sortValue and keys are unique
  within an object, so they never return 0 for two distinct queues -- which
  matters because the OrderedList::Insert they replace refused
  comparator-equal entries outright and would have dropped such a queue.
- GetUniqueKeyList now iterates the map instead of materialising two parallel
  lists via GetAsList. Its output order was previously hash-bucket order and
  is now unordered_map order; both are unspecified and the only contract is
  that it is a set union, which the tests assert as a set.

Fixes a leak found while reading this code: TrackedObject::~TrackedObject
declared two empty local lists and iterated the empty one, never calling
GetAsList, so the OP_DELETE never ran and every TimeAndValueQueue the object
owned leaked -- one per distinct key passed to AddValueByObjectID, and nothing
else in the file freed them. LeakSanitizer on Linux reported 12 leaked queues
plus their sample arrays for 4 objects x 3 keys before, and 0 records after.

Note the leak fix itself is not guarded by the test suite: it is only
observable under a leak checker, and CI has no sanitizer job. The 16 tests
added in dd40528 cover the destructor path functionally but would still pass
if the fix were reverted.

Left as stage 3 work, bound to public signatures: GetHistorySorted and
GetUniqueKeyList out-parameters, ResizeSampleSet's histogram argument and the
local it is passed, and three locals that are arguments to
RakPeerInterface::GetStatisticsList.
…red id

Found by writing the tests that have to exist before RPC4's registration
tables move to std containers (#60, stage 2).

UnregisterLocalCallback put its `return true` inside the branch that fires
only when the removed name was the LAST one for that message id. With two or
more names registered against one id, removing one succeeded but the call
returned false, contradicting the documented contract: "True if the
combination of uniqueID and messageId was in use, and hence removed".

Adds 14 cases covering the three registration tables: register/unregister
round-trips for non-blocking, blocking and local-callback entries, refusal of
a duplicate name, independence of the blocking and non-blocking tables, 200
names at once so a migration that collapses colliding keys shows up here, and
loopback dispatch reaching the registered function.

RPC4Registry.OneMessageIdCarriesSeveralFunctionNames fails before this fix and
passes after. Not flaky: --gtest_repeat=10 clean.

The fixture starts the peer on an ephemeral 127.0.0.1 port, because
CallLoopback pushes a packet that only surfaces through Receive() on a started
peer; there is no connection and no traffic, so the suite stays hermetic.
Part of #60, stage 2. All three are protected members, so no public
declaration changes.

- registeredNonblockingFunctions, registeredBlockingFunctions:
  DataStructures::Hash<RakString, ..., 64, RakString::ToInteger> ->
  std::unordered_map with a hasher reusing that same ToInteger. Hash::Pop
  became find + erase, which is also what gives UnregisterFunction and
  UnregisterBlockingFunction their bool.
- localCallbacks: DataStructures::OrderedList<MessageID, LocalCallback*, Comp>
  -> std::vector<LocalCallback*> kept sorted by messageId and searched with
  std::lower_bound through the new IndexForMessageId helper. The dispatch path
  looks up by message id for every received packet, so the ordering is kept
  rather than traded for an unsorted vector.
- CallBlocking's local DataStructures::Queue<Packet*> -> std::deque. It was
  used as a stack, not a queue: PushAtHead(p,0) pushed to the front and Pop()
  took from the front, so held packets are handed back in reverse arrival
  order. push_front/pop_front preserves that; a vector with push_back/pop_back
  would have reversed the order they are given back to the peer.

Left as stage 3 work: localSlots, LocalCallback::functions and
LocalSlot::slotObjects are tagged \internal but declared in RPC4's public
section, and GetLocalSlotIndex/InvokeSignal take DataStructures::HashIndex
because of it. Those four cannot move while the declarations are public.

Unit suite green, RPC4Context integration test green, RPC4 suites clean under
--gtest_repeat=5.
…ntainers

Part of #60, stage 2. Everything changed here is protected; no public
declaration moves.

- worldsList: DataStructures::List<RM3World*> -> std::vector. RemoveWorld's
  RemoveAtIndexFast is reproduced explicitly as a swap-remove, not an erase:
  GetWorldIdAtIndex documents that "worlds will not necessarily be in the
  order added with AddWorld()", so the swap is a publicly observable contract
  and an erase would renumber every later world.
- constructedReplicaList: OrderedList<Replica3*, LastSerializationResult*,
  Replica3LSRComp> -> std::vector kept sorted by Replica3::referenceIndex,
  with IndexForReplica (lower_bound) and InsertConstructedReplica replacing
  GetIndexFromKey and Insert. InsertConstructedReplica keeps the old
  contract exactly: refuse a duplicate, assert, return (unsigned)-1.
- queryToConstruct/Serialize/DestructReplicaList: List -> std::vector.
- downloadGroup: DataStructures::Queue<Packet*> -> std::deque.

Adds 6 tests. Two of them exist because the migration was not covered at all:

- RemoveWorldSwapsTheLastWorldIntoTheHole pins the documented swap-remove;
  turning it into an erase fails it.
- EveryConstructedReplicaIsFoundWhateverTheConstructionOrder pins that
  constructedReplicaList stays sorted. This was a real hole: appending instead
  of inserting in place passed all 304 unit tests, because every existing test
  constructs at most one replica and one element is trivially sorted. The test
  constructs 16 in an interleaved order and looks each one up through the
  public HasReplicaConstructed; it fails against the appending version.

Left as stage 3 work: constructedReplicasCulled and destroyedReplicasCulled
stay DataStructures::List because they are handed straight to the public
virtuals QueryReplicaList and SendConstruction, and converting at that
boundary would copy both lists every tick on the serialize path.
RM3World::connectionList and userReplicaList are public members of a public
struct.
…stries

TeamManager had no tests. It holds the most container references left in
Source/ after RPC4 and ReplicaManager3 (39), and two of its orderings are
published and differ from each other, which is what makes a mechanical swap
risky in #60 stage 2:

- TM_World::GetTeamByIndex / GetTeamIndex and the team-member equivalents
  publish the index of order-PRESERVING lists; DereferenceTeam and
  DereferenceTeamMember use RemoveAtIndex, so the remainder shifts down.
- TeamManager::GetWorldAtIndex publishes the index of an order-NOT-preserving
  list; RemoveWorld uses RemoveAtIndexFast, swapping the last world into the
  hole.

A migration that applied one idiom to both would renumber half of these while
the NetworkID lookups kept answering correctly, so nothing else would notice.

12 cases, all passing against the current implementation: index order for
teams and members, shift-down on removal, swap-into-hole on world removal,
NetworkID lookup resolution and absence through teamsHash/teamMembersHash,
120 teams at once to spread hash buckets, and participant round-trip.
Part of #60, stage 2. Everything changed is protected; the public getters keep
their DataStructures::List out-parameters.

- TM_World::teams, teamMembers, participants; TM_TeamMember::teams,
  teamsRequested, lastTeams; TM_Team::teamMembers: List -> std::vector, with
  RemoveAtIndex becoming erase so the published indices keep shifting down.
- TM_World::teamsHash, teamMembersHash: DataStructures::Hash<NetworkID, ...,
  256, ToUint32> -> std::unordered_map. NetworkID is an integer type, so the
  default std::hash applies and the ToUint32 projection is no longer needed.
- TeamManager::worldsList: List -> std::vector, with RemoveAtIndexFast
  reproduced as an explicit swap-remove. GetWorldAtIndex exposes that index,
  and unlike the team lists it has never preserved order.
- GetSortedJoinRequests: OrderedList<JoinRequestHelper, ...> -> std::vector +
  std::sort with the unchanged comparator. requestIndex breaks ties and is a
  monotonically increasing per-request counter, so no two requests compare
  equal and the sort matches the ordered inserts exactly.
- A local DataStructures::List working copy in
  GetAvailableTeamIndexWithFewestMembers keeps its swap-remove, which is
  correct there because the loop re-tests the same index.

Adds a file-local IndexOf to replace List::GetIndexOf including its
(unsigned)-1 result, and CopyToList to fill the four still-public
DataStructures::List out-parameters from the internal vectors. Stage 3 removes
those conversions with the signatures.

Two corrections to the tests added in 9a91bea, both found by mutation:
- RemoveWorldSwapsTheLastWorldIntoTheHole removed the second-to-last world,
  which is degenerate: swap-into-hole and shift-down agree there, and turning
  the swap into an erase passed. It now removes an index with two worlds after
  it and fails against the erase.
- Turning the team list's erase into a swap-remove fails
  DereferencingATeamShiftsTheRestDown, so the opposite direction is pinned too.
Part of #60, stage 2. TeamBalancer is now free of DataStructures:: entirely --
all four members and both helper signatures were protected, and the locals are
file-scope.

- myTeamMembers, teamLimits, teamMemberCounts, teamMembers: List ->
  std::vector.
- GetMemberIndexToSwitchTeams and GetOverpopulatedTeams (both protected) take
  std::vector, as do the four locals passed to them.
- Both RemoveAtIndexFast sites are reproduced as explicit swap-removes.
  DeleteMember's is unobservable through the public API, since nothing exposes
  the index of myTeamMembers and every lookup is by NetworkID; it is kept
  because it is the cheaper operation. RemoveTeamMember's does matter: that
  order decides which member is moved when teams are rebalanced.
- Adds a file-local ReplaceAt for DataStructures::List::Replace(input, filler,
  position), which assigns at an index and grows the list filling any gap.
  std::vector::resize(n, filler) gives the same result.

9 tests covering the client-side membership list: request, cancel,
request-any, delete, duplicate-request handling, independence of several
members, and 64 members with every other one deleted to drive the swap path.

The suite deliberately does not claim to pin the swap-remove on myTeamMembers:
swap and shift are indistinguishable through the public API there, and the
test file says so rather than implying coverage it does not have. The
host-side teamMembers order, which does affect rebalancing, is driven by
received packets and is not reachable hermetically.
…tainers

Part of #60, stage 2. Both members are protected.

- filterList: OrderedList<int, FilterSet*, FilterSetComp> -> std::vector kept
  sorted by filterSetID, searched through the new IndexForFilterSetID
  (lower_bound). GetFilterSetIDByIndex publishes this index, so the ordering
  stays.
- systemList: DataStructures::Hash<AddressOrGUID, FilteredSystem, 2048,
  AddressOrGUID::ToInteger> -> std::unordered_map with a hasher reusing that
  same ToInteger, so bucketing and key equality are unchanged.
- GetSystemCount and DeleteFilterSet now iterate the map instead of
  materialising two parallel lists through GetAsList. DeleteFilterSet erases
  while iterating via the iterator returned by erase.
- Update() still takes a snapshot into two std::vectors rather than iterating
  in place, because the timeout callbacks can close connections and reach back
  into systemList. The DataStructures::List copy it replaces had the same
  effect; dropping the snapshot would have invalidated the iterator.

Inherited deliberately, and now written down: AddressOrGUID::operator==
matches when EITHER the guid or the address matches, while ToInteger hashes on
the guid when one is set and the address otherwise. Two keys that compare equal
can hash differently and miss each other. DataStructures::Hash had the same
hazard for the same reason, so this is not a regression -- but changing key
equality would change which systems a filter set matches, which is not a stage
2 decision.

FilterSet::allowedRPC4 stays an OrderedList: it is a public member of a public
struct.
Part of #60, stage 2. All six members are protected; TCPInterface now has no
live DataStructures:: use of the stage 2 containers (one commented-out line
remains).

- headPush, tailPush: Queue<Packet*> -> std::deque, push_back/pop_front. FIFO
  within each queue, and ReceiveInt drains headPush before tailPush.
- completedConnectionAttempts, failedConnectionAttempts: Queue<SystemAddress>
  -> std::deque, same shape, still under their mutexes.
- messageHandlerList: List -> std::vector. DetachPlugin's swap-remove was
  already written out by hand and is kept.
- blockingSocketList: List -> std::vector, RemoteAtIndexFast reproduced as a
  swap-remove; that order is not published.
- activeSSLConnections: List -> std::vector with std::find.
- PacketizedTCP derives from TCPInterface and reads messageHandlerList, so two
  uses there move with it.

7 tests. These are worth having beyond the migration: PushBackPacket and
Receive are public and reachable without a socket, so the head/tail ordering
contract is testable directly. Started with maxIncomingConnections of 0, which
creates no listen socket -- only the update thread -- so the suite stays
hermetic with no bind and no traffic.

Mutation-checked: making the head queue LIFO fails 2 of the 7. The cases push
several packets per queue on purpose; one packet per queue would pass against a
reversed queue.
Part of #60, stage 2. Small by design: most of Table's container surface is
public and therefore stage 3.

- AddRowColumns and QueryRow are protected, so their DataStructures::List<unsigned>
  parameters become std::vector<unsigned>, along with the three locals passed
  to them and the file-static _columnIndices the sort callback reads.

Left alone deliberately:
- Table::columns stays a DataStructures::List because GetColumns() is public
  and returns a const reference to it. Changing the member would break that
  signature, and holding a converted copy to return a reference from is worse
  than waiting for stage 3.
- Row::cells is a public member of a public struct.
- table_serializer.cpp was checked and has nothing in scope: every
  DataStructures:: reference there is in a public static signature.

Covered by the 18 existing Table tests, which exercise query, sort and the
serialization round trip.
DecodeTeamAssigned's three DataStructures::List<TM_Team*> out-parameters are a
protected signature, so they were in scope for stage 2 and I missed them --
found by classifying every remaining reference in Source/include by access
section rather than by grepping file totals.

They become std::vector, along with the three locals its only caller passes.
team_manager.h now has no stage 2 container reference left in any
protected or private section; the five that remain are the public getters,
which stage 3 moves.
…plit packets

Written before stage 2 of #60 moves ReliabilityLayer's protected containers,
because two of them had no coverage that could distinguish a correct migration
from a broken one:

- datagramHistory is a sliding window indexed by
  (datagramNumber - datagramHistoryPopCount) in WRAPPING uint24_t arithmetic.
  Every existing test sends few enough datagrams that the pop count stays at or
  near zero, where an off-by-one in that arithmetic is invisible. The new
  sustained-traffic case pushes 300 small messages through 10% loss, so the
  window advances and pops hundreds of times and resend lookups land at varying
  offsets, then asserts reliable-ordered delivery of all 300 in order.

- splitPacketChannelList is an ordered list keyed by split-packet id, and every
  existing test has exactly ONE split message in flight. With a single element
  any ordering or lookup passes, so the container's job was untested. Three new
  cases put two and then three split messages in flight at once, with and
  without loss, and assert each reassembles as itself rather than being
  conflated with another.

These are not MTU black hole tests, so they are marked off in the file with
their own explanation; they live here to reuse the existing two-layer
fake-socket rig with simulated time rather than duplicate it.

All four pass against the current implementation.
…ainers

Part of #60, stage 2. Eleven protected members plus three locals.

- outputQueue, datagramHistory, hasReceivedPacketQueue, delayList:
  DataStructures::Queue -> std::deque.
- splitPacketChannelList: OrderedList<SplitPacketIdType, SplitPacketChannel*,
  SplitPacketChannelComp> -> std::vector kept sorted, with IndexForSplitPacketId
  (lower_bound) and InsertSplitPacketChannel. Both reuse
  SplitPacketChannelComp rather than reimplementing its key extraction, which
  differs under PREALLOCATE_LARGE_MESSAGES.
- unreliableWithAckReceiptHistory and the five per-update send lists: List ->
  std::vector, with Preallocate becoming reserve.

Three things in here are behaviour or semantics, not mechanics:

1. hasReceivedPacketQueue's AllocationSize()/Compress() compaction is removed.
   It existed only because the hand-rolled ring buffer never shrank; std::deque
   releases blocks as it drains, so there is nothing to compact.

2. uint24_t + size_t is ambiguous where uint24_t + unsigned was not, because
   uint24_t has both operator uint32_t() and an implicit uint24_t(const
   uint32_t&). size() returns size_t where Size() returned unsigned, so the
   datagram-boundary arithmetic is cast back to unsigned int to keep the
   original overload resolution. Same root cause as the RangeSum bug fixed in
   stage 1.

3. delayList's PushAtHead(dat, i) inserts at an arbitrary index to keep the
   list ordered by sendTime; it is now insert(begin() + i, dat), not
   push_front.

My first attempt at this was wrong and the new tests caught it.
InsertSplitPacketChannel originally read the split id back out of the channel,
but the call site inserts the channel before populating its split packet list,
so it keyed on uninitialised memory and segfaulted. Running the same test
against the pre-migration source passed, which pinned it on this change rather
than on a pre-existing bug or a bad test. The id is now a parameter.

What these tests do NOT cover, stated so nobody assumes otherwise: I could not
construct a case that distinguishes a sorted insert from an append for
splitPacketChannelList. Split ids are handed out in ascending order, so
appending stays sorted by accident; adding datagram reordering to the rig and
going to two, three and eight concurrent split messages all still passed
against an appending mutant. The sorted insert is kept because it mirrors the
original and keeps lookup logarithmic, but it is correct by construction, not
by test.

Also fixes replica_manager3's constructedReplicaList.HasData, which only
compiles under _DEBUG and so was missed until the Linux Debug build.

Verified: macOS full suite 408 tests exit 0; macOS Debug build and unit suite
clean; Linux Docker Debug 408/408 and Release 408/408 per CLAUDE.md, which is
what caught the three _DEBUG-only compile errors in the first place.

Left for the peer migration: the DataStructures::List<PluginInterface2*>
parameters threaded through this file belong to peer's pluginListTS/NTS and
have to move with them.
Covers eight of RakPeer's protected containers (issue #60):

  banList                        List<BanStruct*>              -> std::vector
  securityExceptionList          List<RakString>               -> std::vector
  socketList                     List<RakNetSocket2*>          -> std::vector
  SocketQueryOutput::sockets     List<RakNetSocket2*>          -> std::vector
  packetReturnQueue              Queue<Packet*>                -> std::deque
  requestedConnectionQueue       Queue<RequestedConnectionStruct*> -> std::deque
  bufferedPacketsQueue           Queue<RNS2RecvStruct*>        -> std::deque
  bufferedPacketsFreePool        Queue<RNS2RecvStruct*>        -> std::deque

plus the local drain queue in ClearRequestedConnectionList.

Tests first: Tests/Unit/PeerListsTests.cpp, 21 hermetic cases covering the ban
list (exact and wildcard matches, swap-remove, in-place timeout update, the
expiry sweep), the security exception list (multi-match removal, clear-on-null),
the packet return buffer (PushBackPacket at head and tail, interleaved) and the
socket list (one socket per descriptor, in index order). No Connect(), no
loopback traffic; the only use of time is a ban created already expired.

Removal semantics preserved exactly:

  * banList and securityExceptionList wrote their swap-remove out by hand
    (`list[i] = list[size-1]; RemoveAtIndex(size-1)`), which is `list[i] =
    list.back(); pop_back()`. Order is not part of their contract, but dropping
    the swapped-in element is a real bug, and the tests catch it.
  * DataStructures::Queue::RemoveAtIndex is a *shift*-remove, not a swap-remove,
    so requestedConnectionQueue's four removal sites became
    `erase(begin() + i)`, not pop_back. The queue order is not observable from
    outside, so this is preserved by construction rather than by test.
  * packetReturnQueue's `PushAtHead(packet, 0, ...)` is push_front; Receive pops
    the front. Both directions are pinned.

Two redundant clears dropped: GetSocket's `output` is a fresh local filled at
most once, and GetSockets already clears the caller's list at the top of the
function, so clearing again inside the copy loop was dead. With them gone,
removing the surviving clear fails a test.

Mutation-tested both before and after the migration -- eight mutations, all
killed, same tests on both sides:

  banList swap-remove loses the swapped-in element   -> use-after-free in
                                                        RemovingAMiddleBan...
  expiry sweep advances past the swapped-in element   -> ExpiredBansAreSwept...
  AddToBanList appends instead of deduplicating       -> ReAddingAnIp... (x2)
  PushBackPacket's head push becomes a tail push      -> ...AtHeadJumpsTheQueue
  Receive pops the tail (buffer becomes a stack)      -> ...AtTailIsFifo
  exception removal advances past the swapped element -> ...RemovesAllMatching
  socket query answers with only the first socket     -> GetSockets... (x3)
  GetSockets stops clearing the caller's list         -> ...ClearsTheCallersList

Not migrated, and why: pluginListTS/pluginListNTS and the CallPluginCallbacks
parameter stay DataStructures::List. They are handed straight to
ReliabilityLayer::Update, UpdateAndForceACKs and
HandleSocketReceiveFromConnectedPlayer, whose signatures are public; moving the
members without those would mean converting at every call site. Same shape as
Table::columns being blocked by the public GetColumns(). Those belong to the
public-API stage.

remoteSystemIndexPool, packetAllocationPool, bufferedCommands and
socketQueryOutput are MemoryPool/ThreadsafeAllocatingQueue, not stage-2 types.

Every migrated member keeps its existing mutex and every access stays inside the
same critical section; no operation moved across a lock.

Verified: macOS full suite 429/429; local Debug build clean + Debug unit 362/362;
Linux Docker Debug 429/429 and Release 429/429.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
The page promised more than the library delivers, and the std-container
migration (issue #60) makes the gap worth writing down precisely rather than
leaving readers to find it.

Corrections:

  * _USE_RAK_MEMORY_OVERRIDE defaults to 0 (defines.h), and the page never said
    so. In a default build OP_NEW / OP_NEW_ARRAY / OP_DELETE expand to plain
    new/delete and SetMalloc never sees them. Only the direct rakMalloc_Ex /
    rakRealloc_Ex / rakFree_Ex calls -- packet payloads, RakString storage, the
    ban list's IP strings -- are redirected unconditionally.
  * The standard containers the library now uses internally allocate through
    std::allocator and are never redirected. Added the comparison that matters:
    this is not a regression, because DataStructures::List allocated its backing
    array with OP_NEW_ARRAY, which is already a plain new[] in a default build.
    The change is observable only in a build that sets
    _USE_RAK_MEMORY_OVERRIDE=1 *and* installs a custom allocator; pointed at
    replacing global operator new as the way to cover everything.
  * RAKNET_MEMORY_TRACKING does not exist anywhere in the tree. Dropped, along
    with the two _RAKNET_SUPPORT_* entries that have nothing to do with memory.
    Documented _USE_RAK_MEMORY_OVERRIDE in their place.
  * "Memory leak detection helpers" -- there are none. Replaced with a pointer to
    the sanitizers, which also see the std containers.
  * The tracking example would not compile: the _Ex callbacks take
    `unsigned int line`, not `int`. Fixed, and added SetRealloc_Ex, whose absence
    silently left reallocations on the default path.
  * Pool class names were header-style (DS_MemoryPool<T>, DS_BytePool,
    DS_ThreadSafeAllocatingQueue). Replaced with the real names
    (DataStructures::MemoryPool<T>, ::BytePool, ::ThreadsafeAllocatingQueue<T>)
    and the header each lives in.

Verified: sphinx-build exits 0 with no new warning on the page.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
ThreadPool::Clear() locks runThreadsMutex to read runThreads, and unlocks it
again only in the branch taken when threads *are* running. On the other branch
it clears the three queues and returns with the mutex still held.

SimpleMutex is not recursive, so the next lock of runThreadsMutex deadlocks:
StartThreads, StopThreads, Pause, WasStarted, and the destructor, which calls
StopThreads. A user who calls the documented "clears internal buffers" API on a
pool that is not running cannot destroy that pool afterwards.

Nothing inside the library calls Clear() -- send_to_thread and file_list_transfer
use ClearInput() and StopThreads() -- which is why it survived. thread_pool.h is
a public header, so it is reachable.

Found while writing Tests/Unit/ThreadPoolQueueTests.cpp (14 cases) ahead of
moving the three protected queues to std::deque. The new
ClearDoesNotLeaveRunThreadsMutexHeld case hangs against the unfixed header --
verified by reverting the one-line fix and re-running it -- and passes with it.
A hang is a worse failure signal than an assertion, but it is the only one this
bug produces; ctest's timeout catches it.

The other 13 cases pin the queue semantics the migration has to preserve: FIFO
order on both queues, GetOutput popping the head, RemoveInputAtIndex and
RemoveOutputAtIndex shifting later elements down rather than swapping the last
one into the hole, ClearInput and ClearOutput not touching each other's queue,
and two fixed-seed differential runs (4000 mixed add/pop/remove-at-index
operations each) against a std::deque reference model.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
  inputFunctionQueue  Queue<OutputType (*)(InputType, bool*, void*)> -> std::deque
  inputQueue          Queue<InputType>                               -> std::deque
  outputQueue         Queue<OutputType>                              -> std::deque

Issue #60. Tests landed first in 2928ffb, which also fixed the Clear() lock
leak they found.

Semantics preserved:

  * DataStructures::Queue::RemoveAtIndex is a shift-remove, so
    RemoveInputAtIndex and RemoveOutputAtIndex became erase(begin() + index),
    not pop_back.
  * It also silently no-ops when the index is past the end, while
    deque::erase there is undefined. The bound check moved into the two callers,
    alongside the RakAssert the old container did under _DEBUG.
  * Queue::Pop and Queue::operator[] asserted their preconditions under _DEBUG
    and read past the end otherwise. GetOutput, GetInputAtIndex and
    GetOutputAtIndex now carry those asserts explicitly -- GetOutput's
    precondition (HasOutput() first) is the one its doc comment already states.
  * The worker thread takes the head of both paired queues under
    inputQueueMutex, in the same critical section as before.

New: six worker-thread cases in Tests/Integration/ThreadPoolWorkerTests.cpp.
The pairing between inputFunctionQueue and inputQueue is not observable from the
public API without a worker -- a removal from only one of them produces no size
mismatch, just every later job running with its neighbour's callback -- so the
tests tag each callback's output differently and assert the exact sequence.
Integration rather than unit because they need a real thread and a deadline
poll. Not flaky: 10 repeats, 60/60.

Six mutations, all killed, each attribution re-verified against a freshly built
binary:

  paired removal drops only inputQueue   -> RemovingAnInputKeepsEveryOther...,
                                            RemovingTheHeadInputKeepsTheRest...
                                            (unit survives, by design)
  RemoveInputAtIndex -> swap-remove      -> RemoveInputAtIndexShiftsLaterInputs...,
                                            RemoveInputAtTheHeadAndAtTheTail,
                                            InputQueueMatchesADequeModel...
  worker pops the tail, not the head     -> all five ordering cases
  RemoveOutputAtIndex -> swap-remove     -> RemoveOutputAtIndexShiftsLater...,
                                            OutputQueueMatchesADequeModel...
  ClearInput also clears outputQueue     -> ClearInputLeavesTheOutputQueueAlone
  worker ignores returnOutput            -> CallbacksThatSuppressOutputPushNothing

thread_pool.h no longer includes ds_queue.h. Two headers in DependentExtensions
(AutopatcherServer.h, Lobby2Server.h) were getting DataStructures::Queue
transitively through it and now include it themselves.

thread_pool.h has no DataStructures:: references left.

Verified: macOS full suite 449/449; Debug build clean + Debug unit 376/376.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…e in 35f8f0c

35f8f0c's migration of tcp_interface.cpp replaced
DataStructures::List::GetIndexOf with std::find arithmetic but kept the old
"not found" comparison. GetIndexOf returned (unsigned)-1 when the value was
absent; `std::find(...) - begin()` returns size(). Every one of the four
converted lookups therefore tested the wrong thing, and in two of them the test
is inverted:

  StartSSLClient        `if (index==(unsigned)-1) push_back(...)` can never be
                        true, so an address was never added to
                        activeSSLConnections and IsSSLActive always said false.
  DetachPlugin          `if (index!=MAX_UNSIGNED_LONG)` is always true, so
                        detaching a plugin that was never attached ran
                        messageHandlerList[size()]->OnDetach() -- one past the
                        end, or a dereference of nothing on an empty list -- and
                        then swap-removed a plugin that was still attached.
  CloseConnection       same always-true test, then erase(begin() + size()).
  SocketConnect         same always-true test, and the replacement turned one
                        statement into two without adding braces, so
                        pop_back() ran unconditionally -- including on an empty
                        list -- while the swap-assignment was the only thing
                        guarded.

All four now compare iterators against end() and drop the index entirely, so
there is no sentinel left to get wrong. The StartSSLClient and CloseConnection
sites are inside OPEN_SSL_CLIENT_SUPPORT==1, which defaults to 0 and is not
compiled here; they are fixed but not covered by a test, and I am not claiming
otherwise. The other two are always compiled.

I audited every index lookup this branch converted. These four are the only ones
that got it wrong: the team_manager and team_balancer sites go through an
IndexOf helper that preserves (unsigned)-1, and the sorted containers use
lower_bound with an explicit key comparison. No raw `std::find(...) - begin()`
remains anywhere in Source/.

New: Tests/Unit/TCPInterfacePluginTests.cpp, 6 hermetic cases over the plugin
list, which had no tests. Against the broken DetachPlugin, three segfault and
three pass:

  AttachThenDetachCallsEachHookOnce              survives (index 0 of 1 is valid)
  DetachingTheMiddleOfThreeKeepsTheOtherTwo      survives
  DetachingNullIsIgnored                         survives
  AttachingTheSamePluginTwiceAttachesItOnce      crash
  DetachingAnUnattachedPluginLeavesTheAttachedOneAlone  crash
  DetachingFromAnEmptyListIsANoOp                crash

The three that survive are the shape the rest of the codebase already uses --
detaching a plugin you attached, where the wrong sentinel happens to give the
right index. That is why nothing caught this.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…rt a failed bind

Three defects, all in Start/Stop, found while writing the first tests
PacketizedTCP has ever had: every test after the first one in the same process
failed, with the client connecting successfully and the server never reporting
the incoming connection.

**Stop() closed descriptor 0, once per unused connection slot.**
RemoteClient's constructor sets `socket = 0`, and 0 is this class's "no socket"
value -- RemoteClient::SetActive already guards on it. Stop() did not, and
closed every slot unconditionally. On POSIX 0 is a valid descriptor: stdin in a
normal process. A server started with maxIncomingConnections of 4 and one
connection called close(0) three times.

The second-order effect is what the tests actually tripped over. Once
descriptor 0 is free, the next socket created *anywhere in the process* is
handed number 0, and every `listenSocket != 0` check then reads it as absent.
A second TCPInterface started, returned true, and never accepted anything;
instrumenting CreateListenSocket showed `listenSocket=0` on the second cycle
against `listenSocket=3` on the first. Stop() also skipped closing that
listener, leaking it.

Fixed by guarding on `socket != 0`, matching SetActive. Using 0 as the sentinel
for a descriptor remains wrong in principle, and is still how listenSocket is
handled throughout this file; with Stop() no longer closing descriptors it does
not own, a normal process keeps stdin on 0 and the collision is unreachable.
Worth replacing properly, not in this commit.

**Stop() did not clear blockingSocketList.**
It closed each descriptor in the list and left them there, so a second Stop(),
or the one the destructor performs, closed the same numbers again. Descriptor
numbers are reused, so the second close can land on a socket something else has
since opened. The list is now cleared.

**Start() discarded CreateListenSocket's return value.**
A failed bind -- the port already in use -- left Start() returning true with
nothing listening and the caller unable to find out. Start() now tears down what
it allocated and returns false, and an interface whose Start failed can be
stopped and started again.

New: Tests/Integration/TCPInterfaceLifecycleTests.cpp, 5 cases, plus two
reusable helpers. TCPInterface::Start takes a port and exposes no accessor for
the one it bound, so a listening test cannot use port 0 and read the result back
the way the peer tests do; TestHelpers::FindFreeTcpPort binds a throwaway socket
to port 0 and reports the assigned port, which beats adding another hard-coded
port to a suite that is already RUN_SERIAL because of them.

That alone was flaky: 1 failure in 90 repeats, `Start` returning false. The
probe binds 127.0.0.1 while TCPInterface binds INADDR_ANY, and the OS reissues
ephemeral port numbers soon after a test releases them, so a bind can hit a port
in use on another interface or still in TIME_WAIT from a previous test's
accepted connection -- TCPInterface sets no SO_REUSEADDR.
TestHelpers::StartTcpOnAFreePort retries the port selection, which absorbs that
without retrying anything a test asserts. 168/168 across 12 repeats of both new
suites afterwards.

Each fix is pinned independently, by reverting one at a time against a build I
checked each time:

  revert the Stop() guard + clear  -> all 5 fail
                                      (the two descriptor-0 cases directly; the
                                      other three because the freed descriptor 0
                                      is reassigned and read as "no socket")
  revert the Start() return        -> StartFailsWhenThePortIsAlreadyInUse,
                                      AnInterfaceWhoseStartFailedCanStartAgain

The two descriptor-0 cases are POSIX-only: on Windows descriptor 0 is not a
socket handle, so they are compiled out there and the behavioural three-cycle
case carries that platform.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
  waitingPackets                 Queue<Packet*>                      -> std::deque
  connections                    Map<SystemAddress, ByteQueue*>      -> std::map
  _newIncomingConnections        Queue<SystemAddress>                -> std::deque
  _lostConnections               Queue<SystemAddress>                -> std::deque
  _failedConnectionAttempts      Queue<SystemAddress>                -> std::deque
  _completedConnectionAttempts   Queue<SystemAddress>                -> std::deque

Issue #60. packetized_tcp.h now has no stage-2 DataStructures:: references;
DataStructures::ByteQueue stays, it is not one of the types in scope.

`connections` was only ever used as a dictionary: every index came from a key
lookup (Has, then GetIndexAtKey, then operator[]), so std::map::find and an
iterator replace the whole pattern and the positional accessors disappear.
DataStructures::Map kept its entries in key order and so does std::map, but
nothing here depended on that.

New: Tests/Integration/PacketizedTCPFramingTests.cpp, 9 cases. PacketizedTCP had
no tests at all, and none of this is reachable without a real connection --
Send() on an address with no entry in `connections` returns without sending, and
the notification queues are fed from TCPInterface's own lock-free queues -- so
they run over loopback TCP. They cover a single message's exact length, a batch
of twelve in one segment, a 200KB payload reassembled across segments, a small
message behind a large one, both directions at once, close and failed-attempt
notifications, and two clients at once.

Five mutations; three killed, two survived and I am not going to pretend
otherwise:

  waitingPackets drained as a stack    -> ABatchOfMessagesInOneSegment...,
                                          ASmallMessageAfterALargeOne...
  reassembled messages queued at head  -> same two
  AddToConnectionList does not insert  -> six of the nine

  reassembly buffer looked up as the first map entry  -> SURVIVES with small
      payloads, because two complete messages written into one buffer still read
      back correctly in sequence. It only breaks when the reads interleave, so
      TwoClientsAreFramedSeparately now sends 180KB of 'A' from one client and
      180KB of 'B' from the other and asserts both arrive byte-exact; that kills
      it.
  RemoveFromConnectionList erases nothing  -> SURVIVES. The lost-connection
      notification is pushed before the removal, so nothing observable changes;
      the entry leaks until ClearAllConnections. Making it observable needs the
      same address to reconnect, which means controlling the client's ephemeral
      port. Not covered.

Writing the 180KB two-client case also turned up that PacketizedTCP emits an
ID_DOWNLOAD_PROGRESS packet every 64KB while a message is incomplete -- my
collector was counting those as messages. It now skips and counts them
separately, and the next commit pins that path.

AddToConnectionList keeps DataStructures::Map::SetNew's duplicate-key assert and
additionally frees the existing buffer instead of overwriting the pointer, which
is what SetNew did under the assert in a release build.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…the progress packet's bitSize

Three defects the new framing tests turned up.

**Stop() freed the queued messages and left the freed pointers in the queue.**
`for (i...) DeallocatePacket(waitingPackets[i]);` with no clear afterwards. A
second Stop() -- or the one a caller makes before destroying the object --
freed every one of them again, and a Receive() after Stop() popped a dangling
pointer, ran it past the plugin chain and handed it to the caller.

**~PacketizedTCP() never freed the queue at all.**
It called ClearAllConnections(), which frees the per-connection reassembly
buffers, and left waitingPackets alone. Receive() reassembles everything
available and returns one message, so any queued remainder leaked on
destruction. Both now go through one ClearWaitingPackets().

**ID_DOWNLOAD_PROGRESS carried the wrong bitSize.**
The progress packet is built with its own length (1 + 12 + 65536 = 65549
bytes), but took `bitSize = BYTES_TO_BITS(incomingPacket->length)` -- the bit
count of whatever TCP segment happened to trigger it. length and bitSize
disagreed on every progress packet. Now taken from its own length.

Tests: four cases added to Tests/Integration/PacketizedTCPFramingTests.cpp.

  LargeTransfersEmitWellFormedProgressPackets  sends 4MB and asserts progress
      packets are emitted and that each one's bitSize matches its own length.
      The size is what makes it deterministic: the progress branch only runs
      while a message is still incomplete after Receive() has drained what was
      available, so a payload that fits in the socket buffers can complete in one
      go and emit nothing. A first attempt used two 180KB senders and failed
      about one run in ten for exactly that reason. 4MB with a sleep-free poll:
      20/20.
  ReceiveAfterStopReturnsNothing               six messages sent, one Receive
      (which queues the other five), Stop, then Receive must return nothing.
  StoppingTwiceWithQueuedMessagesIsSafe        the same, stopped twice.
  DestroyingTheInstanceWithQueuedMessagesIsClean  destroys with five messages
      queued, no Stop and no drain -- the scenario for the destructor leak.

Mutation-verified, each against a build I checked:

  bitSize taken from the incoming packet again   -> LargeTransfersEmitWellFormed...
  the clear() removed from ClearWaitingPackets   -> ReceiveAfterStopReturnsNothing
                                                    and StoppingTwiceWith... both
                                                    abort on a double free
                                                    (SIGABRT), and
                                                    LargeTransfers... fails its
                                                    assertions
                                                 -> ABatchOfMessages... survives,
                                                    so the existing cases would
                                                    not have caught it

The destructor leak is verified by LeakSanitizer, not by an assertion: an
ASAN+LSAN Debug build on Linux runs the whole suite, including
DestroyingTheInstanceWithQueuedMessagesIsClean, with no leak reported.

Verified: macOS full suite 473/473; Debug build clean + Debug unit 382/382;
Linux ASAN+LSAN clean over all three new suites (24 tests).

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…ddrinfo leak

Found by an ASAN+LSAN build in Docker, which reported
`heap-use-after-free ... in TCPInterface::SocketConnect`. All three predate this
branch -- they come in with the SLikeNet import -- and all three are on the
default connect path.

**ConnectionAttemptLoop used the thread argument after deleting it.**

    MafiaNet::OP_DELETE(s, _FILE_AND_LINE_);
    ...
    tcpInterface->SocketConnect(str1, ..., socketFamily, s->bindAddress);

systemAddress, tcpInterface and socketFamily are copied out of `s` first;
bindAddress is not, and is read after the delete. This is the read ASAN caught,
and it happens on every non-blocking Connect -- which is the default. bindAddress
is now copied into a local before the delete.

**Connect()'s thread-creation error path does the same thing.**

    MafiaNet::OP_DELETE(s, _FILE_AND_LINE_);
    failedConnectionAttempts.push_back(s->systemAddress);

Only reached when creating the connect thread fails, so it is not routinely hit,
but it is the same mistake. The address is now read out first.

**SocketConnect leaked its getaddrinfo result on every call.**
The walk looking for the first IPv4 address advanced `curAddress`, which was the
only pointer to the head of the list, and no path called freeaddrinfo. The head
is now kept in a separate variable and freed on every exit, including the three
early returns, and the `err != 0` check moved ahead of the dereference it used to
sit behind.

Verified: ASAN+LSAN Debug build on Linux, 24 tests across
PacketizedTCPFraming, TCPInterfaceLifecycle and TCPInterfacePlugins, exit 0 with
no AddressSanitizer error and no leak report -- against the use-after-free it
reported before the fix. macOS full suite 473/473.

A note on how this was nearly missed: CLAUDE.md's Docker recipe does
`cp -r /src /work` before configuring. The tree now carries ~780MB of build
output in build/ and build-debug/, and that copy silently truncated
tcp_interface.cpp inside the container -- gcc reported "unterminated #if" for a
file that compiles fine. Building straight from the read-only mount with
`cmake -S /src -B /b` avoids the copy entirely and is what produced the result
above.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…ecipe

The Docker verification recipe copied the source tree into the container
(`cp -r /src /work`) before configuring. A local build/ directory is a few
hundred MB -- this tree carries ~780MB across build/ and build-debug/ -- and once
the container's writable layer fills, the copy truncates files silently. The
symptom is gcc reporting "unterminated #if" in a file that compiles fine
everywhere else, which is easy to read as a real portability problem and waste
time on.

CMake never writes into the source tree, so `-S /src` against the read-only
mount works and there is nothing to copy for. Recipe updated, with the trap
written down.

Also added the ASAN+LSAN variant, since that is what turned up the
use-after-free in TCPInterface::SocketConnect that nothing else was going to
find, and LeakSanitizer only exists on Linux so it belongs in this section
rather than in a local workflow.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…ent or after CloseEvent

On POSIX, SignaledEvent's condition variable and mutex are created by InitEvent,
not by the constructor, but SetEvent called pthread_cond_broadcast
unconditionally and WaitOnEvent called pthread_cond_timedwait unconditionally.
Broadcasting on an uninitialised pthread_cond_t is undefined, and glibc does not
fail -- it blocks forever in __condvar_quiesce_and_switch_g1. The caller hangs,
with no diagnostic.

This is reachable through a public API. ThreadPool::AddInput signals
quitAndIncomingDataEvents and nothing requires StartThreads first, so
Tests/Unit/ThreadPoolQueueTests.cpp -- which exercises the queues without
starting a worker, deliberately -- hung on the first AddInput in the Linux Debug
gate. gdb on the stuck process:

  #3 ___pthread_cond_broadcast (cond=0xffffc73cd200)
  #4 MafiaNet::SignaledEvent::SetEvent (...) signaled_event.cpp:112
  #5 ThreadPool<int, int>::AddInput (...) thread_pool.h:434
  #6 ThreadPoolQueues_RemoveInputAtTheHeadAndAtTheTail_Test::TestBody

It is platform-specific, which is why it was never hit: on Windows
::SetEvent(INVALID_HANDLE_VALUE) just fails, and on macOS the uninitialised
broadcast happens to return.

Fixed in SignaledEvent rather than in ThreadPool, because the hole belongs to
the primitive and any caller can fall into it. An isInitialized flag, written by
InitEvent and CloseEvent and read under the existing isSignaledMutex, now gates
the two pthread calls; the Windows paths gain the equivalent
INVALID_HANDLE_VALUE checks they were missing in SetEvent and WaitOnEvent. Two
further undefined cases are closed with it: a second InitEvent without a
CloseEvent no longer re-runs pthread_cond_init on a live condition variable, and
CloseEvent without a preceding InitEvent no longer destroys one that was never
created.

SetEvent still records isSignaled when there is no condition variable, so a
signal raised before InitEvent is delivered to the first WaitOnEvent after it
rather than lost.

New: Tests/Unit/SignaledEventLifecycleTests.cpp, 8 cases walking the lifecycle
out of order -- signal and wait before init, init twice, close without init,
init again after close, signal after close. SignaledEvent had no tests.

Proved on Linux, in a Debug container, by making the broadcast unconditional
again and rebuilding:

  with the guard     SignaledEventLifecycle 8/8, ThreadPoolQueues 14/14
  without the guard  both time out on their first case
                     (SetEventBeforeInitEventReturns, AddInputAppendsInFifoOrder)

A hang is a poor failure signal, but it is the only one this bug produces; the
timing assertions in the new tests exist to turn it into a failure, not to
measure anything, and are loose by two orders of magnitude.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
…nnection it cannot take

The Linux Release gate aborted eleven tests with

    *** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated

while Debug passed all of them. This is the Debug/Release split CLAUDE.md warns
about, and the asymmetry is the whole story: glibc fortifies fd_set under -O2 and
aborts on an out-of-range bit, while an unfortified Debug build writes outside
the set and carries on.

**accept() failure was read as success.**

    newSock = accept__(sts->listenSocket, ...);
    if (newSock != 0)
    {
        ...
        remoteClients[i].socket = newSock;
        remoteClients[i].SetActive(true);

accept() returns -1 on POSIX and INVALID_SOCKET on Windows; only this class uses
0 for "no socket". A failed accept -- which happens routinely when a listener is
shut down while the update thread is in its loop, exactly what the Start/Stop
cycle tests do -- therefore stored -1 as a client's socket and marked the slot
active. The next pass through the select loop called FD_SET(-1, &readFD).

The file already carries the bruises from this: two comments read "calling
FD_ISSET with -1 as socket (that's what 0 is set to) produces a bus error under
Linux 64-Bit", and the loops were given `socketCopy != 0` guards. Those guard the
symptom; the -1 was still being stored.

Fixed with a file-local IsUsableSocket() that knows what each platform reports,
now used at all three places that check a socket: accept, socket() in
SocketConnect (`sockfd < 0`, which can never be true on Windows where SOCKET is
unsigned) and socket() in CreateListenSocket (`(int)listenSocket == -1`, which
only worked because of the cast and accepted a socket numbered 0 that this class
cannot represent).

**A connection accepted with every slot full was leaked.**
The recovery branch tested `newRemoteClientIndex == -1`, which the loop above
cannot produce -- it leaves the index at remoteClientsLength -- so it never ran,
and the accepted socket was never closed: a descriptor leak per refused
connection, and a client left connected to a server that would never service it.
Had the branch fired it closed `sts->listenSocket`, the listener, instead of the
connection it could not accommodate. Now tracked with an explicit flag, and it
closes newSock.

New: TCPInterfaceLifecycle.AConnectionRefusedForLackOfSlotsIsClosed -- one slot,
two clients; the second must see its connection closed, and the listener must
still accept once the slot frees.

Proved in a Linux Release container, reverting one half at a time:

  with both fixes                 19/19 across TCPInterfaceLifecycle and
                                  PacketizedTCPFraming
  revert the accept check         abort, "bit out of range 0 - FD_SETSIZE"
  revert the full-slots close     abort (closing the listener feeds the same
                                  select loop a dead descriptor)

Verified: Linux Release 19/19 on the two affected suites; macOS 30/30 across the
three new suites.

Claude-Session: https://claude.ai/code/session_011kRj5nodTJrxyoHY7y6R6c
@coderabbitai

coderabbitai Bot commented Oct 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • master

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 012e2a3d-f110-4179-a45d-ed18956452f7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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