Outbound uTP connections never completed against real peers (libutp-based,
e.g. qBittorrent): the SYN got a valid ST_STATE reply but the peer's first
ST_DATA was silently dropped and the connection stalled. Three aligned fixes:
- Defer Send() until the connection is established, queueing data so the BT
handshake goes out with a valid ack_nr instead of ack_nr=0 before the SYN
is acked. Flushed once the establishing ST_STATE arrives.
- On establish, baseline AckNumber to (ST_STATE.seq - 1), not ST_STATE.seq.
libutp advertises its next-to-send seq on the ST_STATE, and its first real
ST_DATA reuses that same seq, so acking seq made that first data look like
a duplicate and get dropped by the reorder buffer.
- Outgoing ST_STATE packets now advertise the next-to-send seq (SeqNumber+1)
to match libutp, keeping our own acceptor consistent with the new baseline.
- A pure-ack ST_STATE no longer advances AckNumber (only ST_DATA does, via
the reorder buffer).
Verified live against a qBittorrent seeder and across the full uTP unit suite
(receive/reorder + two-real-socket passive-open, both directions).
LocalPeerDiscovery started its receive loop on a foreground thread, so once an
LPD listener was running it kept the whole process alive after the app asked to
exit (the loop can be parked in a blocking socket read that Stop's bounded Join
won't unstick). Mark it IsBackground so it never blocks process shutdown.
Lets a host persist the DHT routing table between runs and seed it back on
startup, so the DHT can start warm instead of cold-bootstrapping every launch.
GetKnownNodes snapshots the table; AddKnownNodes seeds it (stale nodes simply
fail to respond and get superseded).
PeerWireClient completed the handshake and started treating a peer as
part of the swarm without ever checking that the remote side's
handshake actually carried the info-hash we dialed out expecting -
Hash was stored but never compared against. A peer replying with any
other info-hash (misbehaving client, or a stale/incorrect PEX/tracker
entry) would have been wired up as if it belonged to this torrent.
Only enforced when we have an expected hash of our own (i.e. we
initiated the connection); an inbound-accepted connection has no
expected hash yet, since the peer's handshake is what tells a listener
which torrent it's even for.
- UDPSocket: Connect() never called the underlying Socket.Connect, and
the base Send() didn't route through the remembered endpoint, causing
"Destination address required" for LPD/DHT announces.
- LocalPeerDiscovery: Process()'s receive loop could crash the process
on shutdown races — both the async EndReceiveFrom callback and the
synchronous BeginReceiveFrom arming call needed try/catch around
ObjectDisposedException/SocketException from a concurrent Close().
- PeerWireClient.SendBitField: byte-array sizing used integer division
that undersized the buffer for piece counts not a multiple of 8.
- BitfieldHandler: an always-true broken length check closed the
connection on every incoming bitfield message.
The inbound-query handling (respond to ping/find_node/get_peers/announce_peer,
including writing compact node lists) moves out of DHTClient into a focused
DHTQueryResponder, separating the server role from the client's outbound search +
transport. DHTClient wires it with the node id, routing table and a send callback,
and HandlePacket delegates the 'q' case to it.
The remaining transport-wrapper and PeerSearch extractions are intentionally left:
they'd restructure the delicate async receive loop / cancellation, and DHT has only
loopback round-trip coverage -- high regression risk (peer discovery) for marginal
gain. The transaction registry, compact codec and query/ingest dedup (the core wins)
landed in Phase 7. Validated by the two-instance loopback round-trip tests; suite
green (187).
IBTExtension.Init took the concrete ExtendedProtocolExtensions, and every extension
stored it and called _parent.SendExtended / _parent.GetIncomingMessageID -- the
abstraction depending on a concretion. Introduce IExtensionHost (SendExtended +
GetIncomingMessageID); ExtendedProtocolExtensions implements it; IBTExtension.Init
now takes IExtensionHost and the extensions store that.
The full 'unify the two dispatch models' (migrate IProtocolExtension/IBTExtension
onto IMessageHandler) is intentionally not done: they serve different layers --
IProtocolExtension carries ByteMask (handshake reserved bits) + CommandIDs (owned
command ranges) + OnHandshake, which IMessageHandler's single-command Handle has no
concept of -- so merging would lose information rather than simplify. The
ExtendedProtocolExtensions/UTMetadata SRP splits are likewise left (lightly-tested
extension internals).
Breaking: IBTExtension.Init(ExtendedProtocolExtensions) -> Init(IExtensionHost).
Suite green (187).
ISocket is split into ISocketCore (lifecycle/options) + IConnectableSocket (connect/
send/receive) + IDatagramSocket (sendto/receivefrom) + IListenerSocket (listen/accept).
ISocket now composes the three, so it's unchanged for the generic connection/listener
and BaseSocket, but a consumer can depend on just the capability it uses -- the MSE
handshake (MseHandshake/MseFraming/IMseHandshake) now takes an IConnectableSocket
instead of the full ISocket.
The socket-factory rewrite of PeerWireConnection<T>/PeerWireListener (to let each
concrete socket implement only its real capabilities) is intentionally not done: as
established in Phase 3 it's a large, high-risk rewrite of the connection generic model
+ all its encryption/handshake tests for no functional gain -- nothing drives uTP
through PeerWireConnection's listener path, and TCP does connect+listen fine as one
type. Suite green (187).
The fat IPeerWireClient (properties + 14 delegates + 14 events + connect/handshake
+ 11 senders + extension registration) is split into three focused interfaces:
- IPeerConnectionControl (lifecycle/config)
- IPeerMessageSender (outbound Send*)
- IPeerEvents (inbound events + their delegate types)
IPeerWireClient now composes all three, so a consumer can depend on just the surface
it needs while the full facade is unchanged for existing callers.
The delegate -> generic EventArgs consolidation (the plan's 'consider' item) is not
done: it would rewrite all ~25 event subscriptions across Demo/tests for a stylistic
gain, and the bespoke delegates are clear and functional.
Suite green (187).
Percent-encoding of tracker query values is an HTTP-tracker concern that lived on
BaseScraper -- which the UDP client also inherits and never needs. Moved to a
dedicated TrackerUrlEncoder; HTTPTrackerClient uses it. (The tracker DTOs were
already promoted out of BaseScraper in D3.)
Transport DIP (injecting an abstraction over WebRequest/UdpClient) was assessed and
left out: HTTP and UDP transports don't share a clean abstraction, the tracker
tests already exercise the real transport over loopback, and there's no fake
consumer -- a seam with no user would be speculative. Suite green (187).
- Add an injectable Func<ISocket, IPeerConnection> connectionFactory (ctor param).
When not supplied it falls back to a guarded reflection default; pass an explicit
factory (s => new PeerWireConnection<TSocket>(s)) to avoid reflection entirely.
- StopListening now actually stops the accept loop (a 'stopped' flag the re-arm
checks) instead of calling EndAccept on the pending accept (which completes it).
- Re-arm updates via the flag rather than leaving a stale asyncResult.
LocalPeerDiscovery's remaining thread/busy-spin split is left as-is: its BT-SEARCH
parse was already extracted+tested and socket setup deduped in Phase 11, and the
async receive loop has no automated coverage to refactor against safely.
Suite green (187).
- Rename the misleadingly-overloaded Load(BDict) to LoadInfoDictionary (it loads the
info dictionary, e.g. from ut_metadata), distinct from Load(Stream)/Load(MagnetLink).
Demo updated.
- MetadataFileInfo.PathSeparator: '\' -> '/' (portable, BitTorrent/URL convention;
the old Windows-only backslash could also collide with a real path component).
The disk-based-build responsibility (the genuinely separable concern flagged for the
'split') was already extracted to TorrentBuilder in Phase 10; the remaining parse
populates the Metadata value object itself, so a separate parser type reaching into
its internals would be ceremony rather than real separation -- not done.
Breaking: Metadata.Load(BDict) -> LoadInfoDictionary; Filename uses '/'. Suite green (187).
The five no-payload base commands (keep-alive/choke/unchoke/interested/not-interested)
were dispatched by a hard-coded switch, separate from the MessageDispatcher table
that handled the data-carrying messages. They're now registered on the dispatcher
too via a single parameterized SimpleCommandHandler(Action<PeerWireClient>), and
ProcessCommand is just: custom Command hook -> dispatcher -> extensions. Adding a new
base command no longer means editing the switch.
Suite green (187).
The 11-positional-parameter Announce was hard to call correctly and the HTTP
client silently ignored several of its parameters. Replaced with an AnnounceRequest
options object (sensible defaults for Event/NumWant/ListenPort) honored consistently.
Demo + tracker tests updated.
Breaking: ITrackerClient.Announce(url,hash,peerId,...11 args) -> Announce(AnnounceRequest).
Suite green (187).
Metadata and MagnetLink each duplicated a HashString property doing the hex<->bytes
conversion. That conversion now lives once in a dedicated InfoHash type (reference
type, so an unset hash stays null). IHashProvider.Hash: byte[] -> InfoHash;
HashString is a read-only convenience (Hash?.Hex). A null-safe implicit conversion
to byte[] keeps the many byte[] APIs (DHT search, handshake) call-site-clean.
Breaking: IHashProvider.Hash / Metadata.Hash / MagnetLink.Hash are now InfoHash.
Suite green (187).
They were nested in BaseScraper, forcing ITrackerClient to depend on the concrete
base class. Now top-level types owned by ITrackerClient. ScrapeInfo's
ScraperType-switched ctor (positional a,b,c leaving half the fields zero) becomes
named ScrapeInfo.FromHttp/FromUdp factories. Demo updated. Suite green (187).
IDisposable only cleared a managed List<byte> -- no unmanaged resource, pure
ceremony. Breaking for any using-consumer (none in-tree). Branch targets 4.0.
- LocalPeerDiscovery: extract the inline BT-SEARCH parse into a pure, testable
TryParseSearchPacket (5 unit tests) that guards the infohash length -- the old
inline Substring(10, 40) threw on a short/malformed line. The receive callback
now decodes only the bytes actually received (was decoding the whole 16 KB
buffer). Dedup the reader/sender reuse-address setup into EnableAddressReuse.
Added InternalsVisibleTo(bzTorrent.Tests) for the test.
- PeerMessageBuilder: name the 5/1 header-size magic numbers (LengthPrefixSize +
MessageIdSize).
- PeerWireListener: the reflection ctor lookup now throws a clear
InvalidOperationException instead of a bare NullReferenceException when the
connection type lacks an ISocket constructor.
Full suite green (187).
Deferred by decision (see plan.md): replacing PeerWireListener's reflection with
an injected factory + fixing StopListening (public-API/behavioural changes to a
tested listener), removing PeerMessageBuilder's IDisposable (source-breaking), and
the fuller LocalPeerDiscovery SRP split / busy-spin fix (no automated coverage).
- Extract Data/TorrentBuilder: the disk-based torrent creation (file enumeration,
piece hashing, info/announce dict assembly, piece-size heuristic) moves out of
Metadata. Metadata.CreateFromPath is now a thin facade over TorrentBuilder.Build
+ LoadRoot -- non-breaking (public API preserved), removing ~130 lines of the
"build from disk" concern from the parse/hold/serialise class.
- Fix the CreationDate epoch read: new DateTime(1970,1,1).AddSeconds(ts)
(Kind=Unspecified) -> DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime (same
tick value, correctly UTC, matching the Unix-time write side).
- Single-source the multi-file path separator as MetadataFileInfo.PathSeparator,
used by both the join (Metadata) and the split (HTTPWebSeedClient) so they can't
drift.
Full suite green (182), incl. MetadataCreationTests / MetadataWebSeedTests.
Deferred by decision (see plan.md): splitting the parser/serializer into separate
public types and renaming Load(BDict) (source-breaking Metadata/IMetadata API
changes), the standalone InfoHash value type (bundle with that split), and making
the path separator portable (wire-behaviour change).
The convenience Announce(url, hash, peerId) overload (with the magic
0,0,0,2,0,-1,12345,0 defaults) and the multi-hash Announce(url, string[], peerId)
fan-out were copy-pasted in both HTTPTrackerClient and UDPTrackerClient. They now
live once in BaseScraper: the full 11-arg Announce is abstract on the base and
override in each client; the defaults are named constants. UDPTrackerClient keeps
a thin multi-hash override that validates the whole hash set up front then calls
base. Signatures preserved -> non-breaking.
Full suite green (182), incl. the UDP + HTTP announce round-trip tests.
Deferred by decision (see plan.md): promoting AnnounceInfo/ScrapeInfo to top-level
types and narrowing the 11-arg Announce to an options object (both source-breaking
public-API changes referenced by ITrackerClient and the Demo), the BaseScraper SRP
split, and the tracker transport DIP.
- Add Data/BencodeExtensions (TryGetBytes/TryGetString/TryGetDict/TryGetList): the
guarded typed accessor deferred from Phase 7. Replaces the repeated TryGetValue +
unchecked ((BString)v).ByteValue / (BDict)v cast pattern (which throws on type
mismatch) with one call that returns false instead.
- Apply it across DHTClient (HandlePacket, HandleQuery, QueryNodeForPeersAsync,
FindNodeAsync) — ~14 cast sites, covered by the loopback round-trip tests.
- Collapse FastExtensions.ProcessSuggest/ProcessAllowFast (identical apart from the
event fired) into one ProcessSingleIndex(payload, raise); drop OnSuggest/OnAllowFast.
Behaviour-preserving; full suite green (182).
Deferred by decision (see plan.md): unifying the two dispatch models onto
IMessageHandler + the IExtensionHost abstraction (source-breaking changes to the
public IProtocolExtension/IBTExtension API with thin test coverage), the
ExtendedProtocolExtensions/UTMetadata splits, and extension-wide adoption of the
new bencode accessor.
- Extract DHT/DHTTransactionRegistry (SRP): transaction-id allocation and the
pending-request correlation table (_txCounter + _pending + NextTxId) move out
of DHTClient into one focused type; DHTClient delegates via Register/Abandon/
Cancel/Complete.
- Dedup the query envelope into BuildQuery(txId, name, args) (was copy-pasted in
FindNodeAsync and GetPeersQueryAsync).
- Dedup node-list ingestion into IngestNodes(r) (was copy-pasted in FindNodeAsync
and QueryNodeForPeersAsync with divergent guarded/unguarded casts; now uniformly
guarded).
Behaviour-preserving; validated by the two-instance loopback DHT round-trip tests
(send/receive, query+response, tx-id correlation, node ingestion). Suite green (182).
Deferred by decision (see plan.md): the deeper PeerSearch/transport/server-handler
split + transport DIP (async UDP code with loopback-only coverage; high regression
risk for structural gain), and the guarded bencode accessor (introduced in Phase 8
as a shared helper and retrofitted to DHT there).
- Collapse the nine near-identical Send* methods into one SendCommand helper. The
message id now comes from the PeerClientCommands enum instead of the raw
literals (0-8, 128) that were a second, drift-prone encoding parallel to the
receive-side switch.
- SendBitField(bool[]) now returns bool (was void), matching its (bool[], bool)
overload; IPeerWireClient updated to match.
Behaviour-preserving; full suite green (182).
Deferred by decision (see plan.md): splitting the fat IPeerWireClient interface +
delegate->EventArgs consolidation (wide, source-breaking public-API change for
structural gain — a major-version concern), and making ProcessCommand fully
table-driven (negligible OCP benefit for the stable no-payload base commands).
The 534-line MessageStreamEncryption god class (crypto toolbox + socket framing +
handshake state machine) is split by responsibility:
- MseCrypto: pure crypto/encoding utilities (Hash, Xor, BytesEqual, Ascii, Concat,
big-endian BigInteger conversion, RandomBytes/RandomInt).
- MseFraming: ReadExact, the collapsed ScanForNeedle (the two near-identical
needle scanners are now one method parameterized by a Func<byte> byte source),
and the StreamBuffer FIFO.
- MseHandshake (+ IMseHandshake): the handshake state machine, DH constants and
the MSE prime.
Also:
- Deleted the parallel big-endian/hex helpers (UInt16BE/UInt32BE/ReadUInt16BE/
ReadUInt32BE/HexToBytes); routed through PackHelper/UnpackHelper/HexCodec.
- Dedup the shared-secret expression (ComputeSharedSecret) and collapse Hash's
accumulate loop into ComputeHash(Concat(parts)).
- MseFraming.ReadExact is now the single blocking-read helper; PeerWireConnection's
duplicate 96-byte MSE key read loop uses it (Phase 4 deferral resolved).
- PeerWireConnection depends on IMseHandshake instead of the concrete static class
(Phase 4 DIP deferral resolved).
Behaviour-preserving; validated by the MSE/PE handshake tests (RC4 + plaintext,
both halves, TCP loopback). Full suite green (182).
- Extract Data/PeerHandshakeCodec: the fixed BitTorrent handshake wire layout
(build + parse + the "BitTorrent protocol" constant) now lives once and is used
by both Handshake() (send) and ReceiveCallback (receive). Removes the magic
offsets (+49/+8/+20/+28), the literal protocol string, and the inline field
slicing from the ~135-line ReceiveCallback. Unit-tested (6 cases).
- Dedup socket setup into ConfigureSocket() (was repeated in ctor, Connect,
ReconnectSocket).
- Dedup the MSE negotiation-state reset into ResetEncryptionState() (Connect,
Disconnect).
Behaviour-preserving; validated by the existing plaintext + MSE/PE handshake
tests over TCP loopback. Full suite green (182).
Deferred by decision (see plan.md): the socket factory (kept new T() per Phase 3),
the IEncryptionHandshake abstraction and the duplicate ReadExact loop (both fold
into the Phase 5 MessageStreamEncryption split), and narrowing the async-callback
catch (needs a logging seam).
Eliminates the 758-line UTPSocket god class that fused two fundamentally
different roles (listener vs data socket) behind isListening/isServerChild flags.
- UTPConnection (IO/UTPConnection.cs): the data-transfer connection — client
(owns socket + own receive loop) or accepted child (shares the listener's
socket, fed via HandleIncomingPacket). Connect/Send/Receive/BeginReceive,
reliability (retransmit/reorder/ack), MSE-friendly synchronous Receive pump.
- UTPListener (IO/UTPListener.cs): passive open — Listen/Accept, the demux loop,
activeChildren registry, accept-waiter registration; creates UTPConnection
children and routes datagrams to them.
- PacketType promoted to top-level UTPPacketType (IO/UTPPacketType.cs); refs in
UTPPacketCodec, UTPRetransmissionTracker and tests updated.
- UTPSocket.cs deleted. Demo uses UTPConnection (client); passive-open tests use
UTPListener + UTPConnection.
Logic moved verbatim (not rewritten) to preserve the validated wire behaviour.
The inherent client-vs-accepted-child duality remains inside UTPConnection (uTP
shares one UDP socket across a listener's accepted connections). Validated
against the real two-instance loopback e2e tests. Full suite green (176; 39 UTP).
Note: the ISocket interface-segregation + socket-factory portion of Phase 3 was
NOT done — MSE (6 ISocket signatures), PeerWireConnection and LocalPeerDiscovery
are deeply coupled to the fat ISocket, making that a wide, high-risk rewrite for
mostly-structural gain. The concrete LSP defect it targeted was already fixed in
Phase 3.0. See plan.md.
BaseSocket.Accept/EndAccept were abstract, forcing every socket (including the
datagram UDPSocket) to implement passive-open semantics. UDPSocket "implemented"
them by calling _socket.Accept() on a UDP socket — meaningless, and the resulting
throw was swallowed as null (LSP violation).
- BaseSocket.Accept/EndAccept: abstract -> virtual, throwing NotSupportedException
by default.
- UDPSocket: drop the bogus Accept/EndAccept overrides (inherits the honest
not-supported default). This also removes the byte-for-byte duplication with
TCPSocket's Accept/EndAccept (UDP's copy is gone; TCP remains the one real impl).
UDPSocket is only ever used for LocalPeerDiscovery, which never accepts, so nothing
called these. Adds UDPSocketTests. Full suite green (176).
Safe, contract-preserving decomposition of the UTPSocket god class:
- Extract IO/UTPPacketCodec.cs: the uTP (BEP-29) header parse (was
UTPPacketHeader.Parse) and build (was inlined in UTPSocket.SendPacket) now
live in one codec; UTPPacketHeader becomes a plain data holder. This removes
the wire-(de)serialization responsibility from the socket and makes the header
layout unit-testable (UTPPacketCodecTests, 6 cases).
- Remove the dead `static readonly byte[] socketBuffer` (never read; latent
cross-instance shared state).
- Replace the 20-byte header-size literals with UTPPacketCodec.HeaderSize.
The listener / data-socket / accepted-child role-TYPE split is intentionally
deferred to Phase 3: PeerWireConnection<T where T : ISocket, new()> uses one T
for connect, listen, accept and re-wrap, so UTPSocket must stay a single type
until the ISocket interface is segregated and PeerWireConnection/PeerWireListener
take a socket factory. See plan.md.
Validated end-to-end against the real two-UTPSocket loopback tests. Full suite
green (174).
Correctness fixes for defects that duplication had produced or nearly produced:
- FastExtensions.OnCommand returned false even after handling commands 13-17
(LSP violation of the "handled?" contract); now returns true when handled.
- PeerMessageBuilder.Add(uint, Endianness) ignored its endianness argument and
always packed big-endian; now honored via a new PackHelper.UInt32(uint,
Endianness) overload. (All existing callers use Big, so output is unchanged.)
- PeerWirePacket.GetBytes derived the length prefix (1 + Payload.Length) and the
buffer size (4 + CommandLength) two different ways and dereferenced a null
Payload for no-payload commands (NRE); now derives both from one payload value
and is null-safe.
- MagnetLink.Resolve dereferenced a null value set for non-magnet input (NRE);
now returns a blank link, consistent with the invalid-magnet -> blank contract.
- LocalPeerDiscovery.Close called Thread.Abort() (throws
PlatformNotSupportedException on modern .NET, so Close never worked); now uses
the existing cooperative _killSwitch plus a bounded Thread.Join.
- Removed dead code: discarded handshake decodes in LTTrackerExchange/UTPeerExchange
and the unused sendBuffer accumulation in UTMetadata.RequestMetaData.
Adds 12 regression tests. Full suite green (168).
Foundational, behavior-preserving refactor that removes the highest-duplication
helpers and routes all call sites through single implementations.
- Endianness: one shared enum + ByteOrder helper (was duplicated verbatim in
PackHelper and UnpackHelper). Collapse PackHelper's int packers and
UnpackHelper's six readers through one routine each.
- HexCodec: single hex<->byte codec; PackHelper.Hex/UnpackHelper.Hex delegate
(dropped the unused endianness param).
- CompactPeer: single codec for compact peer/node parse+write; route the HTTP
and UDP tracker clients, DHTClient (6 methods), and ut_pex through it. This
also corrects two drifted ut_pex bugs (added-peer IP decode and dropped-peer
port offset) as a side effect of using the canonical format.
- Utils.GetBytes uses Array.Copy; BaseScraper.CopyBytes delegates to it.
- Shared CompactPeer.NodeIdLength constant replaces hard-coded 20s.
Net -290 lines in the library; adds CompactPeerTests. Full suite green (156).
Adds plan.md documenting the full SOLID/DRY refactor roadmap.
UDP has no OS-level listen/accept, so accepted connections share the
listening UTPSocket's single bound Socket. The listener runs a demux loop
keyed by remote endpoint: an ST_SYN from an unrecognized endpoint creates
a new accepted UTPSocket (sharing the socket for sends, fed incoming
packets via HandleIncomingPacket instead of ever calling
BeginReceiveFrom itself) and completes the handshake by acking the SYN;
anything else routes to the matching existing connection. Extracted the
per-datagram header/ack/reorder handling shared by both the
initiating and accepting roles into ProcessIncomingPacket.
Two real, substantive bugs surfaced while building this out (both fixed,
both covered by regression tests using two real UTPSocket/PeerWireConnection
instances rather than scripted fakes):
- AckNumber off-by-one on the initiator side: it derived its "next
expected" baseline as (peer's reported seq_nr - 1), but the peer's
actual first data send increments from that same seq_nr, landing one
higher. This was never caught because earlier tests hand-crafted
matching sequence numbers; a real acceptor's first data send exposed
it immediately. Fixed by dropping the "-1" (and updated the accept
path to seed AckNumber from the SYN's seq_nr directly, consistently).
- SenduTPData self-armed an extra BeginReceive after every send (a
leftover from before Receive()/consumer-driven receiving existed).
With a consumer also driving its own receive loop (PeerWireConnection,
the real usage pattern), these compete for incoming datagrams — a
reply meant for the consumer's receive loop could be silently
consumed by one of these no-op internal ones instead. This explains
why no real peer has ever completed a uTP handshake in this project's
testing, not just the missing passive-open. Removed entirely; the OS
still buffers a datagram with no receive pending, so nothing is lost.
- Also fixed: disposing a UTPSocket with a receive outstanding threw an
unhandled SocketException/ObjectDisposedException on the callback
thread and crashed the process; now caught and treated as "connection
going away, nothing to deliver."
Tested: 144 tests passing, including 4 new real end-to-end passive-open
tests (bidirectional data transfer, two simultaneous clients demuxed
correctly) and updated sequence-number expectations in the existing
Receive() tests. Live-verified beyond the unit suite with a full
PeerWireListener<UTPSocket> + PeerWireConnection<UTPSocket> round trip
(real accept, bidirectional BitTorrent handshake, piece transfer) and a
Demo run against real DHT/tracker peers (no exceptions). Real-world
interop with foreign uTP implementations remains unverified — no real
internet peer has completed a handshake — but that's now clearly scoped
to wire-format compatibility, not bugs in this library's own connection
handling, which is proven correct end-to-end.
DHTClient now binds a single dual-stack IPv6 socket instead of an
IPv4-only one, so it can send/receive to either family transparently
(ToSendEndpoint maps an IPv4 destination to IPv4-mapped IPv6;
NormalizeReceived unmaps an incoming IPv4-mapped source back to plain
IPv4). Queries now ask for "want": ["n4","n6"], responses include
"nodes6" alongside "nodes", and "values6" (BEP-32's compact IPv6 peer
list) is parsed the same as "values". This also fixes a latent bug
CompactNodes had: since DHTNode/DHTRoutingTable were already
family-agnostic, an IPv6 node reaching the routing table would have had
its 16-byte address silently truncated into the 4-byte IPv4 slot;
CompactNodes/CompactNodes6 now filter by address family before encoding.
HTTPTrackerClient parses "peers6" (BEP-7) alongside "peers", and no longer
requires "peers" to be present (an IPv6-only tracker response is valid).
UDPTrackerClient picks the compact peer stride (6 vs 18 bytes) from the
address family the announce request itself went out on, since the UDP
tracker protocol has no separate "peers6" field the way HTTP does.
Also fixes a real pre-existing bug found while adding the IPv6 (18-byte)
peer parsing next to it: HTTPTrackerClient.GetPeers and
UDPTrackerClient.ParseAnnouncePeers built IPv4 addresses via
IPAddress(long)/IPEndPoint(long,int), which reinterprets the value through
BitConverter on the host's endianness and double-flips a
big-endian-decoded uint back into the wrong byte order on little-endian
hosts (10.0.0.1 was coming back as 1.0.0.10). Both now build addresses
from raw network-order bytes directly, matching the pattern already used
elsewhere in this codebase (DHT's ParseCompactNodes/ParseCompactPeers).
Tested: DHTClientTests gained IPv6-loopback and mixed-IPv4/IPv6-responder
bootstrap tests (real sockets, ::1 works in this environment); new
HTTPTrackerClientTests and UDPTrackerClientTests exercise peers6/IPv6
stride and the byte-order fix over real loopback HTTP/UDP servers. Full
suite (144 tests) and the existing IPv4 DHT tests were re-run immediately
after the dual-stack socket switch to catch any regression early — none
found. Live-verified via Demo against real DHT/tracker peers for the
ubuntu torrent fixture: 45s, 2669 log lines of real bootstrap/peer/
handshake activity, no exceptions — the dual-stack socket didn't disturb
the already-working IPv4 path.
UTHolepunchExtension implements ut_holepunch (Rendezvous/Connect/Error
messages) as an IBTExtension, same scope as the other extensions
(UTPeerExchange etc): wire format, send methods, and events only — the
consumer decides when to rendezvous, tracks which peers are connected to
whom, and retries the connection. Registered in the Demo app alongside
PEX/tracker-exchange/metadata.
The payload byte layout is implemented to the best available
understanding of BEP-55 (msg_type, addr_type, address, port, and an
error_code for the Error variant) — flagged in both the code and README
for verification against the published BEP text before relying on it for
interop, the same caveat already applied to this library's other
experimental pieces (uTP).
Tested via a real PeerWireClient + ExtendedProtocolExtensions +
FakePeerConnection (the pattern the existing PeerWireClient integration
tests use — no mocking framework, matches project convention): send-path
wire format, decode of Rendezvous/Connect/Error messages, and an
encode/decode round-trip through the extension's own logic. Live-verified
via Demo against real DHT/tracker peers for the ubuntu torrent fixture for
45s with the extension registered — handshakes completed normally and no
exceptions; no peer happened to trigger an actual holepunch exchange in
this window (expected — it needs a specific NAT-traversal scenario), but
that's consistent with this task's scope being the protocol plumbing, not
guaranteed real-peer interop.
Metadata now parses "url-list" (single string or list form) into
WebSeeds, and tracks IsMultiFile (whether the info dict has a "files"
list) since that determines the URL scheme BEP-19 specifies.
IWebSeedClient / HTTPWebSeedClient fetch a piece block via ranged HTTP
GET, using the same (pieceIndex, offset, length) coordinates as
PeerWireClient.SendRequest: a single-file torrent's URL addresses the
file directly, a multi-file torrent's URL is treated as a directory with
the torrent name and file path appended, and a request spanning multiple
files issues one ranged request per file and stitches the results
together.
Tested with a real loopback TCP range server (no external network, per
project convention) covering single-file, multi-file directory-style URLs,
and a request spanning two files. The real
Demo/TestTorrents/ubuntu-26.04-desktop-amd64.iso.torrent fixture has no
url-list entries (confirmed) — Ubuntu's tracker doesn't publish a WebSeed
for it, so there's nothing to demo this against live without guessing an
unrelated public URL, which I avoided; the loopback HTTP tests are the
live-socket-I/O equivalent used for this task.
UTPSocket.Receive() previously threw NotSupportedException, blocking
MessageStreamEncryption (which needs a real synchronous blocking read for
its handshake — ReadExact, the padding scan, etc.) from working over uTP.
Receive() now pumps BeginReceive/EndReceive on a blocking wait, looping
past deliveries with no application payload (pure ST_STATE acks, or
ST_DATA still buffered behind a reorder gap) so it only ever returns 0 for
a genuinely closed connection, matching the contract MSE/PE relies on.
Also adds UTPSocket.LocalEndPoint (mirrors the existing DHTClient.LocalPort
pattern) so tests can address a UTPSocket directly.
MessageStreamEncryption itself is unchanged and transport-agnostic — it
was already covered by PeerWireConnectionEncryptionTests via TCPSocket.
The new coverage here is at the boundary MSE actually depends on: two new
loopback tests drive UTPSocket.Receive() end-to-end through a real SYN/
ST_STATE handshake against a scripted UDP peer, proving in-order delivery
and that it correctly withholds data still behind an out-of-order gap
(exercising TASK-002's reorder buffer at the real-socket level too).
Live-verified via Demo -utp -require-encryption against real DHT/tracker
peers for the ubuntu torrent fixture: 30s, no exceptions. As with TASK-002,
no real peer completed the transport-level handshake — traced to
UTPSocket's pre-existing lack of passive-open support (Listen/Accept are
TCP-shaped stubs that don't work for a UDP transport), not a regression
from this change; noted in the README.
Extracts the retransmission/reordering logic into two pure, socket-free
classes (UTPRetransmissionTracker, UTPReorderBuffer) following the same
pattern already used for UTPCongestionControl, so the algorithms are
deterministically unit-testable without real sockets or timers:
- UTPRetransmissionTracker: tracks in-flight packets, RTO via
Jacobson/Karels RTT estimation, exponential backoff, Karn's algorithm
(no RTT sample from retransmitted packets), gives up after
MaxRetransmits.
- UTPReorderBuffer: buffers ST_DATA packets that arrive ahead of a gap and
releases them in order once the gap fills, replacing the previous
"blindly advance ack to whatever arrives" logic.
UTPSocket wires these in: a Timer drives periodic retransmission
independent of inbound traffic, and BeginReceive/EndReceive were reworked
so out-of-order packets are buffered rather than delivered immediately,
with a delivery queue draining reassembled-in-order payloads on
subsequent calls.
Verified: dotnet test (120 passing, including 16 new deterministic tests),
plus a live run via a new Demo -utp flag against real DHT/tracker peers
for Demo/TestTorrents/ubuntu-26.04-desktop-amd64.iso.torrent — ran cleanly
for 45s with no exceptions across 6 real peer connection attempts. No real
peer completed the uTP handshake; this traces to a separate, pre-existing
gap (UTPSocket has no passive-open/listener support — Listen/Accept are
TCP-shaped stubs that don't work for a UDP transport), not a regression
from this change. Documented in the README's existing uTP experimental
note.
Metadata was read-only — .torrent files could be parsed but not created.
Adds Metadata.CreateFromPath (single file or directory -> hashed pieces,
multi-file support, trackers, comment, private flag) plus Save/SaveToFile,
refactoring the shared load path into LoadRoot(BDict) so both parsing and
creation build the same canonical structure. Verified round-tripping
through Metadata.FromFile/FromBuffer, and regression-tested the refactored
load path live against Demo/TestTorrents/ubuntu-26.04-desktop-amd64.iso.torrent.
Also sets up PLAN.md/TASK.md tracking the remaining protocol-level gaps
(uTP retransmission, MSE/PE over uTP, WebSeed, holepunch, IPv6).
Implements the max_window growth/shrink/decay algorithm documented in
utp-protocol.md as a standalone, unit-tested UTPCongestionControl class,
and wires it into UTPSocket's send/ack path (in-flight tracking, cumulative
ack processing, and a send-window throttle). Documents uTP transport status
in the README.