Skip to content

fix(core): rollback per-index entry when ChannelRegistry id-uniqueness check loses race (fixes #268)#280

Merged
Kiryuumaru merged 2 commits into
masterfrom
fix/channel-registry-orphan-on-id-collision
May 21, 2026
Merged

fix(core): rollback per-index entry when ChannelRegistry id-uniqueness check loses race (fixes #268)#280
Kiryuumaru merged 2 commits into
masterfrom
fix/channel-registry-orphan-on-id-collision

Conversation

@Kiryuumaru
Copy link
Copy Markdown
Owner

Summary

Fixes #268ChannelRegistry.RegisterWriteChannel / RegisterReadChannel leak the per-index entry into _writeChannels / _readChannels when the second-step _idToIndex.TryAdd fails on ID collision. The throw exits with a half-registered channel: the caller observes MultiplexerException(ChannelExists) and discards their reference, but the registry retains the orphan at the allocated index. AllocateChannelIndex is monotonic and never recycles, so the index leaks too.

Bug

Two-step insert with no rollback:

internal void RegisterWriteChannel(ushort index, WriteChannel channel)
{
    if (!_writeChannels.TryAdd(index, channel))
        throw new MultiplexerException(...);          // step 1
    if (!_idToIndex.TryAdd(channel.ChannelId, index))
        throw new MultiplexerException(...);          // step 2 — leaves step 1 in place
}

When two OpenChannel("dup") calls race, both allocate distinct indices (4 and 6) and both insert into _writeChannels successfully. Whichever loses on _idToIndex.TryAdd("dup", _) throws — and _writeChannels[loser_index] is now populated with a channel the caller no longer holds a reference to. Consequences:

  • GetWriteChannel(loser_index) returns the orphan.
  • GetAllWriteChannels() enumerates it (mux teardown does redundant work; events fire to nobody).
  • The 16-bit index space leaks by one slot per losing race; combined with MaxDataChannel, sustained races can exhaust it.

Fix

Roll back the per-index insert on second-step failure. Mirror for both RegisterWriteChannel and RegisterReadChannel. Use ConcurrentDictionary.TryRemove(KeyValuePair) so we only remove the exact entry we just inserted (defensive against any concurrent rewrite of the same index by another caller, though the index allocator is monotonic so this is belt-and-suspenders).

if (!_idToIndex.TryAdd(channel.ChannelId, index))
{
    _writeChannels.TryRemove(new KeyValuePair<ushort, WriteChannel>(index, channel));
    throw new MultiplexerException(ErrorCode.ChannelExists, ...);
}

Regression Tests

Added tests/NetConduit.UnitTests/ChannelRegistryTests.cs with two tests exercising both code paths directly through the registry's internal API (NetConduit.UnitTests has InternalsVisibleTo):

  • RegisterWriteChannel_IdCollision_RollsBackIndexEntry
  • RegisterReadChannel_IdCollision_RollsBackIndexEntry

Each test registers a channel at index N with ID "dup", then attempts to register a second channel at index M (≠ N) with the same ID, asserts the throw, and asserts GetWriteChannel(M) == null (resp. GetReadChannel(M) == null).

Pre-fix verification: stashed the production change, re-ran the two tests — both fail with Assert.Null() Failure: Value is not null returning the orphan channel. Post-fix: both pass.

Verification

  • dotnet build → 0 warnings, 0 errors across net8/net9/net10
  • dotnet test → 1014 passed / 1 skipped (MemoryLeak_SubMuxChaos_MemoryStaysBounded), 3 known integration flakes confirmed passing in isolation (TcpMultiplexerFactoryLeakTests.CreateOptions_EndpointOverload_FactoryCancelledDuringConnect_DoesNotLeakHandles, WebSocketMultiplexerTests.CreateOptions_ConnectsAndTransfersData, IpcMultiplexerTests.MultipleChannels_TransferData)

Files Changed

  • src/NetConduit/Internal/ChannelRegistry.cs — rollback per-index entry on id-collision throw
  • tests/NetConduit.UnitTests/ChannelRegistryTests.cs — regression tests for both register paths

@Kiryuumaru Kiryuumaru merged commit e80bcf0 into master May 21, 2026
35 checks passed
@Kiryuumaru Kiryuumaru deleted the fix/channel-registry-orphan-on-id-collision branch May 21, 2026 07:52
Kiryuumaru added a commit that referenced this pull request May 22, 2026
…to (channelId, index) pair (fixes #228) (#344)

ChannelRegistry.UnregisterChannel called
_idToIndex.TryRemove(channelId, out _) unconditionally. If a caller
passed a (index, channelId) pair where _idToIndex[channelId] mapped to
a *different* (legitimate) index — for example, while cleaning up a
RegisterReadChannel call that failed on the per-id race — the
legitimate channel's ID lookup was destroyed, leaving the legitimate
channel reachable only by index. GetReadChannelById / GetWriteChannelById
would return null for a channel that was still alive in the registry.

The ghost-channel half of #228 was already fixed in #280 (rollback in
RegisterRead/WriteChannel). Today both current call sites of
UnregisterChannel pass matching pairs, so the bug is latent — but the
suggested fix from #228 is a defensive hardening that prevents future
regressions.

Fix: remove via ConcurrentDictionary's KeyValuePair overload, which
only removes if the value still matches the supplied index.

Tests:
- UnregisterChannel_MismatchedIndex_DoesNotRemoveLegitimateIdMapping —
  asserts a mismatched (index, channelId) call leaves the legitimate
  GetReadChannelById lookup intact.
- UnregisterChannel_MatchingPair_RemovesIdMapping — asserts the normal
  path still removes the ID mapping.

All 437 mux unit tests 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

1 participant