Stable cut. Phantom-alive wire detection (synthesis-aware). End-to-end verified against SpawnDev.ILGPU.P2P's P2PSwarm.TwoTab_PeerDiscovery (PASS 1m 37s standalone, was 90s timeout in the 4.9.2-rc.34 EOD known issue) AND LargeBuffer_100MB_DispatchedOverRealWebRtc_BitExact (PASS 3m 37s standalone, no regression).
Companion bumps: SpawnDev.WebTorrent.Server 3.2.3 and SpawnDev.WebTorrent.Server.HuggingFace 3.2.3 (version-sync, no source changes). See the rc.2 section below for the full surface description.
New SimplePeer.IsTransportDead virtual property (default false), overridden in RtcPeer to return true when the underlying transport is no longer reachable. Wire exposes a SimplePeer back-reference set in Peer.OnConnected so consumers walking torrent.Wires (notably SpawnDev.ILGPU.P2P.P2PWebRtcBridge) can detect transport death without scanning a separate collection.
RtcPeer.IsTransportDead consults a new _lastObservedPcState field FIRST (captured inside the _pc.OnConnectionStateChange event handler, which fires for both real native transitions AND synthesised "failed" from SpawnDev.RTC.Browser.BrowserRTCPeerConnection's 15s iceDisconnected debounce poller). Falls back to the native _pc.ConnectionState query only if the field hasn't been populated yet. The native query lies on Chromium-under-Playwright: when the poller invokes OnConnectionStateChange("failed") synthetically, the underlying JS native connectionState stays stuck at "connected" indefinitely.
_dcEverOpen is set inside WireDataChannel's channel.OnOpen (and the defensive "already open at subscribe time" branch), enabling IsTransportDead to distinguish "channel never opened (handshake)" from "channel was open and closed (terminal)" — when the data channel transitioned away from "open" after once being open, the transport is dead.
The 4.9.2-rc.34 known-issue from the SpawnDev.ILGPU.P2P 2026-04-29 EOD: P2PSwarm.TwoTab_PeerDiscovery failed at the 90s timeout because the bridge filter wireSet.RemoveWhere(w => w.Destroyed) couldn't distinguish phantom-alive wires (whose underlying transport was gone but whose Destroyed flag had not yet been set, because the close-event chain was still propagating) from real live wires. Result: peer never unregisters, coord.peerCount stuck at 1, test times out.
With the new accessor, the bridge can walk torrent.Wires and skip wire.SimplePeer?.IsTransportDead == true alongside the existing Destroyed skip. Verified: TwoTab_PeerDiscovery PASS in 1m 37s standalone (was failing in rc.34); LargeBuffer_100MB_DispatchedOverRealWebRtc_BitExact PASS in 3m 37s standalone (no regression vs rc.34).
SpawnDev.WebTorrent.Server 3.2.3-rc.2: version-sync bump only.SpawnDev.WebTorrent.Server.HuggingFace 3.2.3-rc.2: version-sync bump only.
Stable rollup of 3.2.2-rc.1, 3.2.2-rc.2, 3.2.2-rc.3, and the polling-based SCTP backpressure fix shipped in commit 7fd70c5. Five SCTP / wire correctness improvements over 3.2.1. No breaking changes. Verified via SpawnDev.ILGPU.P2P real-WebRTC integration tests (1MB / 10MB / 100MB / multi-peer scenarios).
Pulls in SpawnDev.RTC 1.1.8 stable which is the OTHER half of the SCTP backpressure story (Desktop OnBufferedAmountLow now actually fires; was silent in 1.1.7). Without RTC 1.1.8 the backpressure wait pattern below has nothing to wait on.
_bufferedAmountLowTcs is now a SHARED TaskCompletionSource that every concurrent Send caller references at the time of their await. OnBufferedAmountLow completes the shared TCS to release ALL awaiters at once, then atomically installs a fresh TCS for the next round. Replaces the rc.1 Interlocked.Exchange pattern that orphaned all but the latest concurrent awaiter and timed out at 30s in P2P's 8-chunk pipeline.
OnBufferedAmountLow is best-effort even with the rc.2 multi-awaiter fix. DesktopRTCDataChannel's 20ms-tick poller fires only on a strict above-then-below transition observed between consecutive ticks; rapid SCTP drains miss the transition entirely and the event never fires for the next wait cycle.
Send now races the event TCS against a 50ms Task.Delay:
await Task.WhenAny(tcs.Task, Task.Delay(50)).ConfigureAwait(false);The event is still honored (resolves promptly when it fires); the 50ms poll guarantees forward progress when the event is missed. The loop's top-of-iteration BufferedAmount re-check then either breaks out (drained) or installs a new TCS for the next round. The 120-second wall-clock ceiling now covers genuinely-stalled SCTP rather than relying on an event we can't trust.
Diagnosed via [RtcPeer][BACKPRESSURE-DIAG] against LargeBuffer_100MB_DispatchedOverRealWebRtc_BitExact: BufferedAmount=0 at the prior 30s/120s timeout, ReadyState=open - buffer drained, no event fired. After the fix: 100MB PASSES in ~5 min (was failing reliably with TimeoutException at 1m37s-7m7s across 3 prior runs).
Phantom destroyed-wire entries no longer inflate isLastWireForCanonical to false indefinitely. The bridge filter wireSet.RemoveWhere(w => w.Destroyed) runs before counting in wire.OnClose. Closes the SpawnDev.ILGPU.P2P P2PSwarm.TwoTab_PeerDiscovery regression where coord.peerCount stayed at 1 for the full 90s budget after the worker tab closed.
Replaces the prior 1.1.8-rc.4 transitive. RTC 1.1.8 has three additive fixes:
- Desktop
OnBufferedAmountLownow actually fires (was silent in 1.1.7) BrowserRTCPeerConnectionconnection-state polling fallback (Chromium-under-Playwright)- Opt-in
BrowserRTCPeerConnection.DiagnosticsEnabledflag
RtcPeer.Send no longer relies solely on the OnBufferedAmountLow event to wake from a backpressure wait. The event is best-effort on desktop - DesktopRTCDataChannel's poller fires only on a strict above-then-below transition observed between 20ms polls. If BufferedAmount overshoots threshold and drains back BETWEEN poll ticks (rapid SCTP drain on 100MB+ transfers), the poller's wasAboveThreshold flag stays false and the event NEVER fires for the next wait cycle.
The previous 30s/120s WaitAsync would then fire a TimeoutException despite BufferedAmount=0 and ReadyState=open. The exception cascaded into Peer.SendRaw -> peer.Destroy(null) -> wire close cascade -> dispatcher HandlePeerLost -> "P2P dispatch failed, no peers for retry: Peer X disconnected".
Diagnosed 2026-04-29 against SpawnDev.ILGPU.P2P's LargeBuffer_100MB_DispatchedOverRealWebRtc_BitExact via [RtcPeer][BACKPRESSURE-DIAG] instrumentation:
Wait timeout iter=1 BufferedAmount=0 MaxBuffered=65536 elapsed=120.0s
ReadyState=open dataLen=261663 bufferDelta=1
Buffer fully drained, channel healthy, event never fired.
Fix: Send races the event TCS against a 50ms Task.Delay:
await Task.WhenAny(tcs.Task, Task.Delay(50)).ConfigureAwait(false);The event is still honored (resolves promptly when it fires); the 50ms poll guarantees forward progress when the event is missed. The loop's top-of-iteration BufferedAmount re-check then either breaks out (drained) or installs a new TCS for the next round. The 120-second wall-clock ceiling now covers genuinely-stalled SCTP rather than relying on an event we can't trust. BACKPRESSURE-STALL diagnostic fires only on the rare 120s stall (real wire failure).
Result: LargeBuffer_100MB_DispatchedOverRealWebRtc_BitExact PASSES in ~5 min (was failing reliably at 1m37s-7m7s with TimeoutException across 3 prior runs).
SpawnDev.RTC 1.1.8-rc.4 dep bump. No code changes. Pulls in the opt-in BrowserRTCPeerConnection.DiagnosticsEnabled flag for debugging the polling-fallback path. Used by SpawnDev.ILGPU.Demo for PMT P2PSwarm.TwoTab_PeerDiscovery diagnostics.
SpawnDev.RTC dep bump to 1.1.8-rc.1. The rc.2 SCTP backpressure multi-awaiter fix was correct on its own but turned out to be insufficient on desktop because SpawnDev.RTC 1.1.7's DesktopRTCDataChannel.OnBufferedAmountLow was declared but never fired (SipSorcery has no native event hook). 1.1.8-rc.1 emulates the spec'd edge-triggered semantics via a 20ms-tick poller. Together the two fixes:
- rc.2: shared TCS pattern releases ALL concurrent
Send()callers when OnBufferedAmountLow fires (was orphaning all but the most recent via Interlocked.Exchange). - 1.1.8-rc.1: actually emits OnBufferedAmountLow on desktop (was silent in 1.1.7).
Without 1.1.8-rc.1 the rc.2 multi-awaiter improvement was a no-op on desktop because there was no signal to wait on; the 30-second WaitAsync timeout fired regardless of how clever the awaiter pattern was. Diagnosed via [RtcPeer-DIAG] instrumentation on SpawnDev.ILGPU.P2P's LargeBuffer_1MB_DispatchedOverRealWebRtc_BitExact: the timeout fired with BufferedAmount=0 and ReadyState=open, proving SCTP had drained and the only thing missing was the signal.
After both fixes: 1MB transfer passes 3/3 standalone in ~135s; 10MB passes (eventually) in 155-260s. Browser side unchanged - JS WebRTC fires OnBufferedAmountLow natively.
Cleanup: also reverts the temporary [Peer] Destroy stack-trace logging that was added during rc.2 diagnosis.
Critical follow-up to rc.1's SCTP backpressure work. The rc.1 implementation correctly added the OnBufferedAmountLow wait gate but used Interlocked.Exchange to install the awaiter's TCS into a single shared slot - which meant every concurrent Send() caller orphaned the previous one. Diagnosed against SpawnDev.ILGPU.P2P's LargeBuffer_1MB_DispatchedOverRealWebRtc_BitExact reproduction: the test hung for 109s across 3 retries with "No healthy peers available for dispatch", traced via stack-trace instrumentation to RtcPeer.Send line 336 (tcs.Task.WaitAsync) firing TimeoutException after 30 seconds when the OnBufferedAmountLow signal never arrived for the orphaned awaiters.
// In Send (rc.1)
var tcs = new TaskCompletionSource<bool>(...);
System.Threading.Interlocked.Exchange(ref _bufferedAmountLowTcs, tcs); // overwrite
await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30));
// In OnBufferedAmountLow (rc.1)
var tcs = System.Threading.Interlocked.Exchange(ref _bufferedAmountLowTcs, null);
tcs?.TrySetResult(true); // only signals the LATEST awaiterSend_1 installs tcs1. Send_2 fires concurrently (e.g. P2P's 8-chunk pipeline), installs tcs2, displacing tcs1 into limbo. When OnBufferedAmountLow fires it grabs tcs2 from the slot and signals it; tcs1 is unreachable and times out 30s later. The TimeoutException propagates out of Send, the AsyncTaskMethodBuilder calls SetException on the consuming wire's pending task, the wire's OnConnected await chain catches it and calls Peer.Destroy(err), which calls Wire.Destroy() and fires wire.OnClose. Consumers (e.g. P2PWebRtcBridge.AttachToSwarm) interpret the close as a real peer departure and unregister the peer mid-buffer-transfer. Net: every multi-MB tensor send that fanned out concurrent chunks broke the wire, which manifested upstack as "No healthy peers available for dispatch."
_bufferedAmountLowTcs is now a SHARED TCS that every concurrent awaiter references at the time of their await. OnBufferedAmountLow completes that shared TCS to release ALL awaiters at once, then atomically installs a fresh TCS for the next round of waiters. New helper EnsureBufferedAmountLowTcs() lazily creates the slot the first time Send hits the threshold (before any drain has occurred), guarded by Interlocked.CompareExchange.
// Send (rc.2)
var tcs = EnsureBufferedAmountLowTcs();
if (_dc.BufferedAmount <= MaxBufferedAmount) break;
await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30));
// OnBufferedAmountLow (rc.2)
var fresh = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var completed = System.Threading.Interlocked.Exchange(ref _bufferedAmountLowTcs, fresh);
completed?.TrySetResult(true); // releases EVERY current awaiterVerified: LargeBuffer_1MB_DispatchedOverRealWebRtc_BitExact passes with the surviving wire intact through the entire 1 MB transfer + dispatch + result return.
SCTP backpressure + duplicate-handshake hardening. Two bugs surfaced by Captain's deployed P2P compute demo on GitHub Pages, both in the wire-establishment / wire-teardown paths shared by every P2P consumer.
Before: RtcPeer.Send called _dc.Send(data) synchronously with no check on _dc.BufferedAmount. WebRTC's RTCDataChannel.send() does not throw when the SCTP send buffer is saturated - it queues unbounded internally. Once the remote receive side detected the runaway buffer, it closed the channel with sctpCauseCode=12 (User-Initiated Abort). Symptom in the demo: heavy buffer push-back during P2P compute (e.g. Mandelbrot strip auto-push, multi-MB tensor result buffers) intermittently killed the wire.
After: Send is now async. Before each _dc.Send, if BufferedAmount > MaxBufferedAmount (64 KB), Send awaits OnBufferedAmountLow. The data channel's BufferedAmountLowThreshold is set to 64 KB in WireDataChannel, so the event fires when the queue drops below the gate. A 30-second ceiling on the wait protects against deadlocks on a stalled wire. Sends after Destroyed or ReadyState != "open" throw a clear "Data channel closed during backpressure wait" error rather than silently failing.
Before: the duplicate-detection rule fell back to peer.Id (per-side local) when SimplePeer.ChannelName was empty. The responder's ChannelName starts as "" and only fills in when RtcPeer.OnDataChannel fires. If OnHandshake fires inside that race window on one side and not the other, the two sides compare DIFFERENT label pairs (one with the real channel label, one with the per-side fallback peer.Id) and arrive at OPPOSITE keep/destroy decisions - bilaterally destroying both physical wires.
After: when either label is empty or fell back to the per-side peer.Id, the destroy is skipped and both wires live. Consumer-layer dedup (e.g. P2PWebRtcBridge deduping by remote BitTorrent peer id) collapses the duplication invisibly to the consumer; one of the wires closes naturally as Wire.Destroy / Peer.Destroy fire elsewhere.
First minor cut since 3.1.0. Bundles five additive features on top of 3.1.7's wire-level seeder correctness fix. Zero behavior change for consumers who don't opt in to any of the new surfaces; existing 3.1.x code keeps running unchanged.
The 3.1.8 internal cut wired TcpListenerService into WebTorrentClient as a first-class API with WebTorrentClientOptions.TcpListenPort / TcpListenAddress and EnsureTcpListenerAsync(port, address). 3.2.0 keeps that surface and adds tracker advertising on top.
New: WebTorrentClientOptions.AdvertiseTcpListenerToTrackers (default false, opt-in). When enabled AND a TcpListener is bound, every HTTP / UDP tracker announce includes the listener's actual port in the BEP 3 port= field instead of the legacy hardcoded 0 / 6881. Trackers put us in their compact peer list and mainline peers find us automatically.
await using var client = new WebTorrentClient(new WebTorrentClientOptions
{
TcpListenPort = 51413,
AdvertiseTcpListenerToTrackers = true, // mainline peers can find us via tracker
});WebSocket trackers (WebRTC signaling) are unaffected - their peer-pairing model is SDP-based, not IP+port-based. Inspect the runtime decision via WebTorrentClient.AdvertisedTcpPort (returns 0 when not advertising; the actual listener port when on).
Implementation: new Port field on AnnounceOptions; HttpTracker.AnnounceAsync reads opts.Port (replaces hardcoded &port=0); Discovery.AnnounceAsync propagates the port to UDP / WebSocket trackers; Discovery._lastAdvertisedPort keeps the value consistent across periodic re-announces (otherwise the first announce would advertise our port and every periodic follow-up would silently revert).
Full doc: Docs/tcp-listener.md. Locked by Desktop_AdvertiseTcpListenerToTrackers_PutsListenerPortInAnnounce (stub HttpListener captures the announce URL and asserts the port field) and Desktop_AdvertiseTcpListenerToTrackers_DefaultIsOff (back-compat default).
New IPieceHashEngine interface with Sha1, Sha256, and BatchSha256. Default = SystemCryptoPieceHashEngine (System.Security.Cryptography, byte-identical to 3.1.x). Slot-in via WebTorrentClientOptions.PieceHashEngine and / or WebTorrentClient.PieceHashEngine property.
Why pluggable: verifying every piece of a 100 GB torrent is 25,000+ independent SHA-256 calls. Batching them through ILGPU on a desktop GPU (CUDA / OpenCL / WebGPU on browser) can be ~10-30× faster than sequential CPU. BatchSha256(IReadOnlyList<ReadOnlyMemory<byte>>) is the API that future GPU implementations will dispatch as one kernel batch. SpawnDev.WebTorrent intentionally does NOT take a dependency on SpawnDev.ILGPU - the GPU engine ships as a separate package (SpawnDev.WebTorrent.GpuHash, planned).
The v1 / Phase-1 piece-verification path (Torrent.VerifyPieceHash flat SHA-256 / SHA-1 case) routes through the engine. The v2 Merkle path still uses MerkleHasher directly - integration follows in a future cut.
Locked by Desktop_PieceHashEngine_RoutesThroughCustomEngine (counting-engine wrapper proves the slot-in works) and Desktop_PieceHashEngine_DefaultsToSystemCrypto.
New BandwidthPolicy enum: Unlimited (default, -1 bytes/sec), Conservative (256 KiB/sec), Metered (64 KiB/sec), SeedingDisabled (0, paused), Custom (paired with explicit UploadLimit). Set via WebTorrentClientOptions.BandwidthPolicy; flip at runtime via WebTorrentClient.ApplyBandwidthPolicy(policy).
await using var client = new WebTorrentClient(new WebTorrentClientOptions
{
BandwidthPolicy = BandwidthPolicy.Metered, // 64 KiB/sec ceiling
});
// ...later, on a wired connection again:
client.ApplyBandwidthPolicy(BandwidthPolicy.Unlimited);Explicit UploadLimit >= 0 always wins over the policy - the policy is the convenience layer for callers who want to say "be reasonable on a metered connection" without picking a number. BandwidthPolicy.AutoDetect() exists today as a hook (returns Unlimited until platform-specific signals are wired up - WinRT IsConnectionCostMetered, browser navigator.connection.saveData, NetworkInterface heuristics).
Locked by Desktop_BandwidthPolicy_AppliesToUploadRateLimiter, _ExplicitUploadLimitWins, and _ApplyAtRuntimeSwitchesRate.
The bundled SipSorcery fork's MediaStreamTrack.StreamStatus setter is widened from internal to public. Consumers can now flip direction post-construction (e.g. RecvOnly → SendRecv after a remote DTLS handshake completes) without reflection or track recreation. Strict superset of the upstream surface - existing internal callers continue to work, public callers see what was already there.
Logged in UPSTREAM_BACKLOG.md for eventual upstream PR. Locked by MediaStreamTrack_StreamStatus_PublicSetterWorks in SpawnDev.RTC.DemoConsole.UnitTests.DesktopForkApiTests (the test simply uses the setter from outside the SIPSorcery assembly - if visibility ever regresses to internal, the test stops compiling, which is the signal we want).
8 new PlaywrightMultiTest tests across the five surfaces. All shipped tests + interop matrix (qBittorrent live-swarm both directions, JS WebTorrent live-swarm via local SpawnDev.RTC tracker) green.
Purely additive on top of 3.1.7 - no behavior change for existing consumers, no breaking changes.
New API surface:
WebTorrentClient.TcpListenerproperty (TcpListenerService?).WebTorrentClient.EnsureTcpListenerAsync(port, address)method - idempotent; mirrors the existingEnsureDhtAsyncprecedent.WebTorrentClientOptions.TcpListenPort(int?) -null= no listener (default, back-compat);0= kernel-assigned ephemeral port (readclient.TcpListener.LocalEndPoint.Portback);> 0= bind a specific port.WebTorrentClientOptions.TcpListenAddress(IPAddress?) - defaults toIPAddress.Anyso external peers can reach the listener; passIPAddress.Loopbackfor localhost-only test harnesses.- Constructor fires
EnsureTcpListenerAsyncfire-and-forget whenTcpListenPortis set. client.DisposeAsyncreleases the listening socket so back-to-back tests don't collide on the same port.
// One-liner for a seeder that accepts inbound mainline-client connections:
await using var client = new WebTorrentClient(new WebTorrentClientOptions
{
TcpListenPort = 0, // ephemeral
TcpListenAddress = IPAddress.Any, // external-reachable
});
await client.EnsureTcpListenerAsync(0, IPAddress.Any);
int port = client.TcpListener!.LocalEndPoint.Port;Mainline-client interop (qBittorrent, libtorrent, Transmission) unchanged - dial in by IP+port and the listener routes by info_hash to the matching torrent.
New test: Desktop_TcpListenerOption_AcceptsInboundLeech in PlaywrightMultiTest. Two clients on loopback, A seeds via the option, B leeches via direct TcpPeer.ConnectAsync to A's kernel-assigned port. Full 64 KiB SHA-256 byte-identical round-trip in ~10s with no peer-discovery sources enabled (deterministic - no public swarm dependency).
Reverse-direction interop test (interop_test/qbittorrent_reverse_liveswarm.cs) simplified to use the option - drops the manual TcpListenerService construction + dispose.
PlaywrightMultiTest full sweep: 951 pass / 0 fail / 16 skip (+1 from 3.1.7 baseline for the new test). All three interop tests still PASS.
1. Wire._message byte-stream corruption fix.
The old code framed a length-prefixed wire message followed by payload bytes as TWO separate writes:
// BROKEN
await _push(header);
await _push(data);Two _push acquisitions of the underlying transport's send lock let two concurrent _message calls (e.g. four pipelined SendPiece responses to a leecher's request messages) interleave bytes on the wire: header_A | header_B | data_A | data_B. Mainline clients (qBittorrent, libtorrent) then failed SHA-1 verification on the scrambled blocks and dropped the connection after one good piece.
Fixed by building the full frame (header + payload) into a single buffer and issuing one _push call. Plus a SemaphoreSlim(1, 1) around _push itself for any future paths that send multiple buffers serially.
This is wire-level and benefits every transport - TCP AND WebRTC. Any consumer seeding to a pipelining leecher (the normal case in BitTorrent) avoids byte-stream corruption.
Why latent for so long: every prior live-swarm test had the SpawnDev.WebTorrent client as the LEECH, not the seed. Leechers send tiny request / interested / cancel messages, never concurrent payloads. The bug only surfaces when WE are seeding to a pipelining downloader.
2. TcpPeer.AttachAsync drop-on-floor race.
When TcpListenerService (new, see below) accepted an inbound connection with the BT handshake already kernel-buffered, the old single-method attach started the read loop synchronously inline. NetworkStream.ReadAsync resolved immediately with the buffered bytes, EmitData fired before the caller had wired up OnData -> Wire.DataReceived, and the handshake was lost.
Fixed by splitting:
AttachAsync- configure the socket, fireEmitConnect, return.StartReadLoop- actually start reading.
TcpListenerService calls AddPeer between the two so the wire is constructed and OnData is wired before any inbound read fires.
3. New TcpListenerService.
Accepts inbound BitTorrent peer-wire connections, peeks the 68-byte BT handshake via MSG_PEEK (non-destructive kernel-buffer read), routes by info_hash to the matching torrent in the client, hands the still-unconsumed socket to a responder-mode TcpPeer. Closes the last open audit item in PLAN-BEP52-External-Interop.md Step 4 (reverse direction: seed-C# / leech-qBittorrent).
New interop_test/qbittorrent_reverse_liveswarm.cs drives the full live-swarm: SpawnDev.WebTorrent C# seeds, qBittorrent leeches via WebUI addPeers API pointing at our listener, 1 MiB hybrid torrent SHA-256 byte-identical end-to-end.
PlaywrightMultiTest full sweep: 950 pass / 0 fail / 16 skip in 8m 7s. Zero regressions vs 3.1.6.
New interop_test/js_webtorrent_liveswarm.cs drives a real Node.js webtorrent@^2 seeder (via @roamhq/wrtc - the maintained Node.js WebRTC polyfill) paired against SpawnDev.WebTorrent C# as the leech through a local SpawnDev.RTC.ServerApp WebSocket tracker subprocess: 1 MiB hybrid torrent, real WebRTC offer/answer exchange, SHA-256 byte-identical end-to-end. Closes the "Pure JS-WebTorrent live interop" audit gap in PLAN-BEP52-External-Interop.md.
3.1.6 pulls in SpawnDev.RTC 1.1.6, which fixes two tracker-signaling bugs:
TypeInfoResolverexplicitly set onBinaryJsonSerializer(client outbound) andTrackerSignalingServer._readOpts(server inbound) so announce serialization works under .NET 10 file-baseddotnet run script.cshosts and AOT/trimmed publishes (which disable reflection-based serialization by default). The_ = _discovery.AnnounceAsync(...)fire-and-forget inTorrent.StartDiscoverywas silently swallowing the resultingInvalidOperationExceptionon those hosts - clients never registered with any tracker.- Empty-Origin bypass on the
AllowedOriginsallowlist - non-browser clients (desktop C#ClientWebSocket, Node.jsws, curl) don't send Origin and shouldn't be subject to a browser-origin-abuse gate. Browser-origin enforcement is unchanged.
See SpawnDev.RTC 1.1.6 CHANGELOG for the full root-cause write-up.
- New
LocalTrackerFixturein PlaywrightMultiTest: spins up an in-processSpawnDev.RTC.ServerKestrel host on a free TCP port, used byDesktop_TwoClients_DiscoverViaTrackerandDesktop_SeedAndAnnounce_TrackerConnects(previously depended on hub.spawndev.com - CI-brittle). - Sintel magnets in network / interop tests reduced to
tracker.openwebtorrent.comonly (hub doesn't host Sintel peers). RetryCount = 2on 7 live-public-swarm tests (Network_*,Interop_LiveSwarm_Sintel_DownloadsPieces,Bep46_Loopback_*,UseExtension_RegistersAndCreatesExtension,Torrent_OnWire_FiresOnPeerConnect).Bep46_Loopback_*ports randomized per invocation to avoid TIME_WAIT collisions on retry / cross-run reuse.- Old
JsInteropTest.cs+js-interop/script directory removed; superseded by the newinterop_test/js_webtorrent_liveswarm.csharness which uses@roamhq/wrtcand a local tracker. - SpawnDev.UnitTesting bumped 2.5.0 → 2.5.2 across the solution (for the new
RetryCountattribute).
PlaywrightMultiTest full sweep: 950 pass / 0 fail / 16 skip in 7m 43s (was 477 / 5 / 6 on 3.1.5).
DhtDiscovery.HandleQuerynow dispatches BEP 44getandputqueries via newOnGetQuery/OnPutQueryevents.DhtMutableItemswires those events to a local_storedItemscache: verifies Ed25519 signatures on inbound puts (rejects forgeries), serves stored values on subsequent gets. Previously SpawnDev.WebTorrent could only be a BEP 44 client, never a server, so mutable items never propagated in small swarms.DhtMutableItems.PublishAsyncnow self-stores so a subscriber querying the publisher directly is served the value (required when the publisher is one of the K closest nodes to the target).DhtMutableItems.SubscribeAsyncbypasses the cache short-circuit - keeps polling for newer sequences instead of returning the first cached value forever.- BEP 5 learn-from-query:
DhtDiscovery.HandleQuerynow adds the querier's NodeId to the routing table (previously only query responses populated the table). Fixes small-swarm / bootstrap scenarios. Ed25519Signer.VerifyAsyncnow accepts raw 32-byte BEP 44 wire-format pubkeys and wraps them in SPKI internally. Previously verify silently failed on every incoming put becauseImportEd25519Keyexpects SPKI, not raw bytes. Real production-path bug, not a test-only issue — any incoming signed mutable item was rejected.- New public helpers:
DhtDiscovery.FindNodeAsync(endpoint)+PingAsync(endpoint)for manual-bootstrap / LAN-discovery / unit-test scenarios. New publicBuildGetResponse(txId, nodeId, value, seq, signature, publicKey)for custom get-query handlers. - 2 new real-P2P loopback tests:
Bep46_Loopback_PublishSubscribe_DeliversValue(~5s) +Bep46_Loopback_Republish_BumpsSequence(~14s). Two in-processDhtDiscoveryinstances on loopback UDP exercise the full BEP 44/46 round-trip; the first test is the first end-to-end proof the network path works (existing tests stubbed delivery withNotifyMutableUpdate).
SpawnDev.WebTorrent is now among the very few BitTorrent clients with a genuinely working BEP 46 implementation - libtorrent / qBittorrent / Transmission / Deluge / WebTorrent-JS all skip BEP 46 first-class (only RangerMauve's mutable-webtorrent JS library ships it).
- rc.23: Pure-v2 torrents now work end-to-end through the service-worker media streaming path and the desktop
TorrentHttpServerfile browser.ServiceWorkerStreamHandler.GetStreamUrlwas emitting/webtorrent/{InfoHashHex}/{fileIdx}which collapses to/webtorrent//{fileIdx}for pure-v2 (empty v1 InfoHash). Fixed to useWireInfoHashHex(first 20 bytes of v2 hash for pure-v2, v1 hash otherwise).WebTorrentClient.HandleStreamRequest's lookup switched to matchWireInfoHashHexcase-insensitively. - rc.24: Dep-bump
SpawnDev.RTCrc.3 → rc.6 (transitively: PerfectNegotiator W3C glare-free renegotiation helper; BrowserRTCSctpTransport live JSRef reads; race fix). No WebTorrent source changes. - rc.25:
Torrent.DisplayNameShortnarrow-cell helper (Name → first 12 chars of WireInfoHashHex → "unknown"). Blazor demo switches from an inline 3-level ternary to@torrent.DisplayNameShort.
- rc.20: Duplicate check in
WebTorrentClient.Add(magnet)/Add(bytes)/SeedFromMetadataAsyncnow usesWireInfoHashHexinstead of rawInfoHash. Two calls with the same pure-v2 magnet or bytes previously created two separate Torrent entries (v1 InfoHash is empty for pure-v2 so the dedup check matched everything to everything). PlusRemoveAsync(string)andRemoveWithDataAsync(string)now delegate toGet(string)so callers can remove by v1, full v2, or wire-truncated hash. - rc.21: Pure-v2 OPFS persistence.
Torrent.PersistMetadataAsync/PersistStateAsync/ the chunk-store path +WebTorrentClient.RestoreFromStorageAsync/RemoveAsync(Torrent)/RemoveWithDataAsync(Torrent)all key onWireInfoHashHexnow. Pure-v2 torrents survive page reloads with pieces underwebtorrent/<v2-prefix>/and metadata atwebtorrent/_state/<v2-prefix>.torrent. v1-only / hybrid paths unchanged - byte-compatible with existing OPFS data. NewTorrentMetadata.WireInfoHashHexcomputed property. - rc.22:
Torrent.DisplayNamehelper (Name → WireInfoHashHex → "unknown") so UI code can consume directly without??chains that silently miss pure-v2 torrents (??only triggers on null, not empty). Blazor + WPF demos updated. Blazor details pane now renders bothInfo Hash(v1) andV2 Info Hash(BEP 52) rows conditional on which hashes the torrent has.
- rc.13: Pure-v2 tracker + wire handshake support (
Torrent.WireInfoHashHexreturns first 20 bytes of v2 SHA-256 for pure-v2 torrents, libtorrent / qBittorrent / rqbit convention). Removes theNotSupportedExceptiononmagnet:?xt=urn:btmh:magnets.WebTorrentClient.Get(hash)matches v1 / full v2 / wire-truncated forms.TorrentCreator.CreateFromMultipleStreamsAsyncstreaming multi-file creator (bounded memory for multi-GB HF model shards). - rc.14: ut_metadata BEP 9 extension v2 variant - advertises
metadata_version: 2in the BEP 10 extended handshake, SHA-256 verifies against the full v2 hash on receipt. Hybrid + v1-only stay on the v1 path unchanged. - rc.15: Diagnostic build for Geordi's rc.12 two-popup mystery.
RtcPeer.OnDataChannelresponder path logs the ChannelName after assignment;Torrent.OnHandshakeduplicate branch dumps full Wires state (all PeerIds + matching_peers+ ChannelNames) before the tiebreaker decision. - rc.16:
TorrentCreator.CreateFromMultipleStreamsAsyncnow supportsHybrid=true. Single-pass streaming feeds each read to both the v2 IncrementalMerkleHasher AND the v1 SHA-1 piece buffer. End-of-file emits a zero-padded v1 piece for non-last files (matching pad-file-filled virtual stream) and a genuine partial piece for the last. Byte-identical to the in-memory BuildHybridMultiFile path. - rc.17: Multi-tracker failover resilience.
Discovery.AnnounceAsyncisolates per-tracker failures - one unreachable WSS host / HTTP 5xx / UDP timeout no longer cancels the aggregateTask.WhenAll. Errors surface viaOnWarning. Closes Gap 6 of Geordi's P2P audit on the WebTorrent side. - rc.18:
WebTorrentClient.Addwires the ut_metadata factory into v2 mode automatically when the magnet is pure-v2. NewTorrentParser.ParseInfoDictV2(bytes, expectedV2InfoHash)helper. - rc.19: Phantom-wire filter on the duplicate-peer tiebreaker. Geordi's rc.15 DUP-DIAG output identified orphan Wires entries from destroy-race as the two-popup peerCount=0 root cause. rc.19 filter requires
!w.DestroyedAND a live backing peer in_peers. Closes the multi-iteration (rc.10 → rc.12 → rc.15 → rc.19) paired-debug effort with Geordi; verified GREEN by Geordi in 9s end-to-end viaWasmP2PBrowserTests.ComputeSwarm_Benchmark_RoundTrips_BetweenTwoPopups.
- rc.12: Duplicate-peer tiebreaker on WebRTC channel Label (cross-side-stable). Replaces the rc.10-11 newcomer/existing axis which was per-side timing-dependent.
- rc.11:
RtcPeerdeferredEmitConnectwhen the data channel is already open at subscribe time (responder race). - rc.10: First iteration of the duplicate-peer handshake tiebreaker (superseded by rc.12's correct axis).
- rc.4:
RtcPeer.PeerConnectionpublic getter - unblocked Geordi's MaxBurst consumer path for 10 MB WebRTC dispatch. - rc.3: Dep-bump
SpawnDev.RTC1.1.3-rc.1 → 1.1.3-rc.2 (SCTP MaxBurst tunables).
- TorrentParser now walks the whole v2 file tree and flattens each file's piece-layer hashes into
PieceHashesin file-tree order. Previously onlyFileRoots[0]'s piece layer was emitted, leaving_hashes[globalIndex]wrong (or out of range) for any piece belonging to a file past the first. A pure-v2 multi-file torrent created by libtorrent or any external v2-first creator can now verify past file 0. - Per-file offsets are now in the PADDED virtual stream — each file starts on a piece boundary with implicit zero-padding from the previous file's tail, per BEP 52 §"File tree".
TorrentFileInfo.Offsetreflects the virtual layout so the global-piece-index addressing used by BEP 3 wire request/piece messages is well-defined. - Per-piece length array (
Torrent._pieceLengths[]) replaces the old "every piece isPieceLengthexcept the last" assumption. For pure-v2 multi-file, each file's last piece isfile.Length % PieceLengthbytes long; pieces straddling a file-end boundary are sized correctly soPiece.Flush()returns the right length andVerifyPieceHashhands an appropriately-short buffer toMerkleHasher.ComputePieceLayer(which handles leaf-level zero-padding internally). - TorrentCreator (
BuildV2MultiFile) now sorts input files by path into BEP 52 file-tree walk order before building per-file structures. FlatPieceHashessequence matches what a parse round-trip produces. - New NUnit test
VerifyPieceHashTests.V2_PureMultiFile_AllPiecesVerify_PastFile0: creates a 3-file pure-v2 torrent (with partial last pieces on files 2 + 3), parses round-trip, walks every file's pieces and assertsVerifyPieceHash(globalIdx, piece)succeeds for all of them. Passes in 22ms. Mirror browser testBep52_PureV2MultiFile_AllPiecesVerify_PastFile0added to the cross-platform suite. - Existing test
TorrentCreatorV2MultiFileTests.MultiFile_V2_FlatFiles_ProducesFileTreeWithMultipleLeavesupdated: the second file's offset now asserts against the PADDED value (16384 instead of 500) since BEP 52 requires that layout for multi-file v2. - Full NUnit regression: 256/0/0 in 5s (from 255, +1 new test, 0 regressions).
Wire._messagenow issues one_pushper logical Wire message instead of two (header + data). Each BEP 10 extension message previously became two separate SCTP user messages on the WebRTC data channel; SCTP paced each independently, which stacked on top of SCTPMAX_BURST/BURST_PERIODrate limiting and bottlenecked SpawnDev.ILGPU.P2P multi-MB tensor transfers at ~100 KB/s end-to-end despite the underlying pipe sustaining 5.4 MB/s on single-send payloads (see 3.1.3-rc.1 SctpDataSender fix).- Hardens
Torrent.DisposeAsyncagainst late-firing Rechoke timer callbacks by usingTimer.DisposeAsyncto drain in-flight work, plus a defensive null filter + try/catch inside the callback.
- Dep-bump to
SpawnDev.RTC 1.1.3-rc.1which transitively picks upSpawnDev.SIPSorcery 10.0.5-rc.1and itsSctpDataSenderlost-wakeup fix. 60x on the zero-RTT synthetic benchmark (89.8 KB/s → 5.4 MB/s). Real-world end-to-end throughput stays bounded byMAX_BURST × MTU / RTT(~186 KB/s on loopback) untilMAX_BURSTis tunable — Geordi re-measured ~0.15–0.19 MB/s regardless of buffer size through a real DesktopRTCPeerConnection. The fix is correct; the headline 60x number only manifests when SACK RTT is effectively zero. SeeSpawnDev.RTC/Docs/sctp-tuning.mdfor the full analysis. - No SpawnDev.WebTorrent source changes. Pure dep refresh.
- Full NUnit regression: 255/0/0 in 5s (same as 3.1.2 stable since the WebTorrent library itself didn't change).
- Intended for SpawnDev.ILGPU.P2P to consume. Unblocks multi-MB tensor transfers over WebRTC data channels.
- BEP 52 (BitTorrent v2) is feature-complete. Merkle-tree piece verification, 16 KiB leaves with per-level pad-hash propagation, per-file Merkle roots, piece layers, SHA-256 info hash,
urn:btmh:magnet URIs, hybrid v1+v2 info dicts, and the full peer-wire extension (messages 21hash_request/ 22hashes/ 23hash_reject). SeeDocs/bep52.mdfor the walkthrough. - Peer-wire state machine:
V2HashRequestCoordinatorallocated per v2 torrent, correlates outboundhash_requestto inboundhasheson any connected wire (per-torrent, not per-wire). Handles timeout, cancellation, duplicate-key rejection, cryptographic verification, hash_reject asHashRejectedException. - Seed path:
Torrent.OnV2HashRequestusesMerkleProofBuilderto emithashespayload from ourPieceLayersdict, with self-check that the emitted proof re-climbs to the advertised pieces_root before transmission. - Client path:
Torrent.RequestV2HashesAsync(req, ct, wire?)routes through the coordinator + a picked peer Wire'sSendHashRequest. - Critical correctness:
Torrent.VerifyPieceHashnow branches onMetaVersion. v2 torrents verify against the Merkle piece-layer root (not a flat SHA-256 of the piece bytes) — this was a latent bug where large-piece-size v2 torrents would always mismatch. Caught and fixed before any user impact. - Streaming hybrid single-file creation (
CreateHybridSingleFileFromStreamAsync) for multi-GiB torrents in bounded memory. Multi-file hybrid inserts spec-correct pad files (attr="p",path=[".pad","N"]). - HuggingFaceProxy cutover to
Hybrid = trueby default. Every HF model torrent now carries both v1 SHA-1 and v2 SHA-256 infohashes. - Cross-platform browser coverage via
WebTorrentTestBase.Bep52V2Tests.cs— 16 tests × 2 projects through PlaywrightMultiTest. Peer-wire mirror added in the final step for 10 more browser-covered tests. - Test totals: NUnit desktop 255/0/0 in 5s (from 68 pre-Phase-2 baseline + 187 new BEP 52 tests). Zero regressions on the v1 path anywhere.
- Full BEP coverage matrix: 19 BEPs (added BEP 52). See
Docs/bep-support.md.
- SIPSorcery fork:
SortMediaCapabilitypriority-track inverted ternary fixed (upstream PR sipsorcery-org/sipsorcery#1558). Two peers with identical multi-codec audio lists now agree on a single negotiated format, instead of one side seeing PCMU and the other seeing Opus.
- Dep-bump to
SpawnDev.RTC 1.1.1(was1.1.0, which declared a dep on the un-publishedSIPSorcery 10.0.4-prefork ID and failed to restore in external builds). SpawnDev.RTCis now aPackageReference(previouslyProjectReference) so standalone WebTorrent checkouts build without the sibling RTC repo.SpawnDev.WebTorrent.Serverreference toSpawnDev.RTC.Serversimilarly swapped fromProjectReferencetoPackageReference.- No code changes from 3.1.0.
- First stable cut after the WebTorrent 3.x restructure where the tracker moved to
SpawnDev.RTC.Server. WebTorrent.Server is now web-seed-only. - Captain manually verified bidirectional JS WebTorrent interop through hub.spawndev.com: JS seeder → JS downloader, AND JS seeder → SpawnDev.WebTorrent C# downloader. Both complete.
- Full PlaywrightMultiTest regression: 408/0/13 at this cut.
- See git history for pre-stable 3.0.0-rc.* and 3.0.1-rc.* milestones (BEP 52 Phase 1 foundation, streaming piece flush, service worker range streaming, OPFS persistence, etc.).