Skip to content

Latest commit

 

History

History
571 lines (358 loc) · 62.7 KB

File metadata and controls

571 lines (358 loc) · 62.7 KB

Changelog

3.2.12 (2026-07-04) — storage-quota runaway fix

A browser download whose OPFS piece store failed used to re-request the same piece forever. On the zero-copy web-seed span path (browser model downloads), a verified piece is stored to OPFS. Under Lazy-Hash the hash always "matches" (the first downloader computes it), so the only way a delivered piece fails to complete is a throw in the store — most importantly the browser's QuotaExceededError when the origin is out of space. The old catch left the piece unflagged, so the picker re-requested it, re-fetched, re-stored, threw again: an infinite hot loop (169 identical range GETs observed live on a 1.5 GB model), which also — before the paired SpawnDev.BlazorJS 3.5.15 fix — leaked an OPFS swap file per attempt, accelerating the storage exhaustion.

Torrent.ZeroCopySpanAsync now classifies the failure: a quota error (or a span that has failed ZeroCopyMaxSpanStrikes = 5 times running) surfaces a new Torrent.OnError event and pauses the torrent (StorageQuotaExceeded is set; Resume() clears it and the strike map), instead of burning bandwidth on an unrecoverable loop. A transient (non-quota) store failure backs off exponentially (250 ms → 8 s) and retries, and the strike streak is cleared on the span's first success. Pairs with SpawnDev.BlazorJS 3.5.15 (OPFS writable abort-on-throw — no swap-file leak) and SpawnDev.AsyncFileSystem 1.5.1.

New regression tests (WebTorrentTestBase.StorageQuotaTests, browser/OPFS, fault-injected store): StorageQuota_WriteThrows_PausesTorrent_NoRunawayRefetch (quota → pause + OnError + GETs stop climbing after the pause) and StorageQuota_TransientWriteFault_RecoversWithoutPausing.

3.2.10 (2026-06-09) — web-seed range over-EOF clamp (RFC 7233)

Fixes a server-side HTTP range bug that truncated 206 responses when a client requested past EOF. Both web-seed servers — WebSeedServer (BEP 17/19) and the HuggingFace proxy (HuggingFaceProxy) — set a 206 Content-Length from the requested byte count but used an explicit last-byte-pos verbatim. When that end ran past the file, the server promised more bytes than it could stream, the body closed short, and a browser fetch() rejected the whole response with net::ERR_CONTENT_LENGTH_MISMATCH — so the web-seed piece never verified. Reproduced live against the hub: a 1,059,962-byte file asked for bytes=1059952-1060961 promised Content-Length: 1010 but streamed 10.

Both servers now route range parsing through a new shared, unit-tested HttpByteRange.ParseSingle helper (core lib) that, per RFC 7233 §4.1: clamps an over-long last-byte-pos to length-1, returns 416 (Range Not Satisfiable) for a first-byte-pos at/after EOF, supports suffix ranges (bytes=-N), and uses long lengths (the prior (int) cast overflowed for files > 2 GB). New WebTorrentTestBase.HttpByteRangeTests covers clamp / in-bounds / suffix / unsatisfiable / malformed cases.

Note: our own WebConn client already clamps last-piece requests to the real file length (verified: all hub model torrents declare lengths matching disk), so this fix hardens against any over-EOF client (JS WebTorrent, external tools, future code) rather than a regression in our own download path. Deployed + verified live on hub.spawndev.com (over-EOF now clamps to Content-Length: 10 / 10 bytes; start-past-EOF returns 416; in-bounds + suffix ranges correct).

3.2.9 (2026-06-08) — faster browser model download + IJSReadStream (stable)

Browser model download/load made dramatically faster (measured: cold read-driven load of a 313MB model on WebGPU 202.9s → 35.4s, now network-bound), plus the IJSReadStream zero-copy primitive. Full PMT sweep GREEN (978 pass; the lone fail was a load-contention desktop-WebRTC timeout that passes clean isolated). Depends on SpawnDev.BlazorJS 3.5.12.

Single-marshal Merkle leaf-hash readback — the core download win

The zero-copy v2 (Merkle) verify read each 4 MB piece's 256 16 KiB-leaf hashes back into .NET one 32-byte ReadBytes at a time. Profiling the full download showed this readback phase was 165.7s of the 202.9s load — the cost is the high fixed per-call overhead of a data-carrying JS↔.NET marshal, paid 256× per piece (~20k tiny marshals), not the crypto (the SubtleCrypto wait was 2.2s). Now each leaf digest is Set into one Uint8Array JS-side (no marshal) and the whole ~8 KB is ReadBytes'd once; the .NET Merkle tree then runs on the collective byte[]. Readback phase 165.7s → 2.1s (~80×). Leaf hashes are byte-identical; only the dispatch changed.

Bounded leaf-digest concurrency

Torrent.MaxConcurrentLeafDigests (default 32) caps how many of a piece's ~256 SubtleCrypto leaf digests fire concurrently. Firing all of them (across any concurrently-verifying pieces) queued a large burst on the single WASM thread and starved the download request loop (fetch a batch fast, then stall ~15-20s verifying before the next fetch). Capping keeps each piece's verify responsive.

IJSReadStream support (client)

TorrentReadStream implements SpawnDev.BlazorJS.IJSReadStream — a browser consumer reads piece data while it stays in JS (a Uint8Array via ReadUint8ArrayAsync, downloading on demand) instead of copying it into the .NET heap, e.g. streaming model weights straight into a GPU buffer via IBrowserMemoryBuffer.CopyFromJS (zero JS→.NET→GPU copy). CanReadSync = false in the browser (sync Read throws), true on desktop. Requires SpawnDev.BlazorJS 3.5.12.

Polite web-seed default

MaxWebConns default 4 → 1. A web seed is a fallback HTTP source, not a CDN; several parallel range requests per client is greedy and degrades the seed/tracker for other peers. One polite connection saturates near the link now that per-piece verify is cheap. A consumer that owns its seed can raise it.

Diagnostics

Per-phase zero-copy verify/store profiling (fetch / digest-fire / digest-wait / readback / tree / store) is available behind Torrent.EnableZcProfiling (default false — Stopwatch can itself be an interop crossing in WASM, so production pays nothing).

3.2.8 (2026-06-07) — zero-copy browser download + service-worker seek-while-downloading (stable)

Stable cut. Rolls up the zero-copy download + OPFS store work (3.2.8-local.2 through local.7) plus the service-worker media-streaming unlock and the demo polish, all verified end to end against the live hub and live Sintel. Full PMT sweep GREEN browser+desktop. Depends on SpawnDev.RTC 1.1.10 + SpawnDev.BlazorJS 3.5.11.

Zero-copy browser web-seed download (client) — the core of this release

A single-file torrent on a browser OPFS store now keeps each piece's bytes in JS end to end: browser fetch (BlazorJSRuntime.Fetch) → JS Uint8Array → SubtleCrypto 16 KiB-leaf hashes → .NET Merkle tree over only the 32-byte leaf hashes (MerkleHasher.ComputePieceRootFromLeafHashes, byte-identical to ComputePieceLayer, verified) → OPFS write. The piece data never enters the .NET/WASM heap; per-piece verification is preserved. Desktop is unaffected (keeps the byte[] path). Surfaced via a ZeroCopyPiecesVerified counter so a consumer can confirm the path actually fires.

Extended to multi-file torrents in this release — the service-worker streaming case (Sintel is multi-file). Multi-file zero-copy was the dominant download-speed win there (~35× vs the prior .NET block path).

Service-worker media streaming: seek-while-downloading (client)

SW streaming of a still-downloading torrent now works (verified 8/8 cold live-Sintel runs in real Chrome). The fixes, all ours vs the JS WebTorrent reference:

  • Critical()UpdateWires debounce: marking the seek target critical now kicks the request loop coalesced instead of per-call, so the moov atom actually arrives (it never did before, so playback never started).
  • _critical leak-on-cancel: a cancelled streaming read no longer leaves pieces pinned critical forever.
  • Last-piece stranding + FIFO/idle-drain fixes in the request pipeline.
  • PMT launches Chromium with Channel="chrome" so the H.264/AAC Sintel fixture actually demuxes (bundled Playwright Chromium lacks those codecs).

OPFS AsyncFSChunkStore performance (client)

  • Read cache: GetAsync(index, offset, length) cached the last whole-piece read, so a streaming parser reading a piece in many small chunks does one OPFS read per piece instead of re-reading the whole piece 16–64× (the SD-Turbo model-load crawl).
  • PutAsync via HeapView: pins the ReadOnlyMemory's underlying array (one async-safe pinned memcpy) instead of data.ToArray() plus the byte[]→JS interop serialize — two copies + GC down to one memcpy. OPFS-only; requires SpawnDev.BlazorJS 3.5.11 (HeapView ReadOnlyMemory/ArraySegment support).
  • Concurrent leaf hashing: the 16 KiB SubtleCrypto leaf digests for a piece run via one Task.WhenAll (native crypto overlaps) instead of being awaited one-by-one.

O(pieces²) picker fix on the zero-copy path (client)

The zero-copy web-seed path returns from RequestBlock before wire.Request, so wire.Requests.Count stays 0 and the rarest-piece picker's brakes (all keyed on it) never fired; the _zeroCopyInFlight >= MaxWebConns gate was only checked at TrySelectWire entry. So after each piece completion the picker re-scanned every piece (GetRarestPiece is O(pieces)) → O(pieces²), ~750 ms/piece on a ~1700-piece model on the single WASM thread → ~2.3 MB/s. The budget check now also runs inside the rarest/sequential walk loops (the three break points), so the walk stops the instant the fetches are back in flight (O(pieces) per completion). MaxWebConns 8→4 (at/under the browser's ~6/origin HTTP/1.1 cap).

Web-seed download concurrency (client)

Web seeds pipeline a fixed number of parallel range requests (MaxWebConns) instead of the prior speed-based pipeline that was chicken-and-egg for HTTP web seeds (started at ~1 in flight → serial → low measured speed → stayed at ~1). Saturates the link (measured ~118 MB/s sustained on a 1 GB LAN).

Dependency: SpawnDev.RTC 1.1.10

Periodic re-announce at the tracker-given interval. TrackerSignalingClient's announce-interval timer body was empty (announce-once), so a cold-start swarm never grew; ReAnnounceAll re-sends a periodic announce (fresh offers per room) at the tracker cadence. Already shipped to nuget.org.

Demo polish

  • Torrents.razor re-rendered per verified piece; the piece-map render is O(pieceCount), so the endgame burst was O(pieceCount²) flooding the WASM UI thread (severe completion lag). Removed the per-piece StateHasChanged; the 1 s refresh timer drives the UI. (The lib verifies each piece once on arrival — it does not re-hash on completion.)

Companion packages

  • SpawnDev.WebTorrent.Server 3.2.8: version-sync, no source changes.
  • SpawnDev.WebTorrent.Server.HuggingFace 3.2.8: real fix — never cache a partial/truncated download. GetOrFetchAsync downloaded straight to the final cache path with the body copy bound to the caller's CancellationToken; when the /magnet poll's client/gateway timed out on a long multi-GB fetch and disconnected, CopyToAsync cancelled mid-stream and left a partial file (SD-Turbo's 1.73 GB U-Net truncated at ~746 MB). The cache check is File.Exists only, so that partial was served as "complete" forever → a truncated torrent every consumer failed to parse mid-graph (broke SpawnDev.ILGPU.ML /generate). Now the body downloads to a .part temp on a fetch-owned 30-min timeout (decoupled from the client request), is size-verified against Content-Length, and is atomically renamed into place only on a verified-complete copy; a failed/short fetch deletes the temp and is never cached.

3.2.7 (2026-06-05) — Piece thread-safety (multi-threaded download race fix)

Piece (the per-piece block-download state machine) is a 1:1 port of JS torrent-piece, which runs on a single-threaded event loop. The C# client drives downloads from MULTIPLE threads — the download loop calls Reserve()/ReserveRemaining() while a wire-arrival callback on another thread calls Set()/Flush() (which null _buffer/_cancellations). That raced: Init() returned true, then a concurrent Flush() nulled _cancellations before the caller dereferenced it → NullReferenceException in Piece.ReserveRemaining()/Reserve() (Piece.cs:79). It surfaced loading a large multi-piece torrent (a 2.5GB SD-Turbo model via SpawnDev.ILGPU.ML); the smaller models exercised before never hit the timing window.

Fix: all eight Piece state mutators (Init, Reserve, ReserveRemaining, Cancel, CancelRemaining, Get, Set, Flush) are now serialized on a per-Piece re-entrant lock (_gate). It is a no-op on single-threaded Blazor WASM and makes the state machine atomic on desktop. No public API change; Piece.cs only. No deadlock risk (a single re-entrant lock, no nested foreign locks).

New test guards the concurrent path; Gate: full PMT sweep GREEN browser+desktop. Server / Server.HuggingFace are version-sync companions (no source changes).

3.2.6 (2026-06-04) - zero-copy JS-typed range read (ReadUint8ArrayAsync)

Adds TorrentFileInfo.ReadUint8ArrayAsync(offset, length) - a random-access read that returns a JS Uint8Array instead of a .NET byte[]. On the OPFS path the bytes never marshal through .NET: the GPU upload (writeBuffer) use case in SpawnDev.ILGPU.ML wants a JS Uint8Array anyway, so a byte[] would be a wasted JS->.NET->JS round-trip. It shares ReadFileAsync's exact piece selection + Critical() prioritization - factored into shared EnsureReadSelection + EnsurePieceAsync helpers used by both the byte[] and Uint8Array readers, so download behavior is identical. Browser-only (returns a JS type, like BlobAsync); throws PlatformNotSupportedException on desktop - use ReadFileAsync there. Foundation for zero-copy GPU model loading.

New tests File_ReadUint8ArrayAsync_MatchesReadAsync_InBrowser + _WholeFile_InBrowser: byte-for-byte parity with ReadAsync across piece boundaries + whole file (browser), skipped on desktop. Gate: full PMT sweep GREEN browser+desktop. Server / Server.HuggingFace are version-sync companions (no source changes).

3.2.5 (2026-06-02) — critical-piece prioritization + on-demand inspect

Closes the latency + on-demand follow-ups noted under 3.2.4. Both fixes are client-only; Server / Server.HuggingFace are version-sync companions (no source changes). Covered by two new live-hub tests in WebTorrentTestBase.HuggingFaceProxyTests; full no-regression sweep clean (966/0/16, browser+desktop).

Critical-piece read prioritization (client)

A streaming/seek read marks the pieces it needs Critical(), but those were not fetched ahead of the normal rarest/sequential walk, so a cold first read over a web seed stalled (~21s browser vs ~0.7s desktop). Two fixes:

  • Torrent.Critical() now calls UpdateWires() so marking a piece critical kicks the request loop immediately. The public Select() already did this; a cold read over a web seed (which has no handshake) had nothing else driving requests.
  • TrySelectWire gains a critical-first pass that requests read-awaited pieces (with hotswap, so they can steal block reservations from lower-priority in-flight pieces) before the normal rarest/sequential walk.

New test HuggingFaceProxy_TailSeekRead_IsPrioritized: a tail-seek read (worst case for sequential order) resolves well under the old stall, browser+desktop.

On-demand inspect — read only touched pieces (client)

ReadFileAsync auto-selected the whole file whenever no selection existed, so a torrent added with AddTorrentOptions { Deselect = true } (for inspect-by-URL) still downloaded the entire model. It no longer auto-selects in deselect mode — Critical() + critical-first picking fetch only the pieces a read touches, so structure inspection of a multi-GB checkpoint never pulls weights.

New test HuggingFaceProxy_DeselectedRead_DownloadsOnlyTouchedPieces: a deselected add + 4 KiB header read downloads ~1 piece (asserts Downloaded ≤ 3 pieces), not the full file.

Test harness — desktop drain-bug fix (not the library)

The PMT desktop lane runs DemoConsole as a subprocess and parses stdout for the final TEST: line; SpawnDev.UnitTesting 2.5.2's ProcessRunner could return before draining stdout to EOF, intermittently losing that line under concurrent load → spurious generic "Test run failed" (ProjectRunner.cs:362). Bumped PlaywrightMultiTest + DemoConsole to SpawnDev.UnitTesting 2.5.6, Demo.Shared to SpawnDev.UnitTesting.Blazor 2.5.6, WpfDemo to SpawnDev.UnitTesting.Desktop 2.5.3 (drain-to-EOF fix landed in 2.5.3).

Companion bumps

  • SpawnDev.WebTorrent.Server 3.2.5: version-sync.
  • SpawnDev.WebTorrent.Server.HuggingFace 3.2.5: version-sync.

3.2.4 (2026-06-02) — web-seed-only download + magnet metadata bootstrap (stable)

Stable cut. Rolls up the web-seed/magnet fixes below over the prior 3.2.4-rc line. All four gaps were found and fixed during the first end-to-end run of the HuggingFace-proxy delivery path.

First end-to-end exercise of the HuggingFace-proxy delivery path (magnet from hub → metadata → web-seed download with no peers) surfaced four real gaps. All fixed; covered by new live-hub tests in WebTorrentTestBase.HuggingFaceProxyTests (4/4 PASS browser+desktop; full no-regression sweep clean).

HTTP xs= exact-source metadata bootstrap (client)

Torrent.ParseMagnet previously honored only xs=urn:btpk: (BEP 46). It now captures an HTTP(S) xs= into Torrent.ExactSourceUrl, and InitFromMagnetAsync fetches that .torrent directly (FetchMetadataFromExactSourceAsync) to resolve metadata with zero peers — verifying the fetched info-hash against the magnet before trusting it. This lets the first client on a web-seed-only swarm (the HuggingFace proxy / a CDN cache) get metadata without a ut_metadata peer.

WebTorrentClient.AddAsync(magnet, opts?, ct?) (client)

Added the awaitable add documented in the README + Docs/huggingface.md but never implemented. Returns the Torrent once metadata resolves (via xs= fetch or ut_metadata).

Web-seed-only download now actually downloads (client) — the core fix

SetMetadata's internal default-select inserted selections via _selections.Insert(...) without calling UpdateWires(). Real peers self-start their request cycle from their bitfield/unchoke handshake; a web seed has no handshake, so the swarm selected every piece yet issued zero requests and stalled forever. Added UpdateWires() after the default-select (the public Select() already did this). Web-seed-only download now works. Also replaced two silent catch {} blocks in the web-seed request path with VerboseLogging-gated logging, and added gated WebConn fetch diagnostics (URL / range / status / bytes).

BuildWebSeedUrl emits the full file URL (server — SpawnDev.WebTorrent.Server.HuggingFace)

Previously returned the parent directory of the file, expecting the client to append the torrent name — but BEP 19 clients only append when the URL ends with /, so the emitted ws= 404'd. Now emits the complete URL-encoded file URL.

Companion bumps

  • SpawnDev.WebTorrent.Server 3.2.4: version-sync.
  • SpawnDev.WebTorrent.Server.HuggingFace 3.2.4: BuildWebSeedUrl fix above.

Known follow-ups (not in this build)

  • Critical-piece prioritization: ReadAsync marks Critical() but the rarest picker doesn't fetch those pieces first (browser first-read ~21s vs desktop ~0.7s). Functionally correct, latency-suboptimal for seeking.

3.2.3 (2026-05-03) — stable rollup of rc.1 + rc.2

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.

3.2.3-rc.2 (2026-05-03) (superseded by 3.2.3 stable)

Phantom-alive wire detection (synthesis-aware)

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.

What this closes

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).

Companion bumps

  • 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.

3.2.2 (2026-04-28)

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.

SCTP backpressure shared-TCS multi-awaiter fix (rc.2 origin)

_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.

RtcPeer.Send polling-based SCTP backpressure wait (commit 7fd70c5)

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).

Duplicate-handshake hardening (rc.1 origin)

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.

SpawnDev.RTC 1.1.8 stable dep bump

Replaces the prior 1.1.8-rc.4 transitive. RTC 1.1.8 has three additive fixes:

  • Desktop OnBufferedAmountLow now actually fires (was silent in 1.1.7)
  • BrowserRTCPeerConnection connection-state polling fallback (Chromium-under-Playwright)
  • Opt-in BrowserRTCPeerConnection.DiagnosticsEnabled flag

3.2.2-rc.7 (2026-04-29) (superseded by 3.2.2 stable)

Polling-based SCTP backpressure wait closes LargeBuffer_100MB regression

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).

3.2.2-rc.6 (2026-04-29)

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.

3.2.2-rc.3 (2026-04-29)

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.

3.2.2-rc.2 (2026-04-29)

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.

The race in rc.1

// 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 awaiter

Send_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."

The fix

_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 awaiter

Verified: LargeBuffer_1MB_DispatchedOverRealWebRtc_BitExact passes with the surviving wire intact through the entire 1 MB transfer + dispatch + result return.

3.2.2-rc.1 (2026-04-28)

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.

1. RtcPeer.Send now applies SCTP backpressure

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.

2. Torrent.OnHandshake duplicate-detection skips when labels are not cross-side stable

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.

3.2.0 (2026-04-25)

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.

1. TcpListenerService first-class API + tracker advertising

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).

2. Pluggable piece-hash engine — GPU acceleration ready

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.

3. Bandwidth policy

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.

4. SipSorcery fork: MediaStreamTrack.StreamStatus public setter

The bundled SipSorcery fork's MediaStreamTrack.StreamStatus setter is widened from internal to public. Consumers can now flip direction post-construction (e.g. RecvOnlySendRecv 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).

Test summary

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.

3.1.8 (2026-04-25)

TcpListenerService wired into WebTorrentClient as a first-class API

Purely additive on top of 3.1.7 - no behavior change for existing consumers, no breaking changes.

New API surface:

  • WebTorrentClient.TcpListener property (TcpListenerService?).
  • WebTorrentClient.EnsureTcpListenerAsync(port, address) method - idempotent; mirrors the existing EnsureDhtAsync precedent.
  • WebTorrentClientOptions.TcpListenPort (int?) - null = no listener (default, back-compat); 0 = kernel-assigned ephemeral port (read client.TcpListener.LocalEndPoint.Port back); > 0 = bind a specific port.
  • WebTorrentClientOptions.TcpListenAddress (IPAddress?) - defaults to IPAddress.Any so external peers can reach the listener; pass IPAddress.Loopback for localhost-only test harnesses.
  • Constructor fires EnsureTcpListenerAsync fire-and-forget when TcpListenPort is set.
  • client.DisposeAsync releases 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.

3.1.7 (2026-04-24)

Two seeder peer-wire correctness fixes + new TcpListenerService

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, fire EmitConnect, 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.

3.1.6 (2026-04-24)

JS WebTorrent live-swarm interop PASSING

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.

Carries the SpawnDev.RTC 1.1.6 tracker-signaling fixes

3.1.6 pulls in SpawnDev.RTC 1.1.6, which fixes two tracker-signaling bugs:

  1. TypeInfoResolver explicitly set on BinaryJsonSerializer (client outbound) and TrackerSignalingServer._readOpts (server inbound) so announce serialization works under .NET 10 file-based dotnet run script.cs hosts and AOT/trimmed publishes (which disable reflection-based serialization by default). The _ = _discovery.AnnounceAsync(...) fire-and-forget in Torrent.StartDiscovery was silently swallowing the resulting InvalidOperationException on those hosts - clients never registered with any tracker.
  2. Empty-Origin bypass on the AllowedOrigins allowlist - non-browser clients (desktop C# ClientWebSocket, Node.js ws, 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.

Test infrastructure

  • New LocalTrackerFixture in PlaywrightMultiTest: spins up an in-process SpawnDev.RTC.Server Kestrel host on a free TCP port, used by Desktop_TwoClients_DiscoverViaTracker and Desktop_SeedAndAnnounce_TrackerConnects (previously depended on hub.spawndev.com - CI-brittle).
  • Sintel magnets in network / interop tests reduced to tracker.openwebtorrent.com only (hub doesn't host Sintel peers).
  • RetryCount = 2 on 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 new interop_test/js_webtorrent_liveswarm.cs harness which uses @roamhq/wrtc and a local tracker.
  • SpawnDev.UnitTesting bumped 2.5.0 → 2.5.2 across the solution (for the new RetryCount attribute).

PlaywrightMultiTest full sweep: 950 pass / 0 fail / 16 skip in 7m 43s (was 477 / 5 / 6 on 3.1.5).

3.1.3-rc.26 (2026-04-24)

BEP 44/46 server-side completeness

  • DhtDiscovery.HandleQuery now dispatches BEP 44 get and put queries via new OnGetQuery / OnPutQuery events. DhtMutableItems wires those events to a local _storedItems cache: 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.PublishAsync now 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.SubscribeAsync bypasses the cache short-circuit - keeps polling for newer sequences instead of returning the first cached value forever.
  • BEP 5 learn-from-query: DhtDiscovery.HandleQuery now adds the querier's NodeId to the routing table (previously only query responses populated the table). Fixes small-swarm / bootstrap scenarios.
  • Ed25519Signer.VerifyAsync now accepts raw 32-byte BEP 44 wire-format pubkeys and wraps them in SPKI internally. Previously verify silently failed on every incoming put because ImportEd25519Key expects 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 public BuildGetResponse(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-process DhtDiscovery instances 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 with NotifyMutableUpdate).

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).

3.1.3-rc.23 - rc.25 (2026-04-24)

Pure-v2 feature parity across UI + streaming + HTTP browser

  • rc.23: Pure-v2 torrents now work end-to-end through the service-worker media streaming path and the desktop TorrentHttpServer file browser. ServiceWorkerStreamHandler.GetStreamUrl was emitting /webtorrent/{InfoHashHex}/{fileIdx} which collapses to /webtorrent//{fileIdx} for pure-v2 (empty v1 InfoHash). Fixed to use WireInfoHashHex (first 20 bytes of v2 hash for pure-v2, v1 hash otherwise). WebTorrentClient.HandleStreamRequest's lookup switched to match WireInfoHashHex case-insensitively.
  • rc.24: Dep-bump SpawnDev.RTC rc.3 → rc.6 (transitively: PerfectNegotiator W3C glare-free renegotiation helper; BrowserRTCSctpTransport live JSRef reads; race fix). No WebTorrent source changes.
  • rc.25: Torrent.DisplayNameShort narrow-cell helper (Name → first 12 chars of WireInfoHashHex → "unknown"). Blazor demo switches from an inline 3-level ternary to @torrent.DisplayNameShort.

3.1.3-rc.20 - rc.22 (2026-04-24)

Pure-v2 in-memory dedup, OPFS persistence, and demo UI polish

  • rc.20: Duplicate check in WebTorrentClient.Add(magnet) / Add(bytes) / SeedFromMetadataAsync now uses WireInfoHashHex instead of raw InfoHash. 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). Plus RemoveAsync(string) and RemoveWithDataAsync(string) now delegate to Get(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 on WireInfoHashHex now. Pure-v2 torrents survive page reloads with pieces under webtorrent/<v2-prefix>/ and metadata at webtorrent/_state/<v2-prefix>.torrent. v1-only / hybrid paths unchanged - byte-compatible with existing OPFS data. New TorrentMetadata.WireInfoHashHex computed property.
  • rc.22: Torrent.DisplayName helper (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 both Info Hash (v1) and V2 Info Hash (BEP 52) rows conditional on which hashes the torrent has.

3.1.3-rc.13 - rc.19 (2026-04-23 / 2026-04-24)

Pure-v2 tracker + wire + ut_metadata + phantom-wire handshake fix

  • rc.13: Pure-v2 tracker + wire handshake support (Torrent.WireInfoHashHex returns first 20 bytes of v2 SHA-256 for pure-v2 torrents, libtorrent / qBittorrent / rqbit convention). Removes the NotSupportedException on magnet:?xt=urn:btmh: magnets. WebTorrentClient.Get(hash) matches v1 / full v2 / wire-truncated forms. TorrentCreator.CreateFromMultipleStreamsAsync streaming multi-file creator (bounded memory for multi-GB HF model shards).
  • rc.14: ut_metadata BEP 9 extension v2 variant - advertises metadata_version: 2 in 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.OnDataChannel responder path logs the ChannelName after assignment; Torrent.OnHandshake duplicate branch dumps full Wires state (all PeerIds + matching _peers + ChannelNames) before the tiebreaker decision.
  • rc.16: TorrentCreator.CreateFromMultipleStreamsAsync now supports Hybrid=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.AnnounceAsync isolates per-tracker failures - one unreachable WSS host / HTTP 5xx / UDP timeout no longer cancels the aggregate Task.WhenAll. Errors surface via OnWarning. Closes Gap 6 of Geordi's P2P audit on the WebTorrent side.
  • rc.18: WebTorrentClient.Add wires the ut_metadata factory into v2 mode automatically when the magnet is pure-v2. New TorrentParser.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.Destroyed AND 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 via WasmP2PBrowserTests.ComputeSwarm_Benchmark_RoundTrips_BetweenTwoPopups.

3.1.3-rc.3 - rc.12 (2026-04-23)

  • 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: RtcPeer deferred EmitConnect when 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.PeerConnection public getter - unblocked Geordi's MaxBurst consumer path for 10 MB WebRTC dispatch.
  • rc.3: Dep-bump SpawnDev.RTC 1.1.3-rc.1 → 1.1.3-rc.2 (SCTP MaxBurst tunables).

3.1.3-rc.2 (2026-04-23)

Pure-v2 multi-file download support

  • TorrentParser now walks the whole v2 file tree and flattens each file's piece-layer hashes into PieceHashes in file-tree order. Previously only FileRoots[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.Offset reflects 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 is PieceLength except the last" assumption. For pure-v2 multi-file, each file's last piece is file.Length % PieceLength bytes long; pieces straddling a file-end boundary are sized correctly so Piece.Flush() returns the right length and VerifyPieceHash hands an appropriately-short buffer to MerkleHasher.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. Flat PieceHashes sequence 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 asserts VerifyPieceHash(globalIdx, piece) succeeds for all of them. Passes in 22ms. Mirror browser test Bep52_PureV2MultiFile_AllPiecesVerify_PastFile0 added to the cross-platform suite.
  • Existing test TorrentCreatorV2MultiFileTests.MultiFile_V2_FlatFiles_ProducesFileTreeWithMultipleLeaves updated: 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._message + Rechoke timer hardening (concurrent work by Geordi's session)

  • Wire._message now issues one _push per 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 SCTP MAX_BURST / BURST_PERIOD rate 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.DisposeAsync against late-firing Rechoke timer callbacks by using Timer.DisposeAsync to drain in-flight work, plus a defensive null filter + try/catch inside the callback.

3.1.3-rc.1 (2026-04-23)

Dep-bump: SCTP sender throughput fix (via SpawnDev.RTC 1.1.3-rc.1)

  • Dep-bump to SpawnDev.RTC 1.1.3-rc.1 which transitively picks up SpawnDev.SIPSorcery 10.0.5-rc.1 and its SctpDataSender lost-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 by MAX_BURST × MTU / RTT (~186 KB/s on loopback) until MAX_BURST is 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. See SpawnDev.RTC/Docs/sctp-tuning.md for 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.

3.1.2 (2026-04-22 stable)

BEP 52 v2 complete: peer-wire extension + Torrent integration + browser coverage

  • 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 21 hash_request / 22 hashes / 23 hash_reject). See Docs/bep52.md for the walkthrough.
  • Peer-wire state machine: V2HashRequestCoordinator allocated per v2 torrent, correlates outbound hash_request to inbound hashes on any connected wire (per-torrent, not per-wire). Handles timeout, cancellation, duplicate-key rejection, cryptographic verification, hash_reject as HashRejectedException.
  • Seed path: Torrent.OnV2HashRequest uses MerkleProofBuilder to emit hashes payload from our PieceLayers dict, 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's SendHashRequest.
  • Critical correctness: Torrent.VerifyPieceHash now branches on MetaVersion. 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 = true by 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.

Contributing fork fixes (shipped in this version's transitive deps)

  • SIPSorcery fork: SortMediaCapability priority-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.

3.1.1 (2026-04-22)

Packaging fix for CI / GitHub Pages consumers

  • Dep-bump to SpawnDev.RTC 1.1.1 (was 1.1.0, which declared a dep on the un-published SIPSorcery 10.0.4-pre fork ID and failed to restore in external builds).
  • SpawnDev.RTC is now a PackageReference (previously ProjectReference) so standalone WebTorrent checkouts build without the sibling RTC repo.
  • SpawnDev.WebTorrent.Server reference to SpawnDev.RTC.Server similarly swapped from ProjectReference to PackageReference.
  • No code changes from 3.1.0.

3.1.0 (2026-04-22 stable)

Renamed fork consumption + tracker-signaling flow

  • 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.

Earlier 3.0.x development

  • 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.).