248 Commits

Author SHA1 Message Date
Darren Horrocks 39ea15f90b Fix uTP outbound connections (libutp seq/ack handshake alignment)
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).
2026-07-07 20:49:53 +01:00
Darren Horrocks 9c59fc1513 Make the LPD listener thread a background thread
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.
2026-07-07 17:25:00 +01:00
Darren Horrocks 4f1d095ac4 Add DHTClient routing-table export/import (GetKnownNodes/AddKnownNodes)
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).
2026-07-07 17:05:32 +01:00
Darren Horrocks e0a4f53f65 Reject peer handshakes whose info-hash doesn't match ours
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.
2026-07-07 09:45:23 +01:00
Darren Horrocks 0f0437f938 Fix real runtime bugs found while building the bzTorrentClient frontend
- 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.
2026-07-06 16:17:28 +01:00
Darren Horrocks 7905d69c9c docs: record 4.0 deferred-work completion (D1-D12) in plan.md 2026-07-04 00:33:12 +01:00
Darren Horrocks 5e45e2de35 4.0 D11: extract DHTQueryResponder (DHT server role)
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).
2026-07-04 00:32:37 +01:00
Darren Horrocks 0ebe009373 4.0 D10: IExtensionHost abstraction for BEP-10 extensions (DIP)
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).
2026-07-04 00:29:44 +01:00
Darren Horrocks c02306d1e7 4.0 D9: segregate ISocket into connectable/datagram/listener capabilities
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).
2026-07-04 00:27:42 +01:00
Darren Horrocks 78d387e81b 4.0 D7: segregate IPeerWireClient into control/sender/events (ISP)
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).
2026-07-04 00:25:07 +01:00
Darren Horrocks 23fb76561e 4.0 D5: extract TrackerUrlEncoder from BaseScraper (SRP)
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).
2026-07-04 00:16:39 +01:00
Darren Horrocks c2dc1adfc9 4.0 D12: PeerWireListener injectable factory + fix StopListening
- 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).
2026-07-04 00:13:54 +01:00
Darren Horrocks 58052ec8ee 4.0 D6: rename Metadata.Load(BDict) + portable path separator
- 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).
2026-07-04 00:11:36 +01:00
Darren Horrocks b2150f66bf 4.0 D8: make PeerWireClient.ProcessCommand fully table-driven
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).
2026-07-04 00:08:32 +01:00
Darren Horrocks 6828f676c3 4.0 D4: replace 11-parameter Announce with AnnounceRequest options object
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).
2026-07-04 00:06:33 +01:00
Darren Horrocks 45539e96cf 4.0 D2: introduce InfoHash value type
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).
2026-07-04 00:04:00 +01:00
Darren Horrocks 9a4bcdf3f6 4.0 D3: promote AnnounceInfo/ScrapeInfo to top-level types
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).
2026-07-04 00:01:23 +01:00
Darren Horrocks a3b061f631 4.0 D1: remove PeerMessageBuilder IDisposable + bump to 4.0.0
IDisposable only cleared a managed List<byte> -- no unmanaged resource, pure
ceremony. Breaking for any using-consumer (none in-tree). Branch targets 4.0.
2026-07-04 00:01:23 +01:00
Darren Horrocks ea95ed9d3d Phase 11: misc client cleanups
- 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).
2026-07-03 23:48:44 +01:00
Darren Horrocks 8efed3c609 Phase 10: extract TorrentBuilder + Metadata correctness/DRY fixes
- 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).
2026-07-03 23:42:12 +01:00
Darren Horrocks 8b13ad1de7 Phase 9: move duplicated tracker Announce overloads into BaseScraper
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.
2026-07-03 23:34:06 +01:00
Darren Horrocks 27203df620 Phase 8: shared guarded bencode accessor + FastExtensions dedup
- 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.
2026-07-03 23:30:01 +01:00
Darren Horrocks 5e885842fc Phase 7: extract DHTTransactionRegistry + dedup query/node handling
- 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).
2026-07-03 23:23:31 +01:00
Darren Horrocks 7b3efa11b5 Phase 6: dedup PeerWireClient Send* methods + kill magic message ids
- 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).
2026-07-03 22:56:28 +01:00
Darren Horrocks e5e42266a9 Phase 5: split MessageStreamEncryption into focused types
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).
2026-07-03 22:44:34 +01:00
Darren Horrocks 8542fad827 Phase 4: PeerWireConnection SRP/DRY cleanup
- 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).
2026-07-03 22:25:04 +01:00
Darren Horrocks 4b790e4275 docs: record Phase 3 status and scope decisions in plan.md 2026-07-03 20:52:00 +01:00
Darren Horrocks 7c64ae8c50 Phase 3.2 / deferred Phase 2: split UTPSocket into UTPConnection + UTPListener
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.
2026-07-03 20:51:39 +01:00
Darren Horrocks 8e03e36a11 Phase 3.0: fix UDPSocket LSP violation + de-dupe Accept
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).
2026-07-03 20:42:25 +01:00
Darren Horrocks c465b37d94 Phase 2 (partial): extract UTPPacketCodec from UTPSocket
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).
2026-07-03 20:05:56 +01:00
Darren Horrocks 4663f38553 Phase 1: fix parallel-implementation bugs
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).
2026-07-03 19:45:44 +01:00
Darren Horrocks 2ca170b106 Phase 0: extract shared codecs (SOLID/DRY cleanup)
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.
2026-07-03 19:39:40 +01:00
Darren Horrocks 09969993b0 remove unneeded files 2026-07-02 23:08:10 +01:00
Darren Horrocks 6211a0ddf5 Add uTP passive-open support (Listen/Accept)
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.
2026-07-02 23:07:21 +01:00
Darren Horrocks d8decd2599 Add IPv6 support: BEP-7 tracker extension + BEP-32 DHT extension
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.
2026-07-02 22:45:44 +01:00
Darren Horrocks 819e7cfa18 Add BEP-55 holepunch extension
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.
2026-07-02 22:35:04 +01:00
Darren Horrocks 006dc4626d Add BEP-19 WebSeed support
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.
2026-07-02 22:28:54 +01:00
Darren Horrocks 3ff5a9ed2a Support MSE/PE over UTPSocket
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.
2026-07-02 22:23:28 +01:00
Darren Horrocks 2f3c46b8a5 Add RTO-based retransmission and out-of-order delivery to UTPSocket
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.
2026-07-02 22:16:48 +01:00
Darren Horrocks e1c61fe96d Add .torrent file creation (Metadata writer)
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).
2026-07-02 22:04:46 +01:00
Darren Horrocks b809626650 doc updates version/4.0.0 2026-07-01 16:36:40 +01:00
Darren Horrocks cb1789f771 Add LEDBAT-based congestion control to UTPSocket
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.
2026-07-01 16:10:51 +01:00
Darren Horrocks d128574e29 final mse/pe changes 2026-07-01 15:16:54 +01:00
Darren Horrocks 49429c47a9 fix mse/pe 2026-07-01 14:28:41 +01:00
Darren Horrocks 839aaab485 MSE/PE work in progress 2026-07-01 12:50:16 +01:00
Darren Horrocks b94ae5e95f various correctness fixes and updates to the demo project 2026-07-01 12:17:43 +01:00
Darren Horrocks 2300378873 Delete .github/dependabot.yml 2026-06-30 14:43:46 +01:00
Darren Horrocks 38ad754883 broken test version/3.0.2 2026-06-29 10:13:55 +01:00
Darren Horrocks b4d4d39bfd small build fixes version/3.0.1 2026-06-29 10:07:46 +01:00
Darren Horrocks cfaa040fd0 release pipeline version/3.0.0 2026-06-29 09:46:38 +01:00