VpnHood.Net.Quic.Ios
9.1.874
dotnet add package VpnHood.Net.Quic.Ios --version 9.1.874
NuGet\Install-Package VpnHood.Net.Quic.Ios -Version 9.1.874
<PackageReference Include="VpnHood.Net.Quic.Ios" Version="9.1.874" />
<PackageVersion Include="VpnHood.Net.Quic.Ios" Version="9.1.874" />
<PackageReference Include="VpnHood.Net.Quic.Ios" />
paket add VpnHood.Net.Quic.Ios --version 9.1.874
#r "nuget: VpnHood.Net.Quic.Ios, 9.1.874"
#:package VpnHood.Net.Quic.Ios@9.1.874
#addin nuget:?package=VpnHood.Net.Quic.Ios&version=9.1.874
#tool nuget:?package=VpnHood.Net.Quic.Ios&version=9.1.874
VpnHood.Net.Quic.Ios
iOS QUIC client backed by Apple's Network.framework (NWConnectionGroup /
NWMultiplexGroup, iOS 15+). It implements the same VpnHood.Net.Quic.Abstractions surface
(IQuicClient / IQuicConnection / stream) as the desktop MsQuic client, so the tunneling layer
consumes QUIC identically on every platform. There is no MsQuic on iOS — libmsquic is not
shippable inside a Network Extension, so this project bridges to the OS's own QUIC stack instead.
This document is the authoritative record of the iOS QUIC implementation. It lives with the code (not in the iOS app repo) because everything here is QUIC/Network.framework-specific and platform code in
../VpnHood.Net.Quic.Iosis referenced byProjectReference, never copied.
Why this exists (memory, not features)
The driving constraint is the iOS Network Extension ~52 MB jetsam limit. QUIC receive buffering
on iOS lands in native memory (Network.framework), which phys_footprint counts and jetsam
enforces — so the whole design is about bounding native receive buffering while keeping
throughput. See the shared record in the app repo's
docs/ios/ios-extension-memory-and-throughput.md for the broader memory story.
Files
| File | Role |
|---|---|
IosQuicClient.cs |
IQuicClient. ConnectAsync stands up the QUIC tunnel: builds NWParameters.CreateQuic (ALPN h3, flow-control windows, pinned-cert TLS bridge), starts an NWConnectionGroup over an NWMultiplexGroup, waits for Ready. |
IosQuicConnection.cs |
IQuicConnection. Opens outbound streams (nw_connection_group_extract_connection with a NULL endpoint) and accepts inbound (peer-initiated) streams from a channel; brings each up on a queue until Ready. |
IosQuicStream.cs |
Stream adapter over a single QUIC stream (NWConnection). One armed native receive per ReadAsync, no read-ahead; per-operation callback state prevents late native completions from completing a later operation. |
IosQuicStreamReadOperation.cs, IosQuicStreamWriteOperation.cs |
That per-operation state: one read's or one write's value-task source, which its native completion reserves before it touches the caller's buffer. |
ReusableValueTaskSource.cs, ReusableValueTaskSource_1.cs |
IValueTaskSource[<T>] bridge used by read/write operation state to complete Network.framework callbacks as ValueTasks. |
IosQuicTls.cs |
Configures SecProtocolOptions: ALPN + the pinned-certificate verify callback bridge. |
NWConnectionExtensions.cs, NWConnectionGroupExtensions.cs |
Best-effort teardown of a stream and of the group: each call is attempted and logged, never masking the failure that led to it. |
IosQuicDiagnostics.cs, IosQuicEventIds.cs |
The stream counters and [VHQUIC] log lines (see Diagnostics), and their log event id. |
IosQuicClientFactory.cs |
The platform's IQuicClientFactory: the clients with the flow-control credits below. |
Connection & stream model
- One QUIC tunnel =
NWMultiplexGroup+NWConnectionGroup. Streams are individualNWConnections multiplexed over that group. - Outbound stream:
nw_connection_group_extract_connection(group, NULL, NULL)— the native equivalent of Swift'sNWConnection(from: group). The managedExtractConnectionbinding NREs on a null endpoint, and passing the tunnel endpoint / fresh QUIC options makes the extracted stream try to stand up its own transport and fail (ENETDOWN). So we P/Invoke the native function directly with NULL endpoint AND NULL options so the stream inherits the group's tunnel + QUIC options. (See the[DllImport]inIosQuicConnection.) - Inbound streams: the group's new-connection handler (wired before
Start()so none are missed) writes them to an unbounded channel;AcceptInboundStreamAsyncdrains it. - With TCP forwarding there is one QUIC stream per forwarded flow — this is why stream lifecycle and the per-stream window dominate memory (see below).
Flow control (the load-bearing numbers)
IosQuicClientFactory.CreateClient picks them, times its memory scale (at least 1), and
IosQuicClient.ConnectAsync sets them on the QUIC options block; an IosQuicClient made directly
keeps Network.framework's defaults:
| Parameter | Value | Why |
|---|---|---|
InitialMaxData |
16 KB | Connection-wide initial flow-control credit across all streams. Tuned down in steps after measured sub-second native transient spikes near the jetsam limit; prioritizes extension stability over peak iOS download speed. |
InitialMaxStreamDataBidirectionalLocal |
4 KB | Per-stream credit for streams we open (TCP forwarding's download streams). |
InitialMaxStreamDataBidirectionalRemote |
4 KB | Per-stream credit for peer-opened streams. |
IosQuicStream.MaxReceiveLength |
16 KB | Caps a single armed native receive so even a huge caller buffer pulls native data in bounded chunks. |
Without an explicit window, Network.framework advertises a large default and a download burst floods native receive buffers faster than forwarding drains them — measured +16 MB in ~1 s at ~25 streams → jetsam kill. These are the initial credits the client advertises. Network.framework grants more as it buffers data, not as we read it, so they bound no native memory, and the read path has no memory brake of its own.
⚠️ Interaction with the shared LocalTcpStack (read this before tuning windows)
QUIC's tight windows make this transport less forgiving than kernel TCP, which means it
exposes send-side stalls in the shared TCP stack that big OS socket buffers otherwise hide. The
QUIC+TcpForwarding download collapse (40→3 Mbps) was exactly this: a LocalTcpConnection Zero Window
Probe that failed to make forward progress stalled the forwarding pump; kernel TCP masked it, QUIC did
not. That bug and its forward-progress invariant live in
../VpnHood.Net.TcpStack/LocalTcpConnection.cs (SendZeroWindowProbe) with a regression test in
tests/VpnHood.Net.TcpStack.Test. If you shrink these windows, re-verify the TcpStack send path
under sustained download.
Threading
Network.framework delivers a connection's callbacks (receive, FIN/close, state, and Cancel
teardown) serially on its assigned queue. The QUIC tunnel and extracted streams currently share the
same VpnHood.Quic.Ios queue. Stream callbacks are therefore serialized through one queue, with no per-stream queue pool.
Native interop gotchas
- QUIC options re-wrap: the
NWParameters.CreateQuiccallback argument is typed as the baseNWProtocolOptionseven though the native object is a QUIC options block. A direct C# cast toNWProtocolQuicOptionsthrowsInvalidCastException, and thrown on the native trampoline thread it SIGABRTs the process. Re-wrap the same native handle viaRuntime.GetINativeObject<NWProtocolQuicOptions>(handle, owns:false)before setting options. - NULL-endpoint stream extraction: see "Outbound stream" above — direct
[DllImport]ofnw_connection_group_extract_connection, returns a +1-retainednw_connection_t(owns:true). NSAutoreleasePoolwraps native receive/send arming and callbacks to release autoreleased temporaries promptly (matters under burst).
Read / write semantics
- Read: one native
ReceiveData(minimumIncompleteLength: 1, maximumLength: min(buf, 16 KB))perReadAsync; the completion reserves the value-task source before touching the caller buffer (TryReserve()on the read operation'sSource) — if cancellation/Dispose already completed the read, the buffer may have been returned to a shared pool and re-rented, so copying into it would corrupt unrelated memory. Empty completion ⇒ EOF (returns 0); a non-empty final chunk is returned, the EOF is kept, and the next read returns 0 without a native receive. - Write:
isComplete:falsealways (never signals FIN mid-stream). The bytes are copied into aDispatchData, native-owned storage that Network.framework keeps for the asynchronous send. - Dispose:
Cancel()+Dispose()theNWConnection, dispose pending cancellation registrations (native callbacks may never fire afterCancel()), then unblock any pending read/write. Second native completions are no-ops via the value-task source's state guard.
Diagnostics
IosQuicDiagnostics holds the stream counters and the [VHQUIC] +CONN/-CONN live=… id=… log lines,
which confirm streams (= native NWConnections) are released promptly at flow-end rather than
lingering. Its counters move only while it is enabled (a log level below Information), so production pays
nothing; TakeMaxStreamCancelMs resets its figure either way. LiveStreamCount
is public static so the iOS memory probe in another assembly can read it.
Targeting
net*-ios (Apple platforms only); referenced by the iOS device/extension via ProjectReference.
Requires iOS 15+ (IosQuicClient.IsSupported).
</content>
</invoke>
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0-ios27.2 is compatible. net11.0-tvos27.2 is compatible. |
-
net11.0-ios27.2
- VpnHood.Net.Quic.Abstractions (>= 9.1.874)
- VpnHood.Net.Toolkit (>= 9.1.874)
-
net11.0-tvos27.2
- VpnHood.Net.Quic.Abstractions (>= 9.1.874)
- VpnHood.Net.Toolkit (>= 9.1.874)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on VpnHood.Net.Quic.Ios:
| Package | Downloads |
|---|---|
|
VpnHood.Core.Client.Devices.Ios
VpnHood client device provider for iOS and tvOS: the app's side, and what its Network Extension shares |
|
|
VpnHood.Core.Client.VpnServices.Host.Ios
VpnHood's VPN service on iOS and tvOS: the Network Extension's half, which runs the engine |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 9.1.874 | 0 | 11/15/2026 |
| 9.1.864 | 96 | 11/3/2026 |
| 9.0.860 | 107 | 10/31/2026 |
| 9.0.859.16-prerelease | 100 | 10/30/2026 |
| 9.0.857 | 110 | 10/20/2026 |
| 9.0.856 | 100 | 10/19/2026 |
| 9.0.855 | 104 | 10/13/2026 |
| 8.2.853.11-prerelease | 108 | 10/5/2026 |
| 8.2.853 | 124 | 9/23/2026 |
| 8.2.852 | 136 | 9/23/2026 |